@77-zhike/app-sdk 0.2.0-d7b1.6 → 0.2.0-d7b1.8

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 (40) hide show
  1. package/README.md +192 -0
  2. package/package.json +9 -3
  3. package/src/cli/project-init.js +1 -0
  4. package/src/client/destinations.d.ts +25 -0
  5. package/src/client/destinations.js +70 -0
  6. package/src/client/external-url.js +9 -0
  7. package/src/client/index.d.ts +186 -3
  8. package/src/client/index.js +45 -3
  9. package/src/client/interactions.d.ts +7 -3
  10. package/src/client/interactions.js +19 -2
  11. package/src/client/points.generated.js +48 -0
  12. package/src/forms/array-contract.d.ts +32 -0
  13. package/src/forms/array-contract.js +138 -0
  14. package/src/forms/components.generated.js +2 -2
  15. package/src/forms/factory.js +21 -7
  16. package/src/forms/field-array.d.ts +16 -0
  17. package/src/forms/field-array.js +52 -0
  18. package/src/forms/index.d.ts +67 -3
  19. package/src/forms/index.js +2 -0
  20. package/src/forms/options.d.ts +7 -2
  21. package/src/forms/options.js +8 -1
  22. package/src/forms/record-options.d.ts +28 -0
  23. package/src/forms/record-options.js +92 -0
  24. package/src/forms/schema.d.ts +73 -9
  25. package/src/forms/schema.js +128 -0
  26. package/src/forms/state.d.ts +30 -0
  27. package/src/forms/state.js +82 -0
  28. package/src/forms/validation.d.ts +9 -0
  29. package/src/forms/validation.js +81 -0
  30. package/src/forms/values.d.ts +5 -0
  31. package/src/forms/values.js +38 -0
  32. package/src/server/index.d.ts +15 -0
  33. package/src/server/index.js +20 -0
  34. package/src/toolchain/real-build.js +29 -3
  35. package/src/toolchain/source-discovery.js +202 -16
  36. package/src/ui/components.generated.js +3 -1
  37. package/src/ui/factory.js +50 -3
  38. package/src/ui/index.d.ts +157 -8
  39. package/src/ui/index.js +1 -1
  40. package/src/ui/validation.js +38 -0
@@ -1,3 +1,6 @@
1
+ import { assertExternalUrl } from './external-url.js';
2
+ import { assertAppDestination } from './destinations.js';
3
+
1
4
  const fail = () => { throw new Error('runtime_interaction_invalid'); };
2
5
  const text = (value, max, required = false) => value === undefined ? !required : typeof value === 'string' && value.length <= max && (!required || value.length > 0);
3
6
  const externalUrl = value => {
@@ -9,13 +12,19 @@ const externalUrl = value => {
9
12
  };
10
13
  export function assertHostInteraction(operation, input) {
11
14
  if (!input || typeof input !== 'object' || Array.isArray(input)) fail();
12
- const allowed = operation === 'showToast' ? ['message','tone'] : operation === 'confirm' ? ['title','description','confirmLabel','cancelLabel'] : operation === 'openExternalUrl' ? ['url'] : [];
15
+ const allowed = operation === 'showToast' ? ['message','tone'] : operation === 'confirm' ? ['title','description','confirmLabel','cancelLabel'] : operation === 'navigate' ? ['destination'] : operation === 'openExternal' ? ['href'] : operation === 'openExternalUrl' ? ['url'] : [];
13
16
  if (!allowed.length || Object.keys(input).some(key=>!allowed.includes(key))) fail();
14
17
  if (operation === 'showToast') {
15
18
  if (!text(input.message,500,true) || (input.tone !== undefined && !['info','success','error'].includes(input.tone))) fail();
16
19
  } else if (operation === 'confirm') {
17
20
  if (!text(input.title,200,true) || !text(input.description,2000) || !text(input.confirmLabel,60) || !text(input.cancelLabel,60)) fail();
18
- } else if (!externalUrl(input.url)) fail();
21
+ } else if (operation === 'openExternalUrl') {
22
+ if (!externalUrl(input.url)) fail();
23
+ } else if (operation === 'openExternal') {
24
+ try { return Object.freeze({ href: assertExternalUrl(input.href) }); } catch { fail(); }
25
+ } else {
26
+ try { assertAppDestination(input.destination); } catch { fail(); }
27
+ }
19
28
  return Object.freeze(Object.fromEntries(Object.entries(input).filter(([,value])=>value!==undefined)));
20
29
  }
21
30
  export function createClientInteractions(invoke) {
@@ -32,5 +41,13 @@ export function createClientInteractions(invoke) {
32
41
  const request = assertHostInteraction('openExternalUrl',{url});
33
42
  return invoke('openExternalUrl',request).then(value=>{if(value!==null)fail();});
34
43
  },
44
+ openExternal(href) {
45
+ const request = assertHostInteraction('openExternal', {href});
46
+ return invoke('openExternal',request).then(value=>{if(typeof value!=='boolean')fail();return value;});
47
+ },
48
+ navigate(destination) {
49
+ const request = assertHostInteraction('navigate',{destination});
50
+ return invoke('navigate',request).then(value=>{if(value!==null)fail();});
51
+ },
35
52
  });
36
53
  }
@@ -14,6 +14,54 @@ export const frontendPoints = Object.freeze([
14
14
  "definitionName": "RecordActionDefinition",
15
15
  "callbackName": "onTrigger",
16
16
  "maxPerApp": 100
17
+ },
18
+ {
19
+ "key": "app.object-actions",
20
+ "kind": "action",
21
+ "legacyType": "object-action",
22
+ "definitionName": "ObjectActionDefinition",
23
+ "callbackName": "onTrigger",
24
+ "maxPerApp": 100
25
+ },
26
+ {
27
+ "key": "app.bulk-record-actions",
28
+ "kind": "action",
29
+ "legacyType": "bulk-record-action",
30
+ "definitionName": "BulkRecordActionDefinition",
31
+ "callbackName": "onTrigger",
32
+ "maxPerApp": 100
33
+ },
34
+ {
35
+ "key": "app.record-page-widgets",
36
+ "kind": "view",
37
+ "legacyType": "record-widget",
38
+ "definitionName": "RecordWidgetDefinition",
39
+ "componentName": "Widget",
40
+ "maxPerApp": 100
41
+ },
42
+ {
43
+ "key": "app.record-sections",
44
+ "kind": "view",
45
+ "legacyType": "record-section",
46
+ "definitionName": "RecordSectionDefinition",
47
+ "componentName": "Section",
48
+ "maxPerApp": 100
49
+ },
50
+ {
51
+ "key": "app.record-page-tabs",
52
+ "kind": "view",
53
+ "legacyType": "record-tab",
54
+ "definitionName": "RecordTabDefinition",
55
+ "componentName": "Tab",
56
+ "maxPerApp": 100
57
+ },
58
+ {
59
+ "key": "app.workspace-pages",
60
+ "kind": "view",
61
+ "legacyType": "page",
62
+ "definitionName": "PageDefinition",
63
+ "componentName": "Page",
64
+ "maxPerApp": 100
17
65
  }
18
66
  ].map(Object.freeze));
19
67
  export const frontendPoint = type => frontendPoints.find(point => point.legacyType === type);
@@ -0,0 +1,32 @@
1
+ export const ARRAY_LIMITS: Readonly<{ arrays: number; rowFields: number; rows: number; instances: number; valueBytes: number; messageBytes: number }>;
2
+ export function validFieldKey(key: unknown): boolean;
3
+ export function formBytes(value: unknown): number;
4
+ export function formObject(value: unknown, keys: string[]): void;
5
+ export interface FieldAddress { field: string; array?: string; index?: number }
6
+ export interface ArrayBinding { name: string; rowKey: string; formInstanceId: string; structureRevision: number }
7
+ export type ArrayAction = { kind: 'append'; value: Record<string, unknown> } | { kind: 'insertBefore'; beforeKey: string; value: Record<string, unknown> } | { kind: 'remove'; rowKey: string } | { kind: 'move'; rowKey: string; before: string | null };
8
+ export interface ArrayOperation { formId: string; formInstanceId: string; arrayName: string; baseStructureRevision: number; action: ArrayAction }
9
+ export interface ArraySnapshot {
10
+ formId: string; name: string; formInstanceId: string; revision: number; structureRevision: number;
11
+ rows: { key: string; index: number; value: Record<string, unknown>; errors: Record<string, string>; validatingFields: Record<string, boolean> }[];
12
+ submitting: boolean; canAppend: boolean; error: string | null;
13
+ }
14
+ export type ErrorTree<T = string> = Record<string, T | (Record<string, T> | null)[]>;
15
+ export function parseFieldPath(path: string): FieldAddress;
16
+ export function fieldAtPath<F>(fields: Record<string, F>, path: string): F;
17
+ export function valueAtPath(values: Record<string, unknown>, path: string): unknown;
18
+ export function setPathValue(values: Record<string, unknown>, path: string, value: unknown): void;
19
+ export function rulePath(path: string): string;
20
+ export function leafPaths(fields: Record<string, unknown>, values: Record<string, unknown>): string[];
21
+ export function rhfPath(path: string): string;
22
+ export function publicPath(path: string): string;
23
+ export function assertFieldValue(value: unknown, field: unknown, draft?: boolean): void;
24
+ export function assertArrayCapacity(fields: Record<string, unknown>, values: Record<string, unknown>, draft?: boolean): void;
25
+ export function normalizeFormValues<T extends Record<string, unknown>>(fields: Record<string, unknown>, raw: T): T;
26
+ export function assertErrorTree(tree: unknown, fields: Record<string, unknown>, values?: Record<string, unknown>, boolean?: boolean, maxText?: number): void;
27
+ export function assertArrayErrors(errors: unknown, fields: Record<string, unknown>, maxText?: number): void;
28
+ export function flattenErrors<T = string>(tree?: ErrorTree<T>): Record<string, T>;
29
+ export function nestErrors<T = string>(flat: Record<string, T>): ErrorTree<T>;
30
+ export function assertArrayBinding(binding: unknown): ArrayBinding;
31
+ export function assertArrayOperation(input: unknown): ArrayOperation;
32
+ export function assertArraySnapshot(snapshot: unknown, formId: string, name: string, fields: Record<string, unknown>): ArraySnapshot;
@@ -0,0 +1,138 @@
1
+ import { assertJsonValue } from '../ui/validation.js';
2
+ import { isDateTime, isPlainDate, parseNumberDraft } from './values.js';
3
+ import { assertRecordRef, recordObjectsForField } from './record-options.js';
4
+
5
+ export const ARRAY_LIMITS = Object.freeze({ arrays: 3, rowFields: 12, rows: 50, instances: 300, valueBytes: 65536, messageBytes: 196608 });
6
+ const fail = () => { throw new Error('runtime_form_array_invalid'); };
7
+ export const validFieldKey = key => typeof key === 'string' && /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/.test(key) && !['constructor', 'prototype'].includes(key);
8
+ export const formBytes = value => new TextEncoder().encode(JSON.stringify(value)).length;
9
+ export function formObject(value, keys) {
10
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !keys.includes(key))) fail();
11
+ }
12
+ export function parseFieldPath(path) {
13
+ if (validFieldKey(path)) return { field: path };
14
+ const match = typeof path === 'string' && /^([a-zA-Z][a-zA-Z0-9_]{0,63})\[(0|[1-9][0-9]*)\]\.([a-zA-Z][a-zA-Z0-9_]{0,63})$/.exec(path);
15
+ if (!match || !validFieldKey(match[1]) || !validFieldKey(match[3]) || !Number.isSafeInteger(Number(match[2])) || Number(match[2]) >= ARRAY_LIMITS.rows) fail();
16
+ return { array: match[1], index: Number(match[2]), field: match[3] };
17
+ }
18
+ export function fieldAtPath(fields, path) {
19
+ const address = parseFieldPath(path);
20
+ const field = address.array ? fields[address.array]?.item?.fields[address.field] : fields[address.field];
21
+ if (!field || address.array && fields[address.array].type !== 'array') fail();
22
+ return field;
23
+ }
24
+ export function valueAtPath(values, path) {
25
+ const address = parseFieldPath(path);
26
+ return address.array ? values[address.array]?.[address.index]?.[address.field] : values[address.field];
27
+ }
28
+ export function setPathValue(values, path, value) {
29
+ const address = parseFieldPath(path);
30
+ if (address.array) values[address.array][address.index][address.field] = value;
31
+ else values[address.field] = value;
32
+ }
33
+ export function rulePath(path) {
34
+ const address = parseFieldPath(path);
35
+ return address.array ? `${address.array}[*].${address.field}` : address.field;
36
+ }
37
+ export function leafPaths(fields, values) {
38
+ return Object.entries(fields).flatMap(([name, field]) => field.type === 'array'
39
+ ? (values[name] ?? []).flatMap((_row, index) => Object.keys(field.item.fields).map(key => `${name}[${index}].${key}`)) : [name]);
40
+ }
41
+ export const rhfPath = path => path.replace(/\[(\d+)\]/g, '.$1');
42
+ export const publicPath = path => path.replace(/\.(\d+)\./g, '[$1].');
43
+ export function assertFieldValue(value, field, draft = false) {
44
+ if (field.type === 'array') {
45
+ if (!Array.isArray(value) || value.length > field.maxItems || Object.keys(value).length !== value.length) fail();
46
+ for (const row of value) {
47
+ formObject(row, Object.keys(field.item.fields));
48
+ if (Object.keys(row).length !== Object.keys(field.item.fields).length) fail();
49
+ for (const [key, leaf] of Object.entries(field.item.fields)) assertFieldValue(row[key], leaf, draft);
50
+ }
51
+ } else if (field.type === 'boolean') { if (typeof value !== 'boolean') fail(); }
52
+ else if (field.type === 'number') { if (value !== null && !(draft && typeof value === 'string') && (typeof value !== 'number' || !Number.isFinite(value))) fail(); }
53
+ else if (field.type === 'record') { if (value !== null) assertRecordRef(value, recordObjectsForField(field)); }
54
+ else if (field.type === 'plainDate' || field.type === 'dateTime') { if (value !== null && !(draft && typeof value === 'string') && !(field.type === 'plainDate' ? isPlainDate(value) : isDateTime(value))) fail(); }
55
+ else if (typeof value !== 'string' || value.length > 10000) fail();
56
+ }
57
+ export function assertArrayCapacity(fields, values, draft = false) {
58
+ if (!Object.values(fields).some(field => field.type === 'array')) return;
59
+ formObject(values, Object.keys(fields));
60
+ if (Object.keys(fields).length !== Object.keys(values).length) fail();
61
+ for (const [name, field] of Object.entries(fields)) assertFieldValue(values[name], field, draft);
62
+ if (leafPaths(fields, values).length > ARRAY_LIMITS.instances || formBytes(values) > ARRAY_LIMITS.valueBytes) throw new Error('runtime_form_limit_exceeded');
63
+ }
64
+ export function normalizeFormValues(fields, raw) {
65
+ return Object.fromEntries(Object.entries(fields).map(([name, field]) => {
66
+ const value = raw[name];
67
+ return [name, field.type === 'array' ? value.map(row => normalizeFormValues(field.item.fields, row))
68
+ : field.type === 'number' ? parseNumberDraft(value).value
69
+ : field.type === 'dateTime' ? isDateTime(value) ? new Date(value).toISOString() : null
70
+ : field.type === 'plainDate' ? isPlainDate(value) ? value : null : structuredClone(value)];
71
+ }));
72
+ }
73
+ /** errors / validatingFields 的索引只对应这次快照;无错误行必须是显式 null。 */
74
+ export function assertErrorTree(tree, fields, values, boolean = false, maxText = 4096) {
75
+ formObject(tree, Object.keys(fields));
76
+ for (const [name, error] of Object.entries(tree)) {
77
+ const field = fields[name];
78
+ if (field.type === 'array') {
79
+ if (!Array.isArray(error) || error.length > (values?.[name]?.length ?? field.maxItems) || Object.keys(error).length !== error.length) fail();
80
+ for (const row of error) if (row !== null) assertErrorTree(row, field.item.fields, undefined, boolean, maxText);
81
+ } else if (boolean ? typeof error !== 'boolean' : typeof error !== 'string' || !error.trim() || error.length > maxText) fail();
82
+ }
83
+ }
84
+ export function assertArrayErrors(errors, fields, maxText = 4096) {
85
+ formObject(errors, Object.keys(fields).filter(name => fields[name].type === 'array'));
86
+ if (Object.values(errors).some(value => typeof value !== 'string' || !value.trim() || value.length > maxText)) fail();
87
+ }
88
+ export function flattenErrors(tree = {}) {
89
+ return Object.fromEntries(Object.entries(tree).flatMap(([name, value]) => Array.isArray(value)
90
+ ? value.flatMap((row, index) => Object.entries(row ?? {}).map(([key, error]) => [`${name}[${index}].${key}`, error])) : [[name, value]]));
91
+ }
92
+ export function nestErrors(flat) {
93
+ const tree = {};
94
+ for (const [path, error] of Object.entries(flat)) {
95
+ const address = parseFieldPath(path);
96
+ if (!address.array) tree[path] = error;
97
+ else {
98
+ const rows = tree[address.array] ??= [];
99
+ while (rows.length <= address.index) rows.push(null);
100
+ (rows[address.index] ??= {})[address.field] = error;
101
+ }
102
+ }
103
+ return tree;
104
+ }
105
+ export function assertArrayBinding(binding) {
106
+ formObject(binding, ['name', 'rowKey', 'formInstanceId', 'structureRevision']);
107
+ if (!validFieldKey(binding.name) || !opaque(binding.rowKey) || !opaque(binding.formInstanceId) || !revision(binding.structureRevision)) fail();
108
+ return binding;
109
+ }
110
+ const opaque = value => typeof value === 'string' && value.length > 0 && value.length <= 200;
111
+ const revision = value => Number.isSafeInteger(value) && value >= 0;
112
+ export function assertArrayOperation(input) {
113
+ formObject(input, ['formId', 'formInstanceId', 'arrayName', 'baseStructureRevision', 'action']);
114
+ if (!opaque(input.formId) || !opaque(input.formInstanceId) || !validFieldKey(input.arrayName) || !revision(input.baseStructureRevision)) fail();
115
+ const action = input.action;
116
+ const keys = { append: ['kind', 'value'], insertBefore: ['kind', 'beforeKey', 'value'], remove: ['kind', 'rowKey'], move: ['kind', 'rowKey', 'before'] }[action?.kind];
117
+ if (!keys) fail();
118
+ formObject(action, keys);
119
+ if (Object.keys(action).length !== keys.length || keys.includes('rowKey') && !opaque(action.rowKey) || keys.includes('beforeKey') && !opaque(action.beforeKey) || keys.includes('before') && action.before !== null && !opaque(action.before)) fail();
120
+ return input;
121
+ }
122
+ export function assertArraySnapshot(snapshot, formId, name, fields) {
123
+ assertJsonValue(snapshot, { maxBytes: ARRAY_LIMITS.messageBytes, maxEntries: 4096 });
124
+ formObject(snapshot, ['formId', 'name', 'formInstanceId', 'revision', 'structureRevision', 'rows', 'submitting', 'canAppend', 'error']);
125
+ const field = fields[name];
126
+ if (snapshot.formId !== formId || snapshot.name !== name || !opaque(snapshot.formInstanceId) || !revision(snapshot.revision) || !revision(snapshot.structureRevision) || field?.type !== 'array' || !Array.isArray(snapshot.rows) || snapshot.rows.length > field.maxItems || typeof snapshot.submitting !== 'boolean' || typeof snapshot.canAppend !== 'boolean') fail();
127
+ const keys = new Set();
128
+ for (const [index, row] of snapshot.rows.entries()) {
129
+ formObject(row, ['key', 'index', 'value', 'errors', 'validatingFields']);
130
+ if (!opaque(row.key) || keys.has(row.key) || row.index !== index) fail();
131
+ keys.add(row.key);
132
+ assertFieldValue([row.value], { ...field, maxItems: 1 });
133
+ assertErrorTree(row.errors, field.item.fields);
134
+ assertErrorTree(row.validatingFields, field.item.fields, undefined, true);
135
+ }
136
+ if (snapshot.error !== null && (typeof snapshot.error !== 'string' || !snapshot.error || snapshot.error.length > 4096) || formBytes(snapshot) > ARRAY_LIMITS.messageBytes) fail();
137
+ return snapshot;
138
+ }
@@ -1,4 +1,4 @@
1
1
  // 此文件由 scripts/generate-components.mjs 生成,请修改产品能力声明。
2
- export const fieldComponents = Object.freeze(["TextInput","Checkbox","Select","Combobox","MemberCombobox","TeamCombobox"]);
3
- export const boundComponents = Object.freeze(["TextInput","Checkbox","Select","Combobox","MemberCombobox","TeamCombobox","SubmitButton"]);
2
+ export const fieldComponents = Object.freeze(["TextInput","Checkbox","Select","Combobox","MemberCombobox","TeamCombobox","TextArea","NumberInput","PlainDateInput","DateTimeInput","Toggle","RadioGroup","RecordCombobox"]);
3
+ export const boundComponents = Object.freeze(["TextInput","Checkbox","Select","Combobox","MemberCombobox","TeamCombobox","SubmitButton","TextArea","NumberInput","PlainDateInput","DateTimeInput","Toggle","RadioGroup","RecordCombobox","InputGroup","Fieldset"]);
4
4
  export const formComponent = "Form";
@@ -1,23 +1,37 @@
1
+ import { createFieldArrays } from './field-array.js';
2
+ import { compileForm } from './schema.js';
3
+ import { createWithState } from './state.js';
4
+ import { useNormalizedRecordOptions, recordObjectsForField } from './record-options.js';
1
5
  import { boundComponents, formComponent } from './components.generated.js';
2
6
  import { useNormalizedOptions } from './normalize-options.js';
3
- export function createFormHook({ createElement, useId, useMemo, useRef }) {
7
+ export function createFormHook(React, { useBindCallback = () => callback => callback, useInvoke = () => () => Promise.reject(new Error('runtime_host_interaction_unavailable')) } = {}) {
8
+ const { createElement, useId, useMemo, useRef } = React;
4
9
 
5
10
  return function useForm(options) {
6
11
  const formId = useId();
12
+ const bindCallback = useBindCallback();
13
+ const invoke = useInvoke();
7
14
  const initial = useRef(options);
8
15
  return useMemo(() => {
9
- const { schema, initialValues } = initial.current;
16
+ const { initialValues } = initial.current;
17
+ const { schema, ...validation } = compileForm(initial.current, formId, bindCallback);
18
+ const arrays = createFieldArrays(React, formId, schema, invoke);
10
19
  const field = type => function Field({ children, ...props }) {
11
- if (type === 'Combobox') {
20
+ const binding = arrays.useBinding(props.name);
21
+ if (type === 'Combobox' || type === 'RecordCombobox') {
12
22
  const { options, ...rest } = props;
13
- const normalized = useNormalizedOptions({ useRef, useMemo }, options);
14
- return createElement(type, { ...rest, ...normalized, formId });
23
+ const normalized = type === 'RecordCombobox'
24
+ ? useNormalizedRecordOptions({ useRef, useMemo }, options, recordObjectsForField(binding.arrayBinding ? schema.fields[binding.arrayBinding.name].item.fields[binding.name] : schema.fields[props.name]))
25
+ : useNormalizedOptions({ useRef, useMemo }, options);
26
+ return createElement(type, { ...rest, ...normalized, ...binding, formId });
15
27
  }
16
- return createElement(type, { ...props, formId }, children);
28
+ return createElement(type, { ...props, ...binding, formId }, children);
17
29
  };
18
30
  return Object.freeze({
31
+ FieldArray: arrays.FieldArray,
32
+ WithState: createWithState(React, formId, schema, initialValues),
19
33
  Form: function Form({ children, onSubmit }) {
20
- return createElement(formComponent, { formId, schema, initialValues, onSubmit }, children);
34
+ return createElement(formComponent, { formId, schema, initialValues, onSubmit, ...validation }, children);
21
35
  },
22
36
  ...Object.fromEntries(boundComponents.map(type => [type, field(type)])),
23
37
  });
@@ -0,0 +1,16 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { ArrayName, FormSchema, FormValues } from './schema.js';
3
+ type ReadonlyValue<T> = T extends object ? { readonly [K in keyof T]: ReadonlyValue<T[K]> } : T;
4
+ export type ArrayOperationResult = { ok: true; rowKey?: string } | { ok: false; code: string; message: string };
5
+ type Row<S extends FormSchema, N extends ArrayName<S>> = FormValues<S>[N] extends (infer R)[] ? R : never;
6
+ export interface FieldArrayHandle<R> {
7
+ readonly ready: boolean;
8
+ readonly rows: readonly { readonly key: string; readonly index: number; readonly value: ReadonlyValue<R>; readonly errors: Readonly<Partial<Record<keyof R, string>>>; readonly validatingFields: Readonly<Partial<Record<keyof R, boolean>>> }[];
9
+ readonly pending: boolean; readonly submitting: boolean; readonly canAppend: boolean;
10
+ readonly error: string | null; readonly operationError: string | null;
11
+ append(value: R): Promise<ArrayOperationResult>;
12
+ insertBefore(beforeKey: string, value: R): Promise<ArrayOperationResult>;
13
+ remove(rowKey: string): Promise<ArrayOperationResult>;
14
+ move(rowKey: string, position: { before: string | null }): Promise<ArrayOperationResult>;
15
+ }
16
+ export type FieldArrayComponent<S extends FormSchema> = <N extends ArrayName<S>>(props: { name: N; children: (array: FieldArrayHandle<Row<S, N>>) => ReactNode }) => ReactNode;
@@ -0,0 +1,52 @@
1
+ import { assertArraySnapshot, parseFieldPath } from './array-contract.js';
2
+ import { freezeFormState } from './state.js';
3
+
4
+ export function createFieldArrays(React, formId, schema, invoke) {
5
+ const { createContext, createElement, Fragment, useContext, useEffect, useRef, useState, useMemo } = React;
6
+ const Context = createContext(null);
7
+ // 只串行化结构操作;行值仍由 Host 持有,不能乐观修改第二份草稿。
8
+ const gate = { pending: false };
9
+ function Observer({ name, children }) {
10
+ if (schema.fields[name]?.type !== 'array' || typeof children !== 'function') throw new Error('runtime_form_array_invalid');
11
+ const latest = useRef(null), alive = useRef(true);
12
+ const [snapshot, setSnapshot] = useState(null), [pending, setPending] = useState(false), [operationError, setError] = useState(null);
13
+ useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
14
+ const onState = useMemo(() => next => {
15
+ assertArraySnapshot(next, formId, name, schema.fields);
16
+ if (!alive.current || latest.current && latest.current.formInstanceId === next.formInstanceId && latest.current.revision >= next.revision) return;
17
+ latest.current = freezeFormState(next); setSnapshot(latest.current);
18
+ }, [name]);
19
+ async function operate(action) {
20
+ const state = latest.current;
21
+ const reject = (code, message) => { if (alive.current) setError(message); return { ok: false, code, message }; };
22
+ if (!alive.current || !state) return reject('owner_closed', '表单尚未就绪');
23
+ if (gate.pending || state.submitting) return reject('busy', '请等待当前操作完成');
24
+ gate.pending = true; setPending(true); setError(null);
25
+ try {
26
+ const result = await invoke('formArray', { formId, formInstanceId: state.formInstanceId, arrayName: name, baseStructureRevision: state.structureRevision, action });
27
+ if (result?.snapshot) onState(result.snapshot);
28
+ if (!result || typeof result.ok !== 'boolean') throw new Error('runtime_form_array_invalid');
29
+ if (!result.ok) return reject(result.code, result.message);
30
+ return { ok: true, ...(result.rowKey ? { rowKey: result.rowKey } : {}) };
31
+ } catch { return reject('owner_closed', '操作未确认,请检查当前明细后再操作'); }
32
+ finally { gate.pending = false; if (alive.current) setPending(false); }
33
+ }
34
+ const handle = Object.freeze({ ready: Boolean(snapshot), rows: snapshot?.rows ?? [], pending, submitting: snapshot?.submitting ?? false,
35
+ canAppend: Boolean(snapshot?.canAppend && !pending), error: snapshot?.error ?? null, operationError,
36
+ append: value => operate({ kind: 'append', value }), insertBefore: (beforeKey, value) => operate({ kind: 'insertBefore', beforeKey, value }),
37
+ remove: rowKey => operate({ kind: 'remove', rowKey }), move: (rowKey, { before }) => operate({ kind: 'move', rowKey, before }),
38
+ });
39
+ return createElement(Fragment, null, createElement('FormArrayObserver', { formId, name, onState }),
40
+ createElement(Context.Provider, { value: snapshot }, children(handle)));
41
+ }
42
+ function useBinding(name) {
43
+ const snapshot = useContext(Context);
44
+ if (name === undefined) return {};
45
+ const path = parseFieldPath(name);
46
+ if (!path.array) return { name };
47
+ if (!snapshot || snapshot.name !== path.array || !snapshot.rows[path.index] || !schema.fields[path.array].item.fields[path.field]) throw new Error('runtime_form_array_binding_missing');
48
+ return { name: path.field, arrayBinding: { name: path.array, rowKey: snapshot.rows[path.index].key, formInstanceId: snapshot.formInstanceId, structureRevision: snapshot.structureRevision } };
49
+ }
50
+ function FieldArray(props) { return createElement(Observer, { ...props, key: props.name }); }
51
+ return { FieldArray, useBinding };
52
+ }
@@ -1,10 +1,19 @@
1
1
  // 此文件由 scripts/generate-components.mjs 生成,请修改产品能力声明。
2
2
  import type { ComponentType, ReactNode } from "react";
3
- import type { FormSchema, FormValues, FieldName, FormSubmitResult } from "./schema.js";
3
+ import type { FormSchema, FormValues, FieldName, FormSubmitResult, FormOptions } from "./schema.js";
4
4
  import type { Option, OptionsPage, OptionsProvider } from "./options.js";
5
+ import type { RecordOptionsProvider } from "./record-options.js";
6
+ import type { FieldArrayComponent } from "./field-array.js";
7
+ export type { FieldArrayHandle, ArrayOperationResult } from "./field-array.js";
8
+ import type { WithStateComponent } from "./state.js";
9
+ export type { FormStateSelection, SelectedFormState } from "./state.js";
10
+ export type { RecordRef, RecordObjectOption, RecordOption, RecordOptionsPage, RecordOptionsProvider } from "./record-options.js";
5
11
  export type { Option, OptionsPage, OptionsProvider } from "./options.js";
6
- export type { FormField, FormSchema, FormValues, FieldName, FormSubmitResult } from "./schema.js";
12
+ export { Forms } from "./schema.js";
13
+ export type { FormField, FormSchema, FormValues, FieldName, FormSubmitResult, FormOptions, FormValidationResult, FormFieldErrors, FormArrayErrors, ArrayName } from "./schema.js";
7
14
  export interface FormHandle<S extends FormSchema> {
15
+ WithState: WithStateComponent<S>;
16
+ FieldArray: FieldArrayComponent<S>;
8
17
  Form: ComponentType<{
9
18
  children?: ReactNode;
10
19
  onSubmit: (values: FormValues<S>) => FormSubmitResult<S> | Promise<FormSubmitResult<S>>;
@@ -14,11 +23,13 @@ export interface FormHandle<S extends FormSchema> {
14
23
  description?: string;
15
24
  placeholder?: string;
16
25
  disabled?: boolean;
26
+ type?: "text" | "email" | "password" | "tel" | "url";
17
27
  }>;
18
28
  Checkbox: ComponentType<{
19
29
  name: FieldName<S, "boolean">;
20
30
  description?: string;
21
31
  disabled?: boolean;
32
+ labelHidden?: boolean;
22
33
  }>;
23
34
  Select: ComponentType<{
24
35
  name: FieldName<S, "enum">;
@@ -49,6 +60,59 @@ export interface FormHandle<S extends FormSchema> {
49
60
  children?: string;
50
61
  disabled?: boolean;
51
62
  }>;
63
+ TextArea: ComponentType<{
64
+ name: FieldName<S, "string">;
65
+ description?: string;
66
+ placeholder?: string;
67
+ disabled?: boolean;
68
+ resizable?: boolean;
69
+ }>;
70
+ NumberInput: ComponentType<{
71
+ name: FieldName<S, "number">;
72
+ description?: string;
73
+ placeholder?: string;
74
+ disabled?: boolean;
75
+ step?: number;
76
+ }>;
77
+ PlainDateInput: ComponentType<{
78
+ name: FieldName<S, "plainDate">;
79
+ description?: string;
80
+ placeholder?: string;
81
+ disabled?: boolean;
82
+ }>;
83
+ DateTimeInput: ComponentType<{
84
+ name: FieldName<S, "dateTime">;
85
+ description?: string;
86
+ placeholder?: string;
87
+ disabled?: boolean;
88
+ }>;
89
+ Toggle: ComponentType<{
90
+ name: FieldName<S, "boolean">;
91
+ description?: string;
92
+ placeholder?: string;
93
+ disabled?: boolean;
94
+ }>;
95
+ RadioGroup: ComponentType<{
96
+ name: FieldName<S, "enum">;
97
+ description?: string;
98
+ placeholder?: string;
99
+ disabled?: boolean;
100
+ variant?: "default" | "cards";
101
+ }>;
102
+ RecordCombobox: ComponentType<{
103
+ name: FieldName<S, "record">;
104
+ description?: string;
105
+ placeholder?: string;
106
+ disabled?: boolean;
107
+ options?: RecordOptionsProvider;
108
+ }>;
109
+ InputGroup: ComponentType<{
110
+ children?: ReactNode;
111
+ }>;
112
+ Fieldset: ComponentType<{
113
+ legend: string;
114
+ children?: ReactNode;
115
+ }>;
52
116
  }
53
117
  /** schema 和 initialValues 仅在首次挂载使用;重新渲染不会重置输入。 */
54
- export function useForm<const S extends FormSchema>(options: { schema: S; initialValues: FormValues<S> }): FormHandle<S>;
118
+ export function useForm<const S extends FormSchema>(options: FormOptions<S>): FormHandle<S>;
@@ -1,3 +1,5 @@
1
1
  import * as React from "react";
2
2
  import { createFormHook } from "./factory.js";
3
3
  export const useForm = createFormHook(React);
4
+
5
+ export { Forms } from './schema.js';
@@ -1,12 +1,17 @@
1
+ import type { AppIconName, BadgeProps } from '../ui/index.js';
1
2
  /** value 是持久化标识,label 只负责显示。无法回显不能清除原 value。 */
2
- export interface Option {
3
+ export type Option = {
3
4
  value: string;
4
5
  label: string;
5
6
  description?: string;
6
7
  disabled?: boolean;
7
8
  disabledReason?: string;
9
+ categoryLabel?: string;
10
+ avatarUrl?: string;
11
+ icon?: AppIconName;
12
+ color?: NonNullable<BadgeProps['color']>;
8
13
  }
9
- export interface OptionsPage {
14
+ export type OptionsPage = {
10
15
  items: Option[];
11
16
  /** null 表示末页;游标只能在当前搜索下使用。 */
12
17
  nextCursor: string | null;
@@ -1,16 +1,23 @@
1
+ import { sdkIconNames } from '../ui/icons.generated.js';
2
+ import { badgeColors } from '../ui/components.generated.js';
3
+ import { assertExternalUrl } from '../client/external-url.js';
1
4
  // SDK 回调与 Host 共用有界 DTO 验证;函数留在 Guest,不传递 Provider 对象。
2
5
  const fail = () => { throw new Error('runtime_options_invalid'); };
3
6
  const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
4
7
  const only = (value, keys) => { if (!object(value) || Object.keys(value).some(key => !keys.includes(key))) fail(); };
5
8
  export const OPTIONS_PAGE_LIMIT = 100;
6
9
  export function assertOption(option) {
7
- only(option, ['value', 'label', 'description', 'disabled', 'disabledReason']);
10
+ only(option, ['value', 'label', 'description', 'disabled', 'disabledReason', 'categoryLabel', 'avatarUrl', 'icon', 'color']);
8
11
  assertOptionValue(option.value);
9
12
  if (typeof option.label !== 'string' || !option.label || option.label.length > 200) fail();
10
13
  for (const key of ['description', 'disabledReason']) {
11
14
  if (option[key] !== undefined && (typeof option[key] !== 'string' || option[key].length > 4096)) fail();
12
15
  }
13
16
  if (option.disabled !== undefined && typeof option.disabled !== 'boolean') fail();
17
+ if (option.categoryLabel !== undefined && (typeof option.categoryLabel !== 'string' || !option.categoryLabel.trim() || option.categoryLabel.length > 200)) fail();
18
+ if (option.avatarUrl !== undefined) { try { assertExternalUrl(option.avatarUrl); } catch { fail(); } }
19
+ if (option.icon !== undefined && !sdkIconNames.includes(option.icon)) fail();
20
+ if (option.color !== undefined && !badgeColors.includes(option.color)) fail();
14
21
  return option;
15
22
  }
16
23
  export function assertOptionValue(value) {
@@ -0,0 +1,28 @@
1
+ export type RecordRef = {
2
+ /** 数据源范围内稳定的公开对象键,不是内部模型名或授权证明。 */
3
+ objectSlug: string;
4
+ recordId: string;
5
+ };
6
+ export type RecordObjectOption = { objectSlug: string; label: string; }
7
+ export type RecordOption = {
8
+ value: RecordRef;
9
+ label: string;
10
+ description?: string;
11
+ disabled?: boolean;
12
+ disabledReason?: string;
13
+ }
14
+ export type RecordOptionsPage = { items: RecordOption[]; nextCursor: string | null; }
15
+ export interface RecordOptionsProvider {
16
+ search(input: { objectSlug: string; query: string; cursor?: string }): Promise<RecordOptionsPage>;
17
+ /** null 表示未解析;查询错误必须拒绝 Promise,不能静默清空引用。 */
18
+ getOption(value: RecordRef): Promise<RecordOption | null>;
19
+ }
20
+ export function recordKey(value: RecordRef): string;
21
+ export function assertRecordRef(value: unknown, objects?: readonly RecordObjectOption[]): RecordRef;
22
+ export function assertRecordObjects(value: unknown): RecordObjectOption[];
23
+ export function assertRecordSearch(value: unknown, objects?: readonly RecordObjectOption[]): { objectSlug: string; query: string; cursor?: string };
24
+ export function assertRecordOption(value: unknown): RecordOption;
25
+ export function assertRecordPage(value: unknown, objectSlug: string): RecordOptionsPage;
26
+ export function assertSelectedRecordOption(value: unknown, ref: RecordRef): RecordOption | null;
27
+
28
+ export function recordObjectsForField(field: { objectSlug?: string; objects?: readonly RecordObjectOption[]; label: string }): readonly RecordObjectOption[];