openxiangda-cli 2.0.0-alpha.85 → 2.0.0-alpha.89

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 (65) hide show
  1. package/package.json +4 -4
  2. package/template/AGENTS.md +4 -4
  3. package/template/README.md +4 -4
  4. package/template/apps/server/package.json +1 -1
  5. package/template/apps/server/src/app.module.ts +14 -1
  6. package/template/apps/server/test/smoke.test.ts +3 -1
  7. package/template/apps/web/data-api-live.e2e.html +15 -0
  8. package/template/apps/web/e2e/data-api-live-fixture.tsx +141 -0
  9. package/template/apps/web/e2e/data-api-live.spec.ts +138 -0
  10. package/template/apps/web/e2e/field-protocol-fixture.tsx +280 -0
  11. package/template/apps/web/e2e/field-protocol.spec.ts +100 -0
  12. package/template/apps/web/e2e/resources.spec.ts +258 -20
  13. package/template/apps/web/field-protocol.e2e.html +12 -0
  14. package/template/apps/web/package.json +1 -1
  15. package/template/apps/web/playwright.config.ts +3 -1
  16. package/template/apps/web/scripts/check.mjs +8 -5
  17. package/template/apps/web/scripts/verify-build.mjs +1 -1
  18. package/template/apps/web/src/AuthoritativeSelector.tsx +165 -132
  19. package/template/apps/web/src/FilePreviewPage.tsx +30 -9
  20. package/template/apps/web/src/RoleSubjectSelect.tsx +128 -0
  21. package/template/apps/web/src/Shell.tsx +93 -6
  22. package/template/apps/web/src/components/platform-fields/AddressField.tsx +338 -0
  23. package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +25 -6
  24. package/template/apps/web/src/components/platform-fields/CascadeField.tsx +49 -0
  25. package/template/apps/web/src/components/platform-fields/DateTimeField.tsx +148 -0
  26. package/template/apps/web/src/components/platform-fields/JsonField.tsx +77 -0
  27. package/template/apps/web/src/components/platform-fields/LocationField.tsx +164 -0
  28. package/template/apps/web/src/components/platform-fields/PlatformDirectoryPicker.tsx +19 -44
  29. package/template/apps/web/src/components/platform-fields/ResourceReferenceField.tsx +67 -0
  30. package/template/apps/web/src/components/platform-fields/RichTextField.tsx +196 -0
  31. package/template/apps/web/src/components/platform-fields/SignatureField.tsx +315 -0
  32. package/template/apps/web/src/components/platform-fields/SubtableField.tsx +553 -0
  33. package/template/apps/web/src/components/platform-fields/address-value.ts +80 -0
  34. package/template/apps/web/src/components/platform-fields/cascade-value.ts +66 -0
  35. package/template/apps/web/src/components/platform-fields/directory-value.ts +53 -0
  36. package/template/apps/web/src/components/platform-fields/field-form-codec.ts +98 -0
  37. package/template/apps/web/src/components/platform-fields/location-value.ts +87 -0
  38. package/template/apps/web/src/components/platform-fields/resource-query.ts +131 -0
  39. package/template/apps/web/src/components/platform-fields/rich-text-value.ts +59 -0
  40. package/template/apps/web/src/components/platform-fields/subtable-value.ts +187 -0
  41. package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +194 -108
  42. package/template/apps/web/src/components/resource/ResourceBatchActions.tsx +8 -1
  43. package/template/apps/web/src/components/resource/SurfaceFields.tsx +385 -79
  44. package/template/apps/web/src/components/resource/generated-resource-definition.ts +18 -0
  45. package/template/apps/web/src/components/resource/resource-import.ts +4 -4
  46. package/template/apps/web/src/data-provider.ts +59 -34
  47. package/template/apps/web/src/platform-client.ts +341 -136
  48. package/template/apps/web/src/runtime.tsx +156 -17
  49. package/template/apps/web/src/styles.css +264 -0
  50. package/template/apps/web/test/address-field.test.ts +66 -0
  51. package/template/apps/web/test/cascade-value.test.ts +68 -0
  52. package/template/apps/web/test/contracts.test.ts +29 -5
  53. package/template/apps/web/test/directory-value.test.ts +55 -0
  54. package/template/apps/web/test/field-bounds.test.ts +17 -0
  55. package/template/apps/web/test/field-form-codec.test.ts +85 -0
  56. package/template/apps/web/test/location-field.test.ts +83 -0
  57. package/template/apps/web/test/resource-import.test.ts +8 -7
  58. package/template/apps/web/test/resource-query.test.ts +144 -0
  59. package/template/apps/web/test/rich-json-field.test.ts +53 -0
  60. package/template/apps/web/test/signature-serial-field.test.ts +40 -0
  61. package/template/apps/web/test/subtable-value.test.ts +144 -0
  62. package/template/openxiangda.config.ts +1 -1
  63. package/template/package.json +3 -3
  64. package/template/packages/contracts/src/generated.ts +95 -0
  65. package/template/scripts/verify-template-budget.mjs +11 -4
@@ -35,8 +35,13 @@ import {
35
35
  type MenuProps,
36
36
  type TableColumnsType,
37
37
  } from 'antd';
38
- import dayjs from 'dayjs';
39
- import type { DataAuditEntry, DataResourceSurface } from 'openxiangda-contracts/browser';
38
+ import type {
39
+ DataAuditEntry,
40
+ DataResourceSurface,
41
+ DataTransactionOperation,
42
+ SubjectProfile,
43
+ UserReferenceValue,
44
+ } from 'openxiangda-contracts/browser';
40
45
  import { useEffect, useMemo, useState } from 'react';
41
46
  import { useNavigate, useParams } from 'react-router-dom';
42
47
  import { resourceDefinitions } from '../../../../../packages/contracts/src/generated.js';
@@ -56,10 +61,24 @@ import {
56
61
  SurfaceFilterControl,
57
62
  type SurfaceField,
58
63
  } from './SurfaceFields';
59
- import { createNativeResourceClient, type GenericResourceQuery } from '../../platform-client';
60
- import { ResolvedValueText } from '../../AuthoritativeSelector';
64
+ import {
65
+ createNativeResourceClient,
66
+ transactNativeData,
67
+ type GenericResourceQuery,
68
+ } from '../../platform-client';
69
+ import { normalizeFormValues, normalizeRecordForForm } from '../platform-fields/field-form-codec';
70
+ import { DirectoryIdentityText } from '../../AuthoritativeSelector';
61
71
  import { useRuntime } from '../../runtime';
62
72
  import { ResourceBatchActions, ResourceImportButton } from './ResourceBatchActions';
73
+ import {
74
+ SubtableField,
75
+ SubtableValueDisplay,
76
+ } from '../platform-fields/SubtableField';
77
+ import {
78
+ buildSubtableOperations,
79
+ type SubtableDraftRow,
80
+ } from '../platform-fields/subtable-value';
81
+ import type { GeneratedResourceDefinition } from './generated-resource-definition';
63
82
 
64
83
  type ResourceRecord = Record<string, unknown> & {
65
84
  id: string;
@@ -67,19 +86,28 @@ type ResourceRecord = Record<string, unknown> & {
67
86
  updated_at?: string;
68
87
  };
69
88
 
70
- type GeneratedDefinition = {
71
- code: string;
72
- name: string;
73
- capabilities: {
74
- read: string;
75
- create: string;
76
- update: string;
77
- delete: string;
78
- };
79
- surface: DataResourceSurface;
80
- };
89
+ const definitions = resourceDefinitions as unknown as Record<
90
+ string,
91
+ GeneratedResourceDefinition
92
+ >;
81
93
 
82
- const definitions = resourceDefinitions as unknown as Record<string, GeneratedDefinition>;
94
+ function signatureSigner(profile: SubjectProfile | null): UserReferenceValue | undefined {
95
+ if (!profile) return undefined;
96
+ return {
97
+ label: profile.displayName,
98
+ value: profile.userId,
99
+ ...(profile.avatarUrl ? { avatarUrl: profile.avatarUrl } : {}),
100
+ ...(profile.jobNumber ? { employeeNo: profile.jobNumber } : {}),
101
+ ...(profile.affiliatedDepartment
102
+ ? {
103
+ departments: [{
104
+ label: profile.affiliatedDepartment.name,
105
+ value: profile.affiliatedDepartment.id,
106
+ }],
107
+ }
108
+ : {}),
109
+ };
110
+ }
83
111
 
84
112
  function definitionFor(resourceCode: string) {
85
113
  return definitions[resourceCode];
@@ -92,6 +120,7 @@ function fieldFor(surface: DataResourceSurface, key: string): SurfaceField {
92
120
  : {
93
121
  key,
94
122
  label: key,
123
+ type: 'text.short',
95
124
  widget: 'text',
96
125
  readCapabilities: [],
97
126
  createCapabilities: [],
@@ -112,39 +141,16 @@ function fieldsBySection(surface: DataResourceSurface) {
112
141
  }));
113
142
  }
114
143
 
115
- function normalizeRecordForForm(record: ResourceRecord, surface: DataResourceSurface) {
116
- return Object.fromEntries(
117
- Object.entries(record).map(([key, value]) => {
118
- const widget = surface.fields[key]?.widget;
119
- return widget === 'date' || widget === 'datetime' ? [key, value ? dayjs(String(value)) : undefined] : [key, value];
120
- })
121
- );
122
- }
123
-
124
- function normalizeFormValues(values: Record<string, unknown>, surface: DataResourceSurface) {
125
- return Object.fromEntries(
126
- Object.entries(values).map(([key, value]) => {
127
- const widget = surface.fields[key]?.widget;
128
- return widget === 'date' || widget === 'datetime'
129
- ? [
130
- key,
131
- value && typeof value === 'object' && 'format' in value
132
- ? widget === 'datetime'
133
- ? dayjs(value as never).toISOString()
134
- : (value as { format: (pattern: string) => string }).format('YYYY-MM-DD')
135
- : value,
136
- ]
137
- : [key, value];
138
- })
139
- );
140
- }
141
-
142
- function fieldWritable(field: SurfaceField, mode: 'create' | 'edit', hasCapability: (code: string) => boolean, isAppSuperAdmin: boolean) {
144
+ function fieldWriteAuthorized(field: SurfaceField, mode: 'create' | 'edit', hasCapability: (code: string) => boolean, isAppSuperAdmin: boolean) {
143
145
  if (isAppSuperAdmin) return true;
144
146
  const capabilities = mode === 'create' ? field.createCapabilities : field.updateCapabilities;
145
147
  return capabilities.length > 0 && capabilities.every(hasCapability);
146
148
  }
147
149
 
150
+ function fieldWritable(field: SurfaceField, mode: 'create' | 'edit', hasCapability: (code: string) => boolean, isAppSuperAdmin: boolean) {
151
+ return field.widget !== 'readonly' && fieldWriteAuthorized(field, mode, hasCapability, isAppSuperAdmin);
152
+ }
153
+
148
154
  function fieldReadable(field: SurfaceField, hasCapability: (code: string) => boolean, isAppSuperAdmin: boolean) {
149
155
  return isAppSuperAdmin || (field.readCapabilities.length > 0 && field.readCapabilities.every(hasCapability));
150
156
  }
@@ -166,27 +172,6 @@ function auditChangeFields(entry: DataAuditEntry, surface: DataResourceSurface)
166
172
  return fields.filter((field) => Object.prototype.hasOwnProperty.call(source, field.key));
167
173
  }
168
174
 
169
- function exportCellValue(field: SurfaceField, value: unknown) {
170
- if (value === undefined || value === null || value === '') return '';
171
- if (field.widget === 'boolean') return value ? '是' : '否';
172
- if (field.options && !Array.isArray(value)) {
173
- return field.options.find((option) => option.value === value)?.label || String(value);
174
- }
175
- if (Array.isArray(value)) {
176
- return value.map((item) => field.options?.find((option) => option.value === item)?.label || String(item)).join('、');
177
- }
178
- if (field.widget === 'date' || field.widget === 'datetime') {
179
- const parsed = new Date(String(value));
180
- if (!Number.isNaN(parsed.getTime())) return parsed.toLocaleString('zh-CN', { hour12: false });
181
- }
182
- if (typeof value === 'object' && value && 'id' in value) return String((value as { id: unknown }).id);
183
- return String(value);
184
- }
185
-
186
- function csvCell(value: string) {
187
- return /[\r\n,"]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value;
188
- }
189
-
190
175
  async function exportResourceRows(
191
176
  code: string,
192
177
  name: string,
@@ -196,35 +181,21 @@ async function exportResourceRows(
196
181
  sort: { field: string; order: 'asc' | 'desc' }
197
182
  ) {
198
183
  const client = createNativeResourceClient(code, surface);
199
- const rows: ResourceRecord[] = [];
200
- const pageSize = 500;
201
- let page = 1;
202
- let total = 0;
203
- do {
204
- const result = await client.list({
205
- page,
206
- pageSize,
207
- keyword: query.keyword,
208
- searchField: query.searchField,
209
- filters: query.filters,
210
- sort,
211
- });
212
- rows.push(...(result.rows as ResourceRecord[]));
213
- total = result.total;
214
- page += 1;
215
- } while (rows.length < total && rows.length < 10000 && page <= 20);
216
- const header = fields.map((field) => csvCell(field.label)).join(',');
217
- const body = rows.map((row) => fields.map((field) => csvCell(exportCellValue(field, row[field.key]))).join(','));
218
- const blob = new Blob([`\uFEFF${[header, ...body].join('\r\n')}`], {
219
- type: 'text/csv;charset=utf-8',
220
- });
221
- const url = URL.createObjectURL(blob);
184
+ const exported = await client.exportCsv({
185
+ page: 1,
186
+ pageSize: 200,
187
+ keyword: query.keyword,
188
+ searchField: query.searchField,
189
+ filters: query.filters,
190
+ sort,
191
+ }, fields.map((field) => field.key));
192
+ const url = URL.createObjectURL(exported.blob);
222
193
  const anchor = document.createElement('a');
223
194
  anchor.href = url;
224
- anchor.download = `${name}-${dayjs().format('YYYYMMDD-HHmmss')}.csv`;
195
+ anchor.download = exported.fileName || `${name}.csv`;
225
196
  anchor.click();
226
- URL.revokeObjectURL(url);
227
- return { count: rows.length, truncated: rows.length < total };
197
+ window.setTimeout(() => URL.revokeObjectURL(url), 0);
198
+ return { rowLimit: exported.rowLimit };
228
199
  }
229
200
 
230
201
  function AuditEntry({ entry, surface, readable }: { entry: DataAuditEntry; surface: DataResourceSurface; readable: (field: SurfaceField) => boolean }) {
@@ -244,7 +215,7 @@ function AuditEntry({ entry, surface, readable }: { entry: DataAuditEntry; surfa
244
215
  {entry.actor.oauthClientId ? `(${entry.actor.oauthClientId})` : ''}
245
216
  </Typography.Text>
246
217
  ) : (
247
- <ResolvedValueText source="user" value={entry.actor.userId} />
218
+ <DirectoryIdentityText id={entry.actor.userId} source="user" />
248
219
  )}
249
220
  <span>时间</span>
250
221
  <Typography.Text>{new Date(entry.occurredAt).toLocaleString('zh-CN')}</Typography.Text>
@@ -273,7 +244,10 @@ function AuditEntry({ entry, surface, readable }: { entry: DataAuditEntry; surfa
273
244
  );
274
245
  }
275
246
 
276
- function useGeneratedList(definition: GeneratedDefinition, readableFieldCodes: Set<string>) {
247
+ function useGeneratedList(
248
+ definition: GeneratedResourceDefinition,
249
+ readableFieldCodes: Set<string>
250
+ ) {
277
251
  const { code, surface } = definition;
278
252
  const declaredDefaultSort = surface.list?.defaultSort?.field;
279
253
  const defaultSortField =
@@ -328,12 +302,14 @@ export function GeneratedResourcePage({
328
302
  resourceCode,
329
303
  mode = 'list',
330
304
  variant = 'desktop',
305
+ definition: providedDefinition,
331
306
  }: {
332
307
  resourceCode: string;
333
308
  mode?: 'list' | 'create' | 'edit' | 'detail';
334
309
  variant?: 'desktop' | 'mobile';
310
+ definition?: GeneratedResourceDefinition;
335
311
  }) {
336
- const definition = definitionFor(resourceCode);
312
+ const definition = providedDefinition || definitionFor(resourceCode);
337
313
  if (!definition || definition.surface.generated === false) {
338
314
  return <Result status="404" title="未声明可生成的资源页面" />;
339
315
  }
@@ -343,11 +319,11 @@ export function GeneratedResourcePage({
343
319
  return <GeneratedResourceFormPage key={pageKey} definition={definition} mode={mode} variant={variant} />;
344
320
  }
345
321
 
346
- function GeneratedResourceListPage({ definition, variant }: { definition: GeneratedDefinition; variant: 'desktop' | 'mobile' }) {
322
+ function GeneratedResourceListPage({ definition, variant }: { definition: GeneratedResourceDefinition; variant: 'desktop' | 'mobile' }) {
347
323
  return variant === 'mobile' ? <GeneratedMobileList definition={definition} /> : <GeneratedDesktopList definition={definition} />;
348
324
  }
349
325
 
350
- function GeneratedDesktopList({ definition }: { definition: GeneratedDefinition }) {
326
+ function GeneratedDesktopList({ definition }: { definition: GeneratedResourceDefinition }) {
351
327
  const navigate = useNavigate();
352
328
  const { message } = App.useApp();
353
329
  const { hasCapability, identity } = useRuntime();
@@ -398,6 +374,7 @@ function GeneratedDesktopList({ definition }: { definition: GeneratedDefinition
398
374
  render: (value: unknown, record: ResourceRecord) => (
399
375
  <SurfaceFieldValue
400
376
  field={field}
377
+ resourceCode={code}
401
378
  value={value}
402
379
  renderers={{
403
380
  renderValue: ({ value: current }) =>
@@ -474,7 +451,7 @@ function GeneratedDesktopList({ definition }: { definition: GeneratedDefinition
474
451
  setExporting(true);
475
452
  try {
476
453
  const result = await exportResourceRows(code, name, surface, fields, state.query, state.sort);
477
- message.success(result.truncated ? `已导出 ${result.count} 条(最多导出 10000 条)` : `已导出 ${result.count} 条`);
454
+ message.success(`导出已生成(最多 ${result.rowLimit} 条)`);
478
455
  } catch (error) {
479
456
  message.error(error instanceof Error ? error.message : '导出失败');
480
457
  } finally {
@@ -569,6 +546,7 @@ function GeneratedDesktopList({ definition }: { definition: GeneratedDefinition
569
546
  <div className={`oxa-filter-item${index >= 2 && !filtersExpanded ? ' is-collapsed' : ''}`} key={key}>
570
547
  <SurfaceFilterControl
571
548
  field={field}
549
+ resourceCode={code}
572
550
  onChange={(value) =>
573
551
  state.setDraft((item) => ({
574
552
  ...item,
@@ -708,7 +686,7 @@ function GeneratedDesktopList({ definition }: { definition: GeneratedDefinition
708
686
  );
709
687
  }
710
688
 
711
- function GeneratedMobileList({ definition }: { definition: GeneratedDefinition }) {
689
+ function GeneratedMobileList({ definition }: { definition: GeneratedResourceDefinition }) {
712
690
  const navigate = useNavigate();
713
691
  const { hasCapability, identity } = useRuntime();
714
692
  const { message } = App.useApp();
@@ -728,7 +706,7 @@ function GeneratedMobileList({ definition }: { definition: GeneratedDefinition }
728
706
  setExporting(true);
729
707
  try {
730
708
  const result = await exportResourceRows(code, name, surface, fields, state.query, state.sort);
731
- message.success(result.truncated ? `已导出 ${result.count} 条(最多导出 10000 条)` : `已导出 ${result.count} 条`);
709
+ message.success(`导出已生成(最多 ${result.rowLimit} 条)`);
732
710
  } catch (error) {
733
711
  message.error(error instanceof Error ? error.message : '导出失败');
734
712
  } finally {
@@ -798,7 +776,7 @@ function GeneratedMobileList({ definition }: { definition: GeneratedDefinition }
798
776
  <div key={field.key}>
799
777
  <Typography.Text type="secondary">{field.label}</Typography.Text>
800
778
  <div>
801
- <SurfaceFieldValue field={field} value={record[field.key]} />
779
+ <SurfaceFieldValue field={field} resourceCode={code} value={record[field.key]} />
802
780
  </div>
803
781
  </div>
804
782
  ))}
@@ -839,7 +817,7 @@ function GeneratedResourceFormPage({
839
817
  mode,
840
818
  variant,
841
819
  }: {
842
- definition: GeneratedDefinition;
820
+ definition: GeneratedResourceDefinition;
843
821
  mode: 'create' | 'edit';
844
822
  variant: 'desktop' | 'mobile';
845
823
  }) {
@@ -850,6 +828,7 @@ function GeneratedResourceFormPage({
850
828
  const [form] = Form.useForm();
851
829
  const create = useCreate<ResourceRecord>();
852
830
  const update = useUpdate<ResourceRecord>();
831
+ const [transactionPending, setTransactionPending] = useState(false);
853
832
  const query = useOne<ResourceRecord>({
854
833
  resource: definition.code,
855
834
  id,
@@ -867,19 +846,102 @@ function GeneratedResourceFormPage({
867
846
  if (!hasCapability(mode === 'create' ? capabilities.create : capabilities.update))
868
847
  return <Result status="403" title="当前平台用户无此操作权限" />;
869
848
  const submit = async (values: Record<string, unknown>) => {
870
- const next = normalizeFormValues(
871
- Object.fromEntries(
872
- Object.entries(values).filter(([key]) =>
849
+ const writableValues = Object.fromEntries(
850
+ Object.entries(values).filter(([key]) =>
851
+ fieldWritable(
852
+ fieldFor(surface, key),
853
+ mode,
854
+ hasCapability,
855
+ identity.isAppSuperAdmin
856
+ )
857
+ )
858
+ );
859
+ const subtableFields = Object.entries(surface.fields)
860
+ .map(([key, field]) => ({ key, ...field }))
861
+ .filter(
862
+ field =>
863
+ field.type === 'subtable' &&
873
864
  fieldWritable(
874
- fieldFor(surface, key),
865
+ field,
875
866
  mode,
876
867
  hasCapability,
877
868
  identity.isAppSuperAdmin
878
869
  )
870
+ );
871
+ const next = normalizeFormValues(
872
+ Object.fromEntries(
873
+ Object.entries(writableValues).filter(
874
+ ([key]) => surface.fields[key]?.type !== 'subtable'
879
875
  )
880
876
  ),
881
877
  surface
882
878
  );
879
+ if (subtableFields.length > 0) {
880
+ if (mode === 'edit' && (!record?.id || !Number.isSafeInteger(record.revision))) {
881
+ throw new Error('OPENXIANGDA_SUBTABLE_PARENT_REVISION_REQUIRED');
882
+ }
883
+ const operations: DataTransactionOperation[] = [
884
+ mode === 'create'
885
+ ? { operation: 'create', resourceCode: code, data: next }
886
+ : {
887
+ operation: 'update',
888
+ resourceCode: code,
889
+ id: record!.id,
890
+ expectedRevision: record!.revision,
891
+ data: next,
892
+ },
893
+ ];
894
+ for (const subtableField of subtableFields) {
895
+ const config = subtableField.subtable;
896
+ const childDefinition = config
897
+ ? definitionFor(config.resourceCode)
898
+ : undefined;
899
+ if (!config || !childDefinition) {
900
+ throw new Error(`OPENXIANGDA_SUBTABLE_DEFINITION_MISSING:${subtableField.key}`);
901
+ }
902
+ operations.push(
903
+ ...buildSubtableOperations({
904
+ rows: Array.isArray(writableValues[subtableField.key])
905
+ ? writableValues[subtableField.key] as SubtableDraftRow[]
906
+ : [],
907
+ childResourceCode: config.resourceCode,
908
+ childSurface: childDefinition.surface,
909
+ foreignKey: config.foreignKey,
910
+ orderField: config.orderField,
911
+ maxRows: config.maxRows,
912
+ parent: mode === 'create'
913
+ ? { operation: 'create', operationIndex: 0 }
914
+ : { operation: 'update', id: record!.id },
915
+ canWrite: (fieldCode, childField, childOperation) =>
916
+ fieldWriteAuthorized(
917
+ { key: fieldCode, ...childField },
918
+ childOperation === 'create' ? 'create' : 'edit',
919
+ hasCapability,
920
+ identity.isAppSuperAdmin
921
+ ),
922
+ canDelete:
923
+ identity.isAppSuperAdmin ||
924
+ hasCapability(childDefinition.capabilities.delete),
925
+ })
926
+ );
927
+ }
928
+ if (operations.length > 100) {
929
+ throw new Error('OPENXIANGDA_DATA_TRANSACTION_TOO_LARGE');
930
+ }
931
+ setTransactionPending(true);
932
+ try {
933
+ const result = await transactNativeData(operations);
934
+ const parent = result.items[0];
935
+ if (!parent || parent.operation === 'emitEvent') {
936
+ throw new Error('OPENXIANGDA_SUBTABLE_PARENT_RESULT_MISSING');
937
+ }
938
+ message.success(mode === 'create' ? `${name}已创建` : '修改已保存');
939
+ navigate(mode === 'create' ? listPath : `/${code}/${parent.id}`);
940
+ } finally {
941
+ setTransactionPending(false);
942
+ }
943
+ return;
944
+ }
883
945
  if (mode === 'create') {
884
946
  await create.mutateAsync({ resource: code, values: next });
885
947
  message.success(`${name}已创建`);
@@ -931,8 +993,19 @@ function GeneratedResourceFormPage({
931
993
  disabled={!fieldWritable(field, mode, hasCapability, identity.isAppSuperAdmin)}
932
994
  field={field}
933
995
  operation={mode === 'create' ? 'create' : 'update'}
996
+ resourceCode={code}
934
997
  recordId={record?.id}
935
998
  renderers={{
999
+ renderSubtable: (context) => (
1000
+ <SubtableField
1001
+ disabled={context.disabled}
1002
+ field={context.field}
1003
+ mobile={variant === 'mobile'}
1004
+ operation={context.operation}
1005
+ parentRecordId={context.recordId}
1006
+ />
1007
+ ),
1008
+ signer: signatureSigner(identity.subjectProfile),
936
1009
  upload: (current, file, recordId) => createNativeResourceClient(code, surface).upload(current.key, file, recordId),
937
1010
  }}
938
1011
  />
@@ -943,7 +1016,7 @@ function GeneratedResourceFormPage({
943
1016
  </div>
944
1017
  <div className="oxa-actions">
945
1018
  <Button onClick={() => navigate(mode === 'edit' ? detailPath : listPath)}>取消</Button>
946
- <Button htmlType="submit" loading={create.mutation.isPending || update.mutation.isPending} type="primary">
1019
+ <Button htmlType="submit" loading={create.mutation.isPending || update.mutation.isPending || transactionPending} type="primary">
947
1020
  {mode === 'create' ? '保存' : '保存修改'}
948
1021
  </Button>
949
1022
  </div>
@@ -952,7 +1025,7 @@ function GeneratedResourceFormPage({
952
1025
  );
953
1026
  }
954
1027
 
955
- function GeneratedResourceDetailPage({ definition, variant }: { definition: GeneratedDefinition; variant: 'desktop' | 'mobile' }) {
1028
+ function GeneratedResourceDetailPage({ definition, variant }: { definition: GeneratedResourceDefinition; variant: 'desktop' | 'mobile' }) {
956
1029
  const { id = '' } = useParams();
957
1030
  const navigate = useNavigate();
958
1031
  const { hasCapability, identity } = useRuntime();
@@ -1041,7 +1114,20 @@ function GeneratedResourceDetailPage({ definition, variant }: { definition: Gene
1041
1114
  items={group.fields.map((field) => ({
1042
1115
  key: field.key,
1043
1116
  label: field.label,
1044
- children: <SurfaceFieldValue field={field} value={record[field.key]} />,
1117
+ span: field.type === 'subtable' ? 3 : 1,
1118
+ children: field.type === 'subtable' ? (
1119
+ <SubtableValueDisplay
1120
+ field={field}
1121
+ mobile={variant === 'mobile'}
1122
+ parentRecordId={record.id}
1123
+ />
1124
+ ) : (
1125
+ <SurfaceFieldValue
1126
+ field={field}
1127
+ resourceCode={definition.code}
1128
+ value={record[field.key]}
1129
+ />
1130
+ ),
1045
1131
  }))}
1046
1132
  />
1047
1133
  </Card>
@@ -281,7 +281,14 @@ export function ResourceBatchActions({
281
281
  }}
282
282
  />
283
283
  </Form.Item>
284
- {field && <SurfaceFieldControl disabled={false} field={{ ...field, requiredHint: true }} operation="update" />}
284
+ {field && (
285
+ <SurfaceFieldControl
286
+ disabled={false}
287
+ field={{ ...field, requiredHint: true }}
288
+ operation="update"
289
+ resourceCode={code}
290
+ />
291
+ )}
285
292
  </Form>
286
293
  </Modal>
287
294
  <Modal