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

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 (62) hide show
  1. package/package.json +4 -4
  2. package/template/AGENTS.md +4 -4
  3. package/template/README.md +2 -2
  4. package/template/apps/server/package.json +1 -1
  5. package/template/apps/web/data-api-live.e2e.html +15 -0
  6. package/template/apps/web/e2e/data-api-live-fixture.tsx +141 -0
  7. package/template/apps/web/e2e/data-api-live.spec.ts +138 -0
  8. package/template/apps/web/e2e/field-protocol-fixture.tsx +280 -0
  9. package/template/apps/web/e2e/field-protocol.spec.ts +100 -0
  10. package/template/apps/web/e2e/resources.spec.ts +258 -20
  11. package/template/apps/web/field-protocol.e2e.html +12 -0
  12. package/template/apps/web/package.json +1 -1
  13. package/template/apps/web/playwright.config.ts +3 -1
  14. package/template/apps/web/scripts/check.mjs +8 -5
  15. package/template/apps/web/scripts/verify-build.mjs +1 -1
  16. package/template/apps/web/src/AuthoritativeSelector.tsx +165 -132
  17. package/template/apps/web/src/FilePreviewPage.tsx +30 -9
  18. package/template/apps/web/src/RoleSubjectSelect.tsx +128 -0
  19. package/template/apps/web/src/Shell.tsx +93 -6
  20. package/template/apps/web/src/components/platform-fields/AddressField.tsx +338 -0
  21. package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +25 -6
  22. package/template/apps/web/src/components/platform-fields/CascadeField.tsx +49 -0
  23. package/template/apps/web/src/components/platform-fields/DateTimeField.tsx +148 -0
  24. package/template/apps/web/src/components/platform-fields/JsonField.tsx +77 -0
  25. package/template/apps/web/src/components/platform-fields/LocationField.tsx +164 -0
  26. package/template/apps/web/src/components/platform-fields/PlatformDirectoryPicker.tsx +19 -44
  27. package/template/apps/web/src/components/platform-fields/ResourceReferenceField.tsx +67 -0
  28. package/template/apps/web/src/components/platform-fields/RichTextField.tsx +196 -0
  29. package/template/apps/web/src/components/platform-fields/SignatureField.tsx +315 -0
  30. package/template/apps/web/src/components/platform-fields/SubtableField.tsx +553 -0
  31. package/template/apps/web/src/components/platform-fields/address-value.ts +80 -0
  32. package/template/apps/web/src/components/platform-fields/cascade-value.ts +66 -0
  33. package/template/apps/web/src/components/platform-fields/directory-value.ts +53 -0
  34. package/template/apps/web/src/components/platform-fields/field-form-codec.ts +98 -0
  35. package/template/apps/web/src/components/platform-fields/location-value.ts +87 -0
  36. package/template/apps/web/src/components/platform-fields/resource-query.ts +131 -0
  37. package/template/apps/web/src/components/platform-fields/rich-text-value.ts +59 -0
  38. package/template/apps/web/src/components/platform-fields/subtable-value.ts +187 -0
  39. package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +194 -108
  40. package/template/apps/web/src/components/resource/ResourceBatchActions.tsx +8 -1
  41. package/template/apps/web/src/components/resource/SurfaceFields.tsx +385 -79
  42. package/template/apps/web/src/components/resource/generated-resource-definition.ts +18 -0
  43. package/template/apps/web/src/components/resource/resource-import.ts +4 -4
  44. package/template/apps/web/src/data-provider.ts +59 -34
  45. package/template/apps/web/src/platform-client.ts +341 -136
  46. package/template/apps/web/src/runtime.tsx +156 -17
  47. package/template/apps/web/src/styles.css +264 -0
  48. package/template/apps/web/test/address-field.test.ts +66 -0
  49. package/template/apps/web/test/cascade-value.test.ts +68 -0
  50. package/template/apps/web/test/contracts.test.ts +29 -5
  51. package/template/apps/web/test/directory-value.test.ts +55 -0
  52. package/template/apps/web/test/field-bounds.test.ts +17 -0
  53. package/template/apps/web/test/field-form-codec.test.ts +85 -0
  54. package/template/apps/web/test/location-field.test.ts +83 -0
  55. package/template/apps/web/test/resource-import.test.ts +8 -7
  56. package/template/apps/web/test/resource-query.test.ts +144 -0
  57. package/template/apps/web/test/rich-json-field.test.ts +53 -0
  58. package/template/apps/web/test/signature-serial-field.test.ts +40 -0
  59. package/template/apps/web/test/subtable-value.test.ts +144 -0
  60. package/template/package.json +3 -3
  61. package/template/packages/contracts/src/generated.ts +69 -0
  62. package/template/scripts/verify-template-budget.mjs +11 -4
@@ -0,0 +1,53 @@
1
+ import type {
2
+ DepartmentReferenceValue,
3
+ DirectoryEntry,
4
+ UserReferenceValue,
5
+ } from 'openxiangda-contracts/browser';
6
+ import type { DirectoryKind } from '../../platform-client';
7
+
8
+ export type DirectoryStoredValue = UserReferenceValue | DepartmentReferenceValue;
9
+
10
+ export function directoryStoredValues(
11
+ value?: DirectoryStoredValue | DirectoryStoredValue[]
12
+ ) {
13
+ return Array.isArray(value) ? value : value ? [value] : [];
14
+ }
15
+
16
+ export function directoryEntryFromStored(
17
+ kind: DirectoryKind,
18
+ value: DirectoryStoredValue
19
+ ): DirectoryEntry {
20
+ const description = kind === 'user'
21
+ ? [
22
+ (value as UserReferenceValue).employeeNo,
23
+ (value as UserReferenceValue).departments?.map(item => item.label).join(' / '),
24
+ ].filter(Boolean).join(' · ')
25
+ : (value as DepartmentReferenceValue).fullPath || '';
26
+ return {
27
+ kind,
28
+ id: value.value,
29
+ label: value.label,
30
+ snapshot: value,
31
+ ...(description ? { description } : {}),
32
+ ...(kind === 'department' && (value as DepartmentReferenceValue).parent
33
+ ? { parentId: (value as DepartmentReferenceValue).parent!.value }
34
+ : {}),
35
+ ...(kind === 'department' && (value as DepartmentReferenceValue).path?.length
36
+ ? {
37
+ path: (value as DepartmentReferenceValue).path!.map(item => ({
38
+ id: item.value,
39
+ label: item.label,
40
+ })),
41
+ }
42
+ : {}),
43
+ selectable: true,
44
+ };
45
+ }
46
+
47
+ export function directoryStoredValueFromEntries(
48
+ entries: DirectoryEntry[],
49
+ multiple: boolean
50
+ ) {
51
+ const snapshots = entries.map(entry => entry.snapshot);
52
+ return multiple ? snapshots : snapshots[0];
53
+ }
@@ -0,0 +1,98 @@
1
+ import dayjs, { type Dayjs } from 'dayjs';
2
+ import type { DataFieldSurface, DataResourceSurface } from 'openxiangda-contracts/browser';
3
+
4
+ function dateTimeForForm(value: unknown) {
5
+ return value ? dayjs(String(value)) : undefined;
6
+ }
7
+
8
+ function timeForForm(value: unknown) {
9
+ return value ? dayjs(`2000-01-01T${String(value)}`) : undefined;
10
+ }
11
+
12
+ function rangeForForm(value: unknown, kind: 'date' | 'datetime') {
13
+ if (!value || typeof value !== 'object') return undefined;
14
+ const range = value as { start?: unknown; end?: unknown };
15
+ if (!range.start || !range.end) return undefined;
16
+ return kind === 'date'
17
+ ? [dayjs(String(range.start)), dayjs(String(range.end))]
18
+ : [dateTimeForForm(range.start), dateTimeForForm(range.end)];
19
+ }
20
+
21
+ function dayjsValue(value: unknown): Dayjs | undefined {
22
+ return value && typeof value === 'object' && 'format' in value
23
+ ? value as Dayjs
24
+ : undefined;
25
+ }
26
+
27
+ function rangeValue(value: unknown) {
28
+ return Array.isArray(value) && value.length === 2
29
+ ? [dayjsValue(value[0]), dayjsValue(value[1])] as const
30
+ : undefined;
31
+ }
32
+
33
+ export function fieldValueForForm(field: DataFieldSurface | undefined, value: unknown) {
34
+ switch (field?.type) {
35
+ case 'date':
36
+ case 'datetime':
37
+ return dateTimeForForm(value);
38
+ case 'time':
39
+ return timeForForm(value);
40
+ case 'date-range':
41
+ return rangeForForm(value, 'date');
42
+ case 'datetime-range':
43
+ return rangeForForm(value, 'datetime');
44
+ default:
45
+ return value;
46
+ }
47
+ }
48
+
49
+ export function fieldValueForData(field: DataFieldSurface | undefined, value: unknown) {
50
+ if (value === undefined || value === null) return value;
51
+ const scalar = dayjsValue(value);
52
+ switch (field?.type) {
53
+ case 'date':
54
+ return scalar?.format('YYYY-MM-DD') ?? value;
55
+ case 'time':
56
+ return scalar?.format(field.timePrecision === 'minute' ? 'HH:mm' : 'HH:mm:ss') ?? value;
57
+ case 'datetime':
58
+ return scalar?.toISOString() ?? value;
59
+ case 'date-range': {
60
+ const range = rangeValue(value);
61
+ return range?.[0] && range[1]
62
+ ? { start: range[0].format('YYYY-MM-DD'), end: range[1].format('YYYY-MM-DD') }
63
+ : value;
64
+ }
65
+ case 'datetime-range': {
66
+ const range = rangeValue(value);
67
+ return range?.[0] && range[1]
68
+ ? { start: range[0].toISOString(), end: range[1].toISOString() }
69
+ : value;
70
+ }
71
+ default:
72
+ return value;
73
+ }
74
+ }
75
+
76
+ export function normalizeRecordForForm(
77
+ record: Record<string, unknown>,
78
+ surface: DataResourceSurface
79
+ ) {
80
+ return Object.fromEntries(
81
+ Object.entries(record).map(([key, value]) => [
82
+ key,
83
+ fieldValueForForm(surface.fields[key], value),
84
+ ])
85
+ );
86
+ }
87
+
88
+ export function normalizeFormValues(
89
+ values: Record<string, unknown>,
90
+ surface: DataResourceSurface
91
+ ) {
92
+ return Object.fromEntries(
93
+ Object.entries(values).map(([key, value]) => [
94
+ key,
95
+ fieldValueForData(surface.fields[key], value),
96
+ ])
97
+ );
98
+ }
@@ -0,0 +1,87 @@
1
+ import type { StableLocationValue } from 'openxiangda-contracts/browser';
2
+
3
+ export interface BrowserCoordinateSnapshot {
4
+ longitude: unknown;
5
+ latitude: unknown;
6
+ accuracy?: unknown;
7
+ timestamp?: number;
8
+ }
9
+
10
+ export interface DingTalkCoordinateSnapshot {
11
+ longitude?: unknown;
12
+ latitude?: unknown;
13
+ accuracy?: unknown;
14
+ address?: unknown;
15
+ poiName?: unknown;
16
+ province?: unknown;
17
+ city?: unknown;
18
+ district?: unknown;
19
+ }
20
+
21
+ function coordinate(value: unknown, axis: 'longitude' | 'latitude') {
22
+ if (value === undefined || value === null || value === '') {
23
+ throw new Error(axis === 'longitude' ? '定位结果缺少有效经度' : '定位结果缺少有效纬度');
24
+ }
25
+ const parsed = typeof value === 'number' ? value : Number(String(value ?? '').trim());
26
+ const limit = axis === 'longitude' ? 180 : 90;
27
+ if (!Number.isFinite(parsed) || parsed < -limit || parsed > limit) {
28
+ throw new Error(axis === 'longitude' ? '定位结果缺少有效经度' : '定位结果缺少有效纬度');
29
+ }
30
+ return parsed;
31
+ }
32
+
33
+ function accuracy(value: unknown) {
34
+ if (value === undefined || value === null || value === '') return undefined;
35
+ const parsed = typeof value === 'number' ? value : Number(String(value).trim());
36
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
37
+ }
38
+
39
+ function optionalText(value: unknown) {
40
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
41
+ }
42
+
43
+ function capturedAt(timestamp?: number) {
44
+ const date = new Date(
45
+ typeof timestamp === 'number' && Number.isFinite(timestamp) && timestamp > 0
46
+ ? timestamp
47
+ : Date.now()
48
+ );
49
+ return date.toISOString();
50
+ }
51
+
52
+ export function browserLocationValue(
53
+ snapshot: BrowserCoordinateSnapshot
54
+ ): StableLocationValue {
55
+ const capturedAccuracy = accuracy(snapshot.accuracy);
56
+ return {
57
+ source: 'browser',
58
+ longitude: coordinate(snapshot.longitude, 'longitude'),
59
+ latitude: coordinate(snapshot.latitude, 'latitude'),
60
+ ...(capturedAccuracy === undefined ? {} : { accuracy: capturedAccuracy }),
61
+ capturedAt: capturedAt(snapshot.timestamp),
62
+ };
63
+ }
64
+
65
+ export function dingTalkLocationValue(
66
+ snapshot: DingTalkCoordinateSnapshot,
67
+ timestamp = Date.now()
68
+ ): StableLocationValue {
69
+ const capturedAccuracy = accuracy(snapshot.accuracy);
70
+ const address = optionalText(snapshot.address);
71
+ const name = optionalText(snapshot.poiName);
72
+ const province = optionalText(snapshot.province);
73
+ const city = optionalText(snapshot.city);
74
+ const district = optionalText(snapshot.district);
75
+ return {
76
+ source: 'dingTalk',
77
+ longitude: coordinate(snapshot.longitude, 'longitude'),
78
+ latitude: coordinate(snapshot.latitude, 'latitude'),
79
+ ...(capturedAccuracy === undefined ? {} : { accuracy: capturedAccuracy }),
80
+ ...(address === undefined ? {} : { address }),
81
+ ...(name === undefined ? {} : { name }),
82
+ ...(province === undefined ? {} : { province }),
83
+ ...(city === undefined ? {} : { city }),
84
+ ...(district === undefined ? {} : { district }),
85
+ capturedAt: capturedAt(timestamp),
86
+ };
87
+ }
@@ -0,0 +1,131 @@
1
+ import type {
2
+ DataResourceSurface,
3
+ DataWhere,
4
+ ResourceReferenceValue,
5
+ StableAddressValue,
6
+ } from 'openxiangda-contracts/browser';
7
+ import {
8
+ cascadeTerminalValues,
9
+ type CascadeStoredValue,
10
+ } from './cascade-value';
11
+ import { deepestAddressPredicate } from './address-value';
12
+
13
+ export interface GenericResourceQuery {
14
+ page: number;
15
+ pageSize: number;
16
+ keyword?: string;
17
+ searchField?: string;
18
+ filters?: Record<string, unknown>;
19
+ sort?: { field: string; order: 'asc' | 'desc' };
20
+ }
21
+
22
+ function stableValues(value: unknown) {
23
+ const values = Array.isArray(value) ? value : [value];
24
+ return values.map(item =>
25
+ item && typeof item === 'object' && 'value' in item
26
+ ? String((item as ResourceReferenceValue).value)
27
+ : item
28
+ );
29
+ }
30
+
31
+ export function buildResourceWhere(
32
+ resourceCode: string,
33
+ surface: DataResourceSurface,
34
+ query: GenericResourceQuery
35
+ ): DataWhere | undefined {
36
+ const declaredFields = new Set(Object.keys(surface.fields));
37
+ const assertField = (field: string) => {
38
+ if (!declaredFields.has(field)) {
39
+ throw new Error(
40
+ `OPENXIANGDA_RESOURCE_QUERY_FIELD_NOT_DECLARED:${resourceCode}:${field}`
41
+ );
42
+ }
43
+ return field;
44
+ };
45
+ const predicates: DataWhere[] = [];
46
+ if (query.keyword?.trim()) {
47
+ const searchable = surface.list?.searchableFields || [];
48
+ const field = searchable.includes(query.searchField || '')
49
+ ? query.searchField!
50
+ : searchable[0];
51
+ if (field) {
52
+ predicates.push({
53
+ field: assertField(field),
54
+ operator: 'contains',
55
+ value: query.keyword.trim(),
56
+ });
57
+ }
58
+ }
59
+ for (const [fieldCode, value] of Object.entries(query.filters || {})) {
60
+ if (
61
+ value === undefined ||
62
+ value === null ||
63
+ value === '' ||
64
+ (Array.isArray(value) && value.length === 0)
65
+ ) continue;
66
+ // A list state can outlive a resource route during client-side navigation.
67
+ if (!declaredFields.has(fieldCode)) continue;
68
+ const field = surface.fields[fieldCode];
69
+ if (!field) continue;
70
+ if (field.type === 'json') {
71
+ predicates.push({
72
+ field: fieldCode,
73
+ operator: 'jsonContains',
74
+ value,
75
+ });
76
+ } else if (field.type === 'address') {
77
+ const address = deepestAddressPredicate(value as StableAddressValue);
78
+ if (address) {
79
+ predicates.push({
80
+ field: fieldCode,
81
+ operator: 'jsonContains',
82
+ path: address.path,
83
+ value: address.value,
84
+ });
85
+ }
86
+ } else if (field.type === 'cascade.single' || field.type === 'cascade.multiple') {
87
+ const terminals = cascadeTerminalValues(
88
+ value as CascadeStoredValue,
89
+ field.type === 'cascade.multiple'
90
+ );
91
+ if (terminals.length) {
92
+ predicates.push({
93
+ field: fieldCode,
94
+ operator: field.type === 'cascade.multiple' ? 'hasAny' : 'has',
95
+ value: field.type === 'cascade.multiple' ? terminals : terminals[0],
96
+ });
97
+ }
98
+ } else if (
99
+ (field.type === 'date-range' || field.type === 'datetime-range') &&
100
+ typeof value === 'object'
101
+ ) {
102
+ predicates.push({ field: fieldCode, operator: 'overlaps', value });
103
+ } else if (
104
+ Array.isArray(value) &&
105
+ value.length === 2 &&
106
+ ['date', 'datetime', 'number', 'money', 'percent'].includes(field.widget)
107
+ ) {
108
+ if (value[0] !== undefined && value[0] !== null && value[0] !== '') {
109
+ predicates.push({ field: fieldCode, operator: 'gte', value: value[0] });
110
+ }
111
+ if (value[1] !== undefined && value[1] !== null && value[1] !== '') {
112
+ predicates.push({ field: fieldCode, operator: 'lte', value: value[1] });
113
+ }
114
+ } else if (
115
+ field.type === 'option.multiple' ||
116
+ field.type === 'user.multiple' ||
117
+ field.type === 'department.multiple' ||
118
+ field.type === 'resource-ref.multiple'
119
+ ) {
120
+ predicates.push({
121
+ field: fieldCode,
122
+ operator: 'hasAny',
123
+ value: stableValues(value),
124
+ });
125
+ } else {
126
+ const [stable] = stableValues(value);
127
+ predicates.push({ field: fieldCode, operator: 'eq', value: stable });
128
+ }
129
+ }
130
+ return predicates.length ? { and: predicates } : undefined;
131
+ }
@@ -0,0 +1,59 @@
1
+ const ALLOWED_TAGS = new Set([
2
+ 'p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'blockquote', 'pre', 'code',
3
+ 'ul', 'ol', 'li', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'table',
4
+ 'thead', 'tbody', 'tr', 'th', 'td', 'img',
5
+ ]);
6
+
7
+ const ALLOWED_ATTRIBUTES: Record<string, Set<string>> = {
8
+ a: new Set(['href', 'target', 'rel']),
9
+ img: new Set(['src', 'alt', 'title', 'width', 'height']),
10
+ th: new Set(['colspan', 'rowspan']),
11
+ td: new Set(['colspan', 'rowspan']),
12
+ };
13
+
14
+ const MANAGED_IMAGE =
15
+ /^\/service\/openxiangda-api\/v2\/applications\/[^/?#]+\/native\/data\/[^/?#]+\/files\/[0-9a-f-]+\/content\?disposition=inline$/i;
16
+
17
+ function safeLink(value: string) {
18
+ return /^(?:https?:|mailto:|tel:)/i.test(value.trim());
19
+ }
20
+
21
+ /** Browser-side defense in depth. The platform repeats this allowlist on write. */
22
+ export function sanitizeRichText(value: string) {
23
+ if (typeof DOMParser === 'undefined') return value;
24
+ const document = new DOMParser().parseFromString(value, 'text/html');
25
+ for (const element of [...document.body.querySelectorAll('*')]) {
26
+ const tag = element.tagName.toLowerCase();
27
+ if (!ALLOWED_TAGS.has(tag)) {
28
+ if (tag === 'script' || tag === 'style') element.remove();
29
+ else element.replaceWith(...element.childNodes);
30
+ continue;
31
+ }
32
+ const attributes = ALLOWED_ATTRIBUTES[tag] || new Set<string>();
33
+ for (const attribute of [...element.attributes]) {
34
+ if (!attributes.has(attribute.name.toLowerCase())) {
35
+ element.removeAttribute(attribute.name);
36
+ }
37
+ }
38
+ if (tag === 'a') {
39
+ const href = element.getAttribute('href');
40
+ if (href && !safeLink(href)) element.removeAttribute('href');
41
+ element.setAttribute('rel', 'noopener noreferrer');
42
+ }
43
+ if (tag === 'img') {
44
+ const source = element.getAttribute('src') || '';
45
+ if (!MANAGED_IMAGE.test(source)) element.remove();
46
+ }
47
+ }
48
+ return document.body.innerHTML;
49
+ }
50
+
51
+ export function richTextPlainText(value: string) {
52
+ if (typeof DOMParser === 'undefined') {
53
+ return value.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
54
+ }
55
+ return new DOMParser()
56
+ .parseFromString(value, 'text/html')
57
+ .body.textContent?.replace(/\s+/g, ' ')
58
+ .trim() || '';
59
+ }
@@ -0,0 +1,187 @@
1
+ import type {
2
+ DataFieldSurface,
3
+ DataResourceSurface,
4
+ DataTransactionOperation,
5
+ DataTransactionOperationReference,
6
+ } from 'openxiangda-contracts/browser';
7
+ import { normalizeFormValues } from './field-form-codec';
8
+
9
+ export type SubtableDraftState = 'created' | 'persisted' | 'deleted';
10
+
11
+ export interface SubtableDraftRow {
12
+ key: string;
13
+ state: SubtableDraftState;
14
+ id?: string;
15
+ revision?: number;
16
+ data: Record<string, unknown>;
17
+ originalData?: Record<string, unknown>;
18
+ snapshot?: Record<string, unknown>;
19
+ originalOrder?: number;
20
+ }
21
+
22
+ export interface SubtableOperationPlanInput {
23
+ rows: SubtableDraftRow[];
24
+ childResourceCode: string;
25
+ childSurface: DataResourceSurface;
26
+ foreignKey: string;
27
+ orderField: string;
28
+ maxRows?: number;
29
+ parent:
30
+ | { operation: 'create'; operationIndex: number }
31
+ | { operation: 'update'; id: string };
32
+ canWrite: (
33
+ fieldCode: string,
34
+ field: DataFieldSurface,
35
+ operation: 'create' | 'update'
36
+ ) => boolean;
37
+ canDelete: boolean;
38
+ }
39
+
40
+ export function buildSubtableOperations(
41
+ input: SubtableOperationPlanInput
42
+ ): DataTransactionOperation[] {
43
+ const visibleRows = input.rows.filter(row => row.state !== 'deleted');
44
+ const maximum = input.maxRows ?? 20;
45
+ if (visibleRows.length > maximum || maximum < 1 || maximum > 49) {
46
+ throw new Error('OPENXIANGDA_SUBTABLE_MAX_ROWS_EXCEEDED');
47
+ }
48
+ const operations: DataTransactionOperation[] = [];
49
+ for (const row of input.rows.filter(item => item.state === 'deleted')) {
50
+ if (!input.canDelete) {
51
+ throw new Error('OPENXIANGDA_SUBTABLE_DELETE_FORBIDDEN');
52
+ }
53
+ if (!row.id || !Number.isSafeInteger(row.revision) || Number(row.revision) < 1) {
54
+ throw new Error('OPENXIANGDA_SUBTABLE_DELETED_ROW_INVALID');
55
+ }
56
+ operations.push({
57
+ operation: 'delete',
58
+ resourceCode: input.childResourceCode,
59
+ id: row.id,
60
+ expectedRevision: row.revision!,
61
+ });
62
+ }
63
+ visibleRows.forEach((row, order) => {
64
+ const operation = row.state === 'created' ? 'create' : 'update';
65
+ const current = writableData(input, row.data, operation);
66
+ if (operation === 'create') {
67
+ assertLinkFieldsWritable(input, 'create');
68
+ const parentValue: string | DataTransactionOperationReference =
69
+ input.parent.operation === 'create'
70
+ ? { operationIndex: input.parent.operationIndex, field: 'id' }
71
+ : input.parent.id;
72
+ operations.push({
73
+ operation: 'create',
74
+ resourceCode: input.childResourceCode,
75
+ data: {
76
+ ...current,
77
+ [input.foreignKey]: parentValue,
78
+ [input.orderField]: order,
79
+ },
80
+ });
81
+ return;
82
+ }
83
+ if (!row.id || !Number.isSafeInteger(row.revision) || Number(row.revision) < 1) {
84
+ throw new Error('OPENXIANGDA_SUBTABLE_PERSISTED_ROW_INVALID');
85
+ }
86
+ const original = writableData(
87
+ input,
88
+ row.originalData || {},
89
+ 'update'
90
+ );
91
+ const changes = changedValues(original, current);
92
+ if (row.originalOrder !== order) {
93
+ assertLinkFieldsWritable(input, 'update');
94
+ changes[input.orderField] = order;
95
+ }
96
+ if (Object.keys(changes).length === 0) return;
97
+ operations.push({
98
+ operation: 'update',
99
+ resourceCode: input.childResourceCode,
100
+ id: row.id,
101
+ expectedRevision: row.revision!,
102
+ data: changes,
103
+ });
104
+ });
105
+ return operations;
106
+ }
107
+
108
+ function assertLinkFieldsWritable(
109
+ input: SubtableOperationPlanInput,
110
+ operation: 'create' | 'update'
111
+ ) {
112
+ for (const fieldCode of operation === 'create'
113
+ ? [input.foreignKey, input.orderField]
114
+ : [input.orderField]) {
115
+ const field = input.childSurface.fields[fieldCode];
116
+ if (!field || !input.canWrite(fieldCode, field, operation)) {
117
+ throw new Error(`OPENXIANGDA_SUBTABLE_LINK_FIELD_FORBIDDEN:${fieldCode}`);
118
+ }
119
+ }
120
+ }
121
+
122
+ function writableData(
123
+ input: SubtableOperationPlanInput,
124
+ values: Record<string, unknown>,
125
+ operation: 'create' | 'update'
126
+ ) {
127
+ const normalized = normalizeFormValues(values, input.childSurface);
128
+ return Object.fromEntries(
129
+ Object.entries(normalized)
130
+ .filter(([fieldCode]) => {
131
+ if (fieldCode === input.foreignKey || fieldCode === input.orderField) {
132
+ return false;
133
+ }
134
+ const field = input.childSurface.fields[fieldCode];
135
+ return Boolean(
136
+ field &&
137
+ !field.system &&
138
+ field.type !== 'subtable' &&
139
+ field.widget !== 'readonly' &&
140
+ input.canWrite(fieldCode, field, operation)
141
+ );
142
+ })
143
+ .map(([fieldCode, value]) => [
144
+ fieldCode,
145
+ value === undefined
146
+ ? emptyValue(input.childSurface.fields[fieldCode])
147
+ : value,
148
+ ])
149
+ );
150
+ }
151
+
152
+ function emptyValue(field: DataFieldSurface | undefined) {
153
+ return field && isMultiField(field.type) ? [] : null;
154
+ }
155
+
156
+ function isMultiField(type: string) {
157
+ return (
158
+ type.endsWith('.multiple') ||
159
+ type === 'file' ||
160
+ type === 'image' ||
161
+ type === 'subtable'
162
+ );
163
+ }
164
+
165
+ function changedValues(
166
+ original: Record<string, unknown>,
167
+ current: Record<string, unknown>
168
+ ) {
169
+ return Object.fromEntries(
170
+ Object.keys(current)
171
+ .filter(key => canonicalJson(current[key]) !== canonicalJson(original[key]))
172
+ .map(key => [key, current[key]])
173
+ );
174
+ }
175
+
176
+ function canonicalJson(value: unknown): string {
177
+ if (Array.isArray(value)) {
178
+ return `[${value.map(canonicalJson).join(',')}]`;
179
+ }
180
+ if (value && typeof value === 'object') {
181
+ return `{${Object.entries(value)
182
+ .sort(([left], [right]) => left.localeCompare(right))
183
+ .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`)
184
+ .join(',')}}`;
185
+ }
186
+ return JSON.stringify(value);
187
+ }