@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.
- package/README.md +192 -0
- package/package.json +9 -3
- package/src/cli/project-init.js +1 -0
- package/src/client/destinations.d.ts +25 -0
- package/src/client/destinations.js +70 -0
- package/src/client/external-url.js +9 -0
- package/src/client/index.d.ts +186 -3
- package/src/client/index.js +45 -3
- package/src/client/interactions.d.ts +7 -3
- package/src/client/interactions.js +19 -2
- package/src/client/points.generated.js +48 -0
- package/src/forms/array-contract.d.ts +32 -0
- package/src/forms/array-contract.js +138 -0
- package/src/forms/components.generated.js +2 -2
- package/src/forms/factory.js +21 -7
- package/src/forms/field-array.d.ts +16 -0
- package/src/forms/field-array.js +52 -0
- package/src/forms/index.d.ts +67 -3
- package/src/forms/index.js +2 -0
- package/src/forms/options.d.ts +7 -2
- package/src/forms/options.js +8 -1
- package/src/forms/record-options.d.ts +28 -0
- package/src/forms/record-options.js +92 -0
- package/src/forms/schema.d.ts +73 -9
- package/src/forms/schema.js +128 -0
- package/src/forms/state.d.ts +30 -0
- package/src/forms/state.js +82 -0
- package/src/forms/validation.d.ts +9 -0
- package/src/forms/validation.js +81 -0
- package/src/forms/values.d.ts +5 -0
- package/src/forms/values.js +38 -0
- package/src/server/index.d.ts +15 -0
- package/src/server/index.js +20 -0
- package/src/toolchain/real-build.js +29 -3
- package/src/toolchain/source-discovery.js +202 -16
- package/src/ui/components.generated.js +3 -1
- package/src/ui/factory.js +50 -3
- package/src/ui/index.d.ts +157 -8
- package/src/ui/index.js +1 -1
- package/src/ui/validation.js +38 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { assertOptionValue, assertOptionsSearch } from './options.js';
|
|
2
|
+
const fail = () => { throw new Error('runtime_record_options_invalid'); };
|
|
3
|
+
const only = (value, keys) => {
|
|
4
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !keys.includes(key))) fail();
|
|
5
|
+
};
|
|
6
|
+
/** 仅用于 Host 选择器 identity,不作为公开保存值或传输编码。 */
|
|
7
|
+
export const recordKey = value => JSON.stringify([value.objectSlug, value.recordId]);
|
|
8
|
+
export function assertRecordRef(value, objects) {
|
|
9
|
+
only(value, ['objectSlug', 'recordId']);
|
|
10
|
+
assertOptionValue(value.objectSlug); assertOptionValue(value.recordId);
|
|
11
|
+
if (objects && !objects.some(object => object.objectSlug === value.objectSlug)) fail();
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
export function assertRecordObjects(objects) {
|
|
15
|
+
if (!Array.isArray(objects) || objects.length !== 1) fail();
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
for (const object of objects) {
|
|
18
|
+
only(object, ['objectSlug', 'label']);
|
|
19
|
+
assertOptionValue(object.objectSlug);
|
|
20
|
+
if (seen.has(object.objectSlug) || typeof object.label !== 'string' || !object.label.trim() || object.label.length > 200) fail();
|
|
21
|
+
seen.add(object.objectSlug);
|
|
22
|
+
}
|
|
23
|
+
return objects;
|
|
24
|
+
}
|
|
25
|
+
/** 新配置绑定唯一对象;旧数组仅允许恰好一项。 */
|
|
26
|
+
export function recordObjectsForField(field) {
|
|
27
|
+
if (field.objectSlug !== undefined) {
|
|
28
|
+
if (field.objects !== undefined) fail();
|
|
29
|
+
assertOptionValue(field.objectSlug);
|
|
30
|
+
return [{ objectSlug: field.objectSlug, label: field.label }];
|
|
31
|
+
}
|
|
32
|
+
return assertRecordObjects(field.objects);
|
|
33
|
+
}
|
|
34
|
+
export function assertRecordSearch(input, objects) {
|
|
35
|
+
only(input, ['objectSlug', 'query', 'cursor']);
|
|
36
|
+
assertOptionValue(input.objectSlug);
|
|
37
|
+
if (objects && !objects.some(object => object.objectSlug === input.objectSlug)) fail();
|
|
38
|
+
assertOptionsSearch({ query: input.query, ...(input.cursor === undefined ? {} : { cursor: input.cursor }) });
|
|
39
|
+
return input;
|
|
40
|
+
}
|
|
41
|
+
export function assertRecordOption(option) {
|
|
42
|
+
only(option, ['value', 'label', 'description', 'disabled', 'disabledReason']);
|
|
43
|
+
assertRecordRef(option.value);
|
|
44
|
+
if (typeof option.label !== 'string' || !option.label || option.label.length > 200) fail();
|
|
45
|
+
for (const key of ['description', 'disabledReason']) if (option[key] !== undefined && (typeof option[key] !== 'string' || option[key].length > 4096)) fail();
|
|
46
|
+
if (option.disabled !== undefined && typeof option.disabled !== 'boolean') fail();
|
|
47
|
+
return option;
|
|
48
|
+
}
|
|
49
|
+
export function assertRecordPage(page, objectSlug) {
|
|
50
|
+
only(page, ['items', 'nextCursor']);
|
|
51
|
+
if (!Array.isArray(page.items) || page.items.length > 100) fail();
|
|
52
|
+
if (page.nextCursor !== null && (typeof page.nextCursor !== 'string' || !page.nextCursor || page.nextCursor.length > 4096)) fail();
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
for (const item of page.items) {
|
|
55
|
+
assertRecordOption(item);
|
|
56
|
+
const key = recordKey(item.value);
|
|
57
|
+
if (item.value.objectSlug !== objectSlug || seen.has(key)) fail();
|
|
58
|
+
seen.add(key);
|
|
59
|
+
}
|
|
60
|
+
return page;
|
|
61
|
+
}
|
|
62
|
+
export function assertSelectedRecordOption(option, ref) {
|
|
63
|
+
assertRecordRef(ref);
|
|
64
|
+
if (option === null) return null;
|
|
65
|
+
assertRecordOption(option);
|
|
66
|
+
if (recordKey(option.value) !== recordKey(ref)) fail();
|
|
67
|
+
return option;
|
|
68
|
+
}
|
|
69
|
+
export function useNormalizedRecordOptions({ useRef, useMemo }, options, objects) {
|
|
70
|
+
assertRecordObjects(objects);
|
|
71
|
+
const source = useRef({ options: null, search: null, getOption: null, revision: 0 });
|
|
72
|
+
const search = options?.search, getOption = options?.getOption;
|
|
73
|
+
if (source.current.options !== options || source.current.search !== search || source.current.getOption !== getOption) {
|
|
74
|
+
source.current = { options, search, getOption, revision: source.current.revision + 1 };
|
|
75
|
+
}
|
|
76
|
+
const revision = source.current.revision;
|
|
77
|
+
return useMemo(() => {
|
|
78
|
+
if (options === undefined) return {};
|
|
79
|
+
if (!options || typeof search !== 'function' || typeof getOption !== 'function') fail();
|
|
80
|
+
return {
|
|
81
|
+
sourceRevision: String(revision),
|
|
82
|
+
onSearch: input => {
|
|
83
|
+
assertRecordSearch(input, objects);
|
|
84
|
+
return Promise.resolve(search.call(options, input)).then(page => assertRecordPage(page, input.objectSlug));
|
|
85
|
+
},
|
|
86
|
+
onGetOption: value => {
|
|
87
|
+
assertRecordRef(value, objects);
|
|
88
|
+
return Promise.resolve(getOption.call(options, value)).then(option => assertSelectedRecordOption(option, value));
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}, [revision]);
|
|
92
|
+
}
|
package/src/forms/schema.d.ts
CHANGED
|
@@ -1,18 +1,82 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import type { RecordRef, RecordObjectOption } from "./record-options.js";
|
|
2
|
+
export interface RuleMessages { required?: string; min?: string; max?: string; format?: string; }
|
|
3
|
+
export type FormField = (
|
|
4
|
+
| { type: "string"; label: string; required?: boolean; minLength?: number; maxLength?: number; format?: "email" | "url" }
|
|
3
5
|
| { type: "boolean"; label: string; required?: boolean }
|
|
4
|
-
| { type: "enum"; label: string; required?: boolean; options: readonly { value: string; label: string }[] }
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
| { type: "enum"; label: string; required?: boolean; options: readonly { value: string; label: string; description?: string }[] }
|
|
7
|
+
| { type: "number"; label: string; required?: boolean; min?: number; max?: number }
|
|
8
|
+
| { type: "plainDate" | "dateTime"; label: string; required?: boolean; min?: string; max?: string }
|
|
9
|
+
| ({ type: "record"; label: string; required?: boolean } & ({ objectSlug: string; objects?: never } | { objectSlug?: never; objects: readonly [RecordObjectOption] }))) & { messages?: RuleMessages };
|
|
10
|
+
export type LeafDefinition = FormField | { type: FormField['type']; label: (text: string) => unknown };
|
|
11
|
+
export interface ArrayField<F extends Record<string, LeafDefinition> = Record<string, LeafDefinition>> {
|
|
12
|
+
type: 'array'; label: string; minItems: number; maxItems: number; item: { type: 'object'; fields: F };
|
|
13
|
+
messages?: { min?: string; max?: string };
|
|
14
|
+
}
|
|
15
|
+
export type ArrayBuilder<F extends Record<string, LeafDefinition>> = Omit<ArrayField<F>, 'label'> & {
|
|
16
|
+
label(text: string): ArrayBuilder<F>;
|
|
17
|
+
min(count: number, options?: MessageOption): ArrayBuilder<F>;
|
|
18
|
+
max(count: number, options?: MessageOption): ArrayBuilder<F>;
|
|
19
|
+
custom(validator: (rows: ReadonlyValue<RowValues<F>[]>) => void | string | Promise<void | string>): ArrayBuilder<F>;
|
|
8
20
|
};
|
|
21
|
+
export interface FormSchema { fields: Readonly<Record<string, LeafDefinition | ArrayField | ArrayBuilder<Record<string, LeafDefinition>>>>; }
|
|
22
|
+
export type ValueOf<F> = F extends { type: 'array'; item: { fields: infer R extends Record<string, LeafDefinition> } } ? RowValues<R>[]
|
|
23
|
+
: F extends { type: 'boolean' } ? boolean : F extends { type: 'record' } ? (F extends { objectSlug: infer O extends string } ? RecordRef & { objectSlug: O } : RecordRef) | null
|
|
24
|
+
: F extends { type: 'number' } ? number | null : F extends { type: 'plainDate' | 'dateTime' } ? string | null : string;
|
|
25
|
+
export type RowValues<F extends Record<string, LeafDefinition>> = { [K in keyof F]: ValueOf<F[K]> };
|
|
26
|
+
export type FormValues<S extends FormSchema> = { [K in keyof S['fields']]: ValueOf<S['fields'][K]> };
|
|
27
|
+
export type ArrayName<S extends FormSchema> = { [K in keyof S['fields']]: S['fields'][K]['type'] extends 'array' ? K : never }[keyof S['fields']] & string;
|
|
9
28
|
export type FieldName<S extends FormSchema, T extends FormField['type']> = {
|
|
10
|
-
[K in keyof S['fields']]: S['fields'][K]
|
|
11
|
-
}[keyof
|
|
29
|
+
[K in keyof S['fields'] & string]: S['fields'][K] extends { type: 'array'; item: { fields: infer F extends Record<string, LeafDefinition> } }
|
|
30
|
+
? `${K}[${number}].${{ [P in keyof F & string]: F[P]['type'] extends T ? P : never }[keyof F & string]}`
|
|
31
|
+
: S['fields'][K]['type'] extends T ? K : never;
|
|
32
|
+
}[keyof S['fields'] & string];
|
|
33
|
+
export type FormFieldErrors<S extends FormSchema, E = string> = { [K in keyof S['fields']]?: S['fields'][K] extends { type: 'array'; item: { fields: infer F } }
|
|
34
|
+
? (Partial<Record<keyof F, E>> | null)[] : E };
|
|
35
|
+
export type FormArrayErrors<S extends FormSchema> = Partial<Record<ArrayName<S>, string>>;
|
|
12
36
|
/** void 兼容既有提交;失败保留输入,字段名必须属于当前表单。 */
|
|
13
37
|
export type FormSubmitResult<S extends FormSchema> = void | { ok: true } | {
|
|
14
38
|
ok: false;
|
|
15
39
|
code: string;
|
|
16
40
|
message: string;
|
|
17
|
-
fieldErrors?:
|
|
41
|
+
fieldErrors?: FormFieldErrors<S>;
|
|
42
|
+
arrayErrors?: FormArrayErrors<S>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type FormValidationResult<S extends FormSchema> = void | {
|
|
46
|
+
fieldErrors?: FormFieldErrors<S>;
|
|
47
|
+
arrayErrors?: FormArrayErrors<S>;
|
|
48
|
+
formError?: string;
|
|
49
|
+
};
|
|
50
|
+
type FieldValue<F extends FormField> = FormValues<{ fields: { value: F } }>['value'];
|
|
51
|
+
type ReadonlyValue<T> = T extends object ? { readonly [K in keyof T]: ReadonlyValue<T[K]> } : T;
|
|
52
|
+
export type FormOptions<S extends FormSchema> = {
|
|
53
|
+
schema: S;
|
|
54
|
+
initialValues: FormValues<S>;
|
|
55
|
+
validationMode?: 'onBlur' | 'onChange' | 'onSubmit';
|
|
56
|
+
validate?: (values: ReadonlyValue<FormValues<S>>) => FormValidationResult<S> | Promise<FormValidationResult<S>>;
|
|
57
|
+
};
|
|
58
|
+
type MessageOption = { message: string };
|
|
59
|
+
export type FieldBuilder<F extends FormField> = Omit<F, 'label'> & {
|
|
60
|
+
label(text: string): FieldBuilder<F>;
|
|
61
|
+
required(message?: string): FieldBuilder<F>;
|
|
62
|
+
optional(): FieldBuilder<F>;
|
|
63
|
+
custom(validator: (value: ReadonlyValue<FieldValue<F>>) => void | string | Promise<void | string>): FieldBuilder<F>;
|
|
64
|
+
} & (F['type'] extends 'string' | 'number' | 'plainDate' | 'dateTime' ? {
|
|
65
|
+
min(value: F['type'] extends 'string' | 'number' ? number : string, options?: MessageOption): FieldBuilder<F>;
|
|
66
|
+
max(value: F['type'] extends 'string' | 'number' ? number : string, options?: MessageOption): FieldBuilder<F>;
|
|
67
|
+
} : {}) & (F['type'] extends 'string' ? {
|
|
68
|
+
email(options?: MessageOption): FieldBuilder<F>;
|
|
69
|
+
url(options?: MessageOption): FieldBuilder<F>;
|
|
70
|
+
} : {});
|
|
71
|
+
// builder 的 label 是方法,wire 的 label 是字符串;只有编译后才跨 Worker 边界。
|
|
72
|
+
export const Forms: {
|
|
73
|
+
schema<const F extends Record<string, LeafDefinition | ArrayBuilder<Record<string, LeafDefinition>>>>(fields: F): { readonly fields: F };
|
|
74
|
+
array<const F extends Record<string, LeafDefinition>>(fields: F): ArrayBuilder<F>;
|
|
75
|
+
string(): FieldBuilder<Extract<FormField, { type: 'string' }>>;
|
|
76
|
+
number(): FieldBuilder<Extract<FormField, { type: 'number' }>>;
|
|
77
|
+
boolean(): FieldBuilder<Extract<FormField, { type: 'boolean' }>>;
|
|
78
|
+
enum(options: readonly { value: string; label: string; description?: string }[]): FieldBuilder<Extract<FormField, { type: 'enum' }>>;
|
|
79
|
+
plainDate(): FieldBuilder<{ type: 'plainDate'; label: string }>;
|
|
80
|
+
dateTime(): FieldBuilder<{ type: 'dateTime'; label: string }>;
|
|
81
|
+
record<const O extends string>(objectSlug: O): FieldBuilder<{ type: 'record'; label: string; objectSlug: O }>;
|
|
18
82
|
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { isPlainDate, isDateTime } from './values.js';
|
|
2
|
+
import { recordObjectsForField } from './record-options.js';
|
|
3
|
+
import { freezeFormState } from './state.js';
|
|
4
|
+
import { createValidationCallback } from './validation.js';
|
|
5
|
+
|
|
6
|
+
import { validFieldKey, assertArrayCapacity } from './array-contract.js';
|
|
7
|
+
|
|
8
|
+
const descriptors = new WeakMap();
|
|
9
|
+
const fail = () => { throw new Error('runtime_form_schema_invalid'); };
|
|
10
|
+
const message = value => {
|
|
11
|
+
if (typeof value !== 'string' || !value.trim() || value.length > 300) fail();
|
|
12
|
+
return value;
|
|
13
|
+
};
|
|
14
|
+
function builder(field, validators = []) {
|
|
15
|
+
field = freezeFormState(field);
|
|
16
|
+
const next = (patch, checks = validators) => builder({ ...field, ...patch }, checks);
|
|
17
|
+
const rule = (key, value, options) => {
|
|
18
|
+
const messages = { ...field.messages };
|
|
19
|
+
if (options !== undefined) {
|
|
20
|
+
if (!options || Object.keys(options).some(key => key !== 'message')) fail();
|
|
21
|
+
messages[key] = message(options.message);
|
|
22
|
+
} else delete messages[key];
|
|
23
|
+
return next({ [field.type === 'array' ? `${key}Items` : field.type === 'string' ? `${key}Length` : key]: value, messages });
|
|
24
|
+
};
|
|
25
|
+
const methods = {
|
|
26
|
+
label(value) { return next({ label: value }); },
|
|
27
|
+
required(value) { return next({ required: true, messages: { ...field.messages, ...(value === undefined ? {} : { required: message(value) }) } }); },
|
|
28
|
+
optional() { return next({ required: false }); },
|
|
29
|
+
custom(validator) {
|
|
30
|
+
if (typeof validator !== 'function' || validators.length >= 4) fail();
|
|
31
|
+
return next({}, [...validators, validator]);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
if (['array', 'string', 'number', 'plainDate', 'dateTime'].includes(field.type)) {
|
|
35
|
+
methods.min = (value, options) => rule('min', value, options);
|
|
36
|
+
methods.max = (value, options) => rule('max', value, options);
|
|
37
|
+
}
|
|
38
|
+
if (field.type === 'array') { delete methods.required; delete methods.optional; }
|
|
39
|
+
if (field.type === 'string') for (const format of ['email', 'url']) methods[format] = options => {
|
|
40
|
+
const messages = { ...field.messages };
|
|
41
|
+
if (options === undefined) delete messages.format;
|
|
42
|
+
else { if (!options || Object.keys(options).some(key => key !== 'message')) fail(); messages.format = message(options.message); }
|
|
43
|
+
return next({ format, messages });
|
|
44
|
+
};
|
|
45
|
+
const result = Object.freeze({ ...field, ...methods });
|
|
46
|
+
descriptors.set(result, { field, validators });
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
export const Forms = Object.freeze({
|
|
50
|
+
schema(fields) {
|
|
51
|
+
if (!fields || typeof fields !== 'object' || Array.isArray(fields)) fail();
|
|
52
|
+
if (Object.values(fields).some(field => !descriptors.has(field))) fail();
|
|
53
|
+
return Object.freeze({ fields: Object.freeze({ ...fields }) });
|
|
54
|
+
},
|
|
55
|
+
...Object.fromEntries(['string', 'number', 'boolean', 'plainDate', 'dateTime'].map(type => [type, () => builder({ type })])),
|
|
56
|
+
enum: options => builder({ type: 'enum', options: Array.isArray(options) ? options.map(option => Object.freeze({ ...option })) : options }),
|
|
57
|
+
array(fields) {
|
|
58
|
+
if (!fields || typeof fields !== 'object' || Array.isArray(fields) || !Object.keys(fields).length || Object.keys(fields).length > 12 || Object.values(fields).some(field => !descriptors.has(field) || field.type === 'array')) fail();
|
|
59
|
+
return builder({ type: 'array', minItems: 0, maxItems: 50, item: { type: 'object', fields: { ...fields } } });
|
|
60
|
+
},
|
|
61
|
+
record: objectSlug => builder({ type: 'record', objectSlug }),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** 只编译描述与回调;输入规则由 Host 单一校验器执行。 */
|
|
65
|
+
export function compileForm(options, formId, bindCallback = callback => callback) {
|
|
66
|
+
const { schema, validate, validationMode = 'onBlur' } = options;
|
|
67
|
+
if (!schema || !schema.fields || typeof schema.fields !== 'object' || Array.isArray(schema.fields) || Object.keys(schema).some(key => key !== 'fields')
|
|
68
|
+
|| Object.values(schema.fields).filter(field => field.type !== 'array').length > 30 || Object.values(schema.fields).filter(field => field.type === 'array').length > 3 || !['onBlur', 'onChange', 'onSubmit'].includes(validationMode)
|
|
69
|
+
|| (validate !== undefined && typeof validate !== 'function')) fail();
|
|
70
|
+
const fields = {}, validators = {};
|
|
71
|
+
for (const [name, input] of Object.entries(schema.fields)) {
|
|
72
|
+
if (!validFieldKey(name)) fail();
|
|
73
|
+
const descriptor = descriptors.get(input);
|
|
74
|
+
const source = descriptor?.field ?? input;
|
|
75
|
+
if (source?.type === 'array') {
|
|
76
|
+
if (Object.keys(source).some(key => !['type', 'label', 'minItems', 'maxItems', 'item', 'messages'].includes(key)) || typeof source.label !== 'string' || !source.label.trim() || source.label.length > 200
|
|
77
|
+
|| !Number.isSafeInteger(source.minItems) || source.minItems < 0 || !Number.isSafeInteger(source.maxItems) || source.maxItems < 1 || source.maxItems > 50 || source.minItems > source.maxItems
|
|
78
|
+
|| source.item?.type !== 'object' || Object.keys(source.item).some(key => !['type', 'fields'].includes(key)) || !source.item.fields || Object.values(source.item.fields).some(field => field.type === 'array')
|
|
79
|
+
|| !Object.keys(source.item.fields).length || Object.keys(source.item.fields).length > 12) fail();
|
|
80
|
+
const child = compileForm({ schema: { fields: source.item.fields } }, formId, bindCallback);
|
|
81
|
+
fields[name] = { ...source, item: { type: 'object', fields: child.schema.fields } };
|
|
82
|
+
if (source.messages) { if (Object.keys(source.messages).some(key => !['min', 'max'].includes(key))) fail(); Object.values(source.messages).forEach(message); }
|
|
83
|
+
for (const [key, checks] of Object.entries(child.validators)) validators[`${name}[*].${key}`] = checks;
|
|
84
|
+
if (descriptor?.validators.length) validators[name] = descriptor.validators.map(bindCallback);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const field = structuredClone(source);
|
|
88
|
+
if (!field || !['string', 'boolean', 'enum', 'number', 'plainDate', 'dateTime', 'record'].includes(field.type)
|
|
89
|
+
|| typeof field.label !== 'string' || descriptor && !field.label.trim() || field.label.length > 200) fail();
|
|
90
|
+
const allowed = ['type', 'label', 'required', 'messages', ...(field.type === 'string' ? ['minLength', 'maxLength', 'format'] : field.type === 'enum' ? ['options'] : field.type === 'record' ? ['objectSlug', 'objects'] : ['number', 'plainDate', 'dateTime'].includes(field.type) ? ['min', 'max'] : [])];
|
|
91
|
+
if (Object.keys(field).some(key => !allowed.includes(key)) || (field.required !== undefined && typeof field.required !== 'boolean')) fail();
|
|
92
|
+
if (field.messages !== undefined) {
|
|
93
|
+
if (!field.messages || typeof field.messages !== 'object' || Array.isArray(field.messages)
|
|
94
|
+
|| Object.keys(field.messages).some(key => !['required', 'min', 'max', 'format'].includes(key))) fail();
|
|
95
|
+
Object.values(field.messages).forEach(message);
|
|
96
|
+
}
|
|
97
|
+
if (field.type === 'record') recordObjectsForField(field);
|
|
98
|
+
if (field.type === 'enum') {
|
|
99
|
+
if (!Array.isArray(field.options) || !field.options.length || field.options.length > 100) fail();
|
|
100
|
+
const seen = new Set();
|
|
101
|
+
for (const choice of field.options) {
|
|
102
|
+
if (!choice || Object.keys(choice).some(key => !['value', 'label', 'description'].includes(key)) || typeof choice.value !== 'string' || !choice.value || choice.value.length > 200 || seen.has(choice.value) || typeof choice.label !== 'string' || choice.label.length > 200) fail();
|
|
103
|
+
if (choice.description !== undefined && (typeof choice.description !== 'string' || choice.description.length > 300)) fail();
|
|
104
|
+
seen.add(choice.value);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const low = field.type === 'string' ? 'minLength' : 'min', high = field.type === 'string' ? 'maxLength' : 'max';
|
|
108
|
+
const valid = value => field.type === 'string' ? Number.isSafeInteger(value) && value >= 0 && value <= 10000
|
|
109
|
+
: field.type === 'number' ? typeof value === 'number' && Number.isFinite(value)
|
|
110
|
+
: field.type === 'plainDate' ? isPlainDate(value) : isDateTime(value);
|
|
111
|
+
for (const key of [low, high]) if (field[key] !== undefined && (!valid(field[key]) || key === 'maxLength' && field[key] === 0)) fail();
|
|
112
|
+
const comparable = value => field.type === 'dateTime' ? Date.parse(value) : value;
|
|
113
|
+
if (field[low] !== undefined && field[high] !== undefined && comparable(field[low]) > comparable(field[high])) fail();
|
|
114
|
+
if (field.format !== undefined && !['email', 'url'].includes(field.format)) fail();
|
|
115
|
+
fields[name] = field;
|
|
116
|
+
if (descriptor?.validators.length) validators[name] = descriptor.validators.map(bindCallback);
|
|
117
|
+
}
|
|
118
|
+
const wire = freezeFormState({ fields });
|
|
119
|
+
const validation = { fields: Object.keys(validators), form: Boolean(validate), mode: validationMode };
|
|
120
|
+
const remote = Boolean(validation.fields.length || validate);
|
|
121
|
+
const configured = remote || options.validationMode !== undefined || Object.values(schema.fields).some(field => descriptors.has(field));
|
|
122
|
+
if (options.initialValues !== undefined) assertArrayCapacity(fields, options.initialValues);
|
|
123
|
+
const result = { schema: wire, ...(configured ? { validation } : {}), ...(remote ? {
|
|
124
|
+
onValidate: createValidationCallback(formId, fields, validators, validate ? bindCallback(validate) : undefined),
|
|
125
|
+
} : {}) };
|
|
126
|
+
Object.defineProperty(result, 'validators', { value: validators });
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { FormSchema, FormValues, FormFieldErrors, FormArrayErrors } from './schema.js';
|
|
3
|
+
export type FormStateSelection<S extends FormSchema> = {
|
|
4
|
+
values?: true | readonly (keyof S['fields'] & string)[];
|
|
5
|
+
arrayErrors?: true | readonly (keyof FormArrayErrors<S> & string)[];
|
|
6
|
+
errors?: true | readonly (keyof S['fields'] & string)[];
|
|
7
|
+
submitting?: boolean;
|
|
8
|
+
validating?: boolean;
|
|
9
|
+
validatingFields?: true | readonly (keyof S['fields'] & string)[];
|
|
10
|
+
formError?: boolean;
|
|
11
|
+
};
|
|
12
|
+
type Names<S extends FormSchema, T> = T extends true ? keyof FormValues<S> : T extends readonly (infer K)[] ? Extract<K, keyof FormValues<S>> : never;
|
|
13
|
+
type ReadonlyValue<T> = T extends object ? { readonly [K in keyof T]: ReadonlyValue<T[K]> } : T;
|
|
14
|
+
export type SelectedFormState<S extends FormSchema, Selection extends FormStateSelection<S>> =
|
|
15
|
+
(Selection extends { values: infer T } ? { readonly values: ReadonlyValue<Pick<FormValues<S>, Names<S, T>>> } : {})
|
|
16
|
+
& (Selection extends { errors: infer T } ? { readonly errors: ReadonlyValue<Pick<FormFieldErrors<S>, Names<S, T>>> } : {})
|
|
17
|
+
& (Selection extends { arrayErrors: infer T } ? { readonly arrayErrors: Readonly<Pick<FormArrayErrors<S>, Extract<Names<S, T>, keyof FormArrayErrors<S>>>> } : {})
|
|
18
|
+
& (Selection extends { submitting: true } ? { readonly submitting: boolean } : {})
|
|
19
|
+
& (Selection extends { validating: true } ? { readonly validating: boolean } : {})
|
|
20
|
+
& (Selection extends { formError: true } ? { readonly formError: string | null } : {})
|
|
21
|
+
& (Selection extends { validatingFields: infer T } ? { readonly validatingFields: ReadonlyValue<Pick<FormFieldErrors<S, boolean>, Names<S, T>>> } : {});
|
|
22
|
+
export type WithStateComponent<S extends FormSchema> = <const Selection extends FormStateSelection<S>>(props: {
|
|
23
|
+
selection: Selection;
|
|
24
|
+
children: (state: SelectedFormState<S, Selection>) => ReactNode;
|
|
25
|
+
}) => ReactNode;
|
|
26
|
+
|
|
27
|
+
export type StateSelection = { arrayErrors?: true | readonly string[]; values?: true | readonly string[]; errors?: true | readonly string[]; submitting?: boolean; validating?: boolean; validatingFields?: true | readonly string[]; formError?: boolean };
|
|
28
|
+
export function selectedNames(selection: true | readonly string[] | undefined, fields: Record<string, unknown>): string[];
|
|
29
|
+
export function assertFormStateSelection(selection: unknown, fields: Record<string, unknown>): StateSelection;
|
|
30
|
+
export function assertFormStateSnapshot(snapshot: unknown, formId: string, fields: Record<string, unknown>, selection: StateSelection): unknown;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { assertFieldValue, assertErrorTree, assertArrayErrors, normalizeFormValues, formBytes, ARRAY_LIMITS } from './array-contract.js';
|
|
2
|
+
import { assertJsonValue } from '../ui/validation.js';
|
|
3
|
+
const fail = () => { throw new Error('runtime_form_state_invalid'); };
|
|
4
|
+
const only = (value, keys) => {
|
|
5
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !keys.includes(key))) fail();
|
|
6
|
+
};
|
|
7
|
+
export function selectedNames(selection, fields) {
|
|
8
|
+
return selection === true ? Object.keys(fields) : Array.isArray(selection) ? selection : [];
|
|
9
|
+
}
|
|
10
|
+
export function assertFormStateSelection(selection, fields) {
|
|
11
|
+
only(selection, ['values', 'errors', 'submitting', 'validating', 'validatingFields', 'arrayErrors', 'formError']);
|
|
12
|
+
for (const kind of ['values', 'errors', 'validatingFields', 'arrayErrors']) {
|
|
13
|
+
const names = selection[kind];
|
|
14
|
+
if (names === undefined || names === true) continue;
|
|
15
|
+
if (!Array.isArray(names) || names.length > 33 || new Set(names).size !== names.length || names.some(name => typeof name !== 'string' || !Object.hasOwn(fields, name))) fail();
|
|
16
|
+
if (kind === 'arrayErrors' && names.some(name => fields[name].type !== 'array')) fail();
|
|
17
|
+
}
|
|
18
|
+
for (const key of ['submitting', 'validating', 'formError']) if (selection[key] !== undefined && typeof selection[key] !== 'boolean') fail();
|
|
19
|
+
return selection;
|
|
20
|
+
}
|
|
21
|
+
export function assertFormStateSnapshot(snapshot, formId, fields, selection) {
|
|
22
|
+
assertJsonValue(snapshot, Object.values(fields).some(field => field.type === 'array') ? { maxBytes: ARRAY_LIMITS.messageBytes, maxEntries: 4096 } : undefined);
|
|
23
|
+
assertFormStateSelection(selection, fields);
|
|
24
|
+
const keys = ['formId', 'revision', ...(selection.values !== undefined ? ['values'] : []), ...(selection.errors !== undefined ? ['errors'] : []), ...['submitting', 'validating', 'formError'].filter(key => selection[key]), ...(selection.arrayErrors !== undefined ? ['arrayErrors'] : []), ...(selection.validatingFields !== undefined ? ['validatingFields'] : [])];
|
|
25
|
+
only(snapshot, keys);
|
|
26
|
+
if (Object.keys(snapshot).length !== keys.length || snapshot.formId !== formId || !Number.isSafeInteger(snapshot.revision) || snapshot.revision < 1) fail();
|
|
27
|
+
if (selection.values !== undefined) {
|
|
28
|
+
const names = selectedNames(selection.values, fields);
|
|
29
|
+
only(snapshot.values, names);
|
|
30
|
+
if (Object.keys(snapshot.values).length !== names.length) fail();
|
|
31
|
+
for (const name of names) assertFieldValue(snapshot.values[name], fields[name]);
|
|
32
|
+
}
|
|
33
|
+
if (selection.errors !== undefined) {
|
|
34
|
+
only(snapshot.errors, selectedNames(selection.errors, fields));
|
|
35
|
+
assertErrorTree(snapshot.errors, fields, snapshot.values);
|
|
36
|
+
}
|
|
37
|
+
for (const key of ['submitting', 'validating']) if (selection[key] && typeof snapshot[key] !== 'boolean') fail();
|
|
38
|
+
if (selection.formError && snapshot.formError !== null && (typeof snapshot.formError !== 'string' || !snapshot.formError || snapshot.formError.length > 4096)) fail();
|
|
39
|
+
if (selection.validatingFields !== undefined) {
|
|
40
|
+
only(snapshot.validatingFields, selectedNames(selection.validatingFields, fields));
|
|
41
|
+
assertErrorTree(snapshot.validatingFields, fields, snapshot.values, true);
|
|
42
|
+
}
|
|
43
|
+
if (selection.arrayErrors !== undefined) { only(snapshot.arrayErrors, selectedNames(selection.arrayErrors, fields)); assertArrayErrors(snapshot.arrayErrors, fields); }
|
|
44
|
+
if (Object.values(fields).some(field => field.type === 'array') && formBytes(snapshot) > ARRAY_LIMITS.messageBytes) fail();
|
|
45
|
+
return snapshot;
|
|
46
|
+
}
|
|
47
|
+
export function freezeFormState(value) {
|
|
48
|
+
if (value && typeof value === 'object') { Object.values(value).forEach(freezeFormState); Object.freeze(value); }
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
export function initialFormState(formId, fields, initialValues, selection) {
|
|
52
|
+
const snapshot = { formId, revision: 1 };
|
|
53
|
+
if (selection.values !== undefined) { const values = normalizeFormValues(fields, initialValues); snapshot.values = Object.fromEntries(selectedNames(selection.values, fields).map(name => [name, values[name]])); }
|
|
54
|
+
if (selection.errors !== undefined) snapshot.errors = {};
|
|
55
|
+
if (selection.arrayErrors !== undefined) snapshot.arrayErrors = {};
|
|
56
|
+
for (const key of ['submitting', 'validating']) if (selection[key]) snapshot[key] = false;
|
|
57
|
+
if (selection.formError) snapshot.formError = null;
|
|
58
|
+
if (selection.validatingFields !== undefined) snapshot.validatingFields = {};
|
|
59
|
+
assertFormStateSnapshot(snapshot, formId, fields, selection);
|
|
60
|
+
return snapshot;
|
|
61
|
+
}
|
|
62
|
+
export function createWithState({ createElement, Fragment, useMemo, useState, useRef }, formId, schema, initialValues) {
|
|
63
|
+
function Observer({ selection, children }) {
|
|
64
|
+
const revision = useRef(0);
|
|
65
|
+
const [snapshot, setSnapshot] = useState(() => initialFormState(formId, schema.fields, initialValues, selection));
|
|
66
|
+
const onState = useMemo(() => next => {
|
|
67
|
+
assertFormStateSnapshot(next, formId, schema.fields, selection);
|
|
68
|
+
if (next.revision <= revision.current) return;
|
|
69
|
+
revision.current = next.revision;
|
|
70
|
+
setSnapshot(freezeFormState(next));
|
|
71
|
+
}, []);
|
|
72
|
+
const { formId: _, revision: _revision, ...state } = snapshot;
|
|
73
|
+
return createElement(Fragment, null,
|
|
74
|
+
createElement('FormStateObserver', { formId, selection, onState }),
|
|
75
|
+
children(freezeFormState(state)));
|
|
76
|
+
}
|
|
77
|
+
return function WithState({ selection, children }) {
|
|
78
|
+
assertFormStateSelection(selection, schema.fields);
|
|
79
|
+
if (typeof children !== 'function') fail();
|
|
80
|
+
return createElement(Observer, { key: JSON.stringify(selection), selection, children });
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const VALIDATION_TIMEOUT_MS: 10000;
|
|
2
|
+
export interface ValidationMetadata { fields: string[]; form: boolean; mode: 'onBlur' | 'onChange' | 'onSubmit'; }
|
|
3
|
+
export interface ValidationRequest {
|
|
4
|
+
formId: string; runId: number; reason: 'blur' | 'change' | 'submit';
|
|
5
|
+
values: Record<string, unknown>; fields: string[]; includeForm: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface ValidationResponse { formId: string; runId: number; fieldErrors: Record<string, string | (Record<string, string> | null)[]>; arrayErrors?: Record<string, string>; formError?: string; }
|
|
8
|
+
export function assertValidationMetadata(value: unknown, fields: Record<string, unknown>): ValidationMetadata;
|
|
9
|
+
export function assertValidationResponse(value: unknown, request: Pick<ValidationRequest, 'formId' | 'runId'> & { values?: Record<string, unknown> }, fields: Record<string, unknown>): ValidationResponse;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { assertFieldValue, assertArrayCapacity, assertErrorTree, assertArrayErrors, fieldAtPath, valueAtPath, rulePath, flattenErrors, nestErrors, formBytes, ARRAY_LIMITS } from './array-contract.js';
|
|
2
|
+
import { freezeFormState } from './state.js';
|
|
3
|
+
import { isDateTime, isPlainDate } from './values.js';
|
|
4
|
+
import { assertRecordRef, recordObjectsForField } from './record-options.js';
|
|
5
|
+
|
|
6
|
+
export const VALIDATION_TIMEOUT_MS = 10000;
|
|
7
|
+
const fail = () => { throw new Error('runtime_form_validation_invalid'); };
|
|
8
|
+
const only = (value, keys) => {
|
|
9
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !keys.includes(key))) fail();
|
|
10
|
+
};
|
|
11
|
+
const validMessage = value => typeof value === 'string' && value.trim() && value.length <= 300;
|
|
12
|
+
export function assertValidationMetadata(value, fields) {
|
|
13
|
+
only(value, ['fields', 'form', 'mode']);
|
|
14
|
+
if (!Array.isArray(value.fields) || value.fields.length > 69 || new Set(value.fields).size !== value.fields.length
|
|
15
|
+
|| value.fields.some(name => { try { fieldAtPath(fields, typeof name === 'string' ? name.replace('[*]', '[0]') : name); return false; } catch { return true; } })
|
|
16
|
+
|| typeof value.form !== 'boolean' || !['onBlur', 'onChange', 'onSubmit'].includes(value.mode)) fail();
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
export function assertValidationResponse(value, request, fields) {
|
|
20
|
+
only(value, ['formId', 'runId', 'fieldErrors', 'arrayErrors', 'formError']);
|
|
21
|
+
if (value.formId !== request.formId || value.runId !== request.runId) fail();
|
|
22
|
+
assertErrorTree(value.fieldErrors, fields, request.values, false, 300);
|
|
23
|
+
if (value.arrayErrors !== undefined) assertArrayErrors(value.arrayErrors, fields, 300);
|
|
24
|
+
if (value.formError !== undefined && !validMessage(value.formError)) fail();
|
|
25
|
+
if (Object.values(fields).some(field => field.type === 'array') && formBytes({ ...value, values: request.values }) > ARRAY_LIMITS.messageBytes - 32768) fail();
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
function assertRequest(request, formId, fields, validators, validate) {
|
|
29
|
+
only(request, ['formId', 'runId', 'reason', 'values', 'fields', 'includeForm']);
|
|
30
|
+
if (request.formId !== formId || !Number.isSafeInteger(request.runId) || request.runId < 1
|
|
31
|
+
|| !['blur', 'change', 'submit'].includes(request.reason) || typeof request.includeForm !== 'boolean'
|
|
32
|
+
|| request.includeForm && !validate || !Array.isArray(request.fields) || new Set(request.fields).size !== request.fields.length
|
|
33
|
+
|| request.fields.length > 303 || request.fields.some(name => !Object.hasOwn(validators, rulePath(name)))) fail();
|
|
34
|
+
only(request.values, Object.keys(fields));
|
|
35
|
+
if (Object.keys(request.values).length !== Object.keys(fields).length) fail();
|
|
36
|
+
for (const [name, field] of Object.entries(fields)) assertFieldValue(request.values[name], field);
|
|
37
|
+
assertArrayCapacity(fields, request.values);
|
|
38
|
+
for (const path of request.fields) if (valueAtPath(request.values, path) === undefined) fail();
|
|
39
|
+
}
|
|
40
|
+
export function createValidationCallback(formId, fields, validators, validate) {
|
|
41
|
+
let busy = false;
|
|
42
|
+
return async input => {
|
|
43
|
+
assertRequest(input, formId, fields, validators, validate);
|
|
44
|
+
if (busy) fail();
|
|
45
|
+
busy = true;
|
|
46
|
+
const request = freezeFormState(structuredClone(input));
|
|
47
|
+
let active = true, timer, position = 0;
|
|
48
|
+
const fieldErrors = {}, arrayErrors = {};
|
|
49
|
+
const runField = async () => {
|
|
50
|
+
while (active && position < request.fields.length) {
|
|
51
|
+
const name = request.fields[position++], value = valueAtPath(request.values, name), field = fieldAtPath(fields, name);
|
|
52
|
+
if (!field.required && (value === '' || value === null)) continue;
|
|
53
|
+
for (const validator of validators[rulePath(name)]) {
|
|
54
|
+
if (!active) return;
|
|
55
|
+
const error = await validator(value);
|
|
56
|
+
if (!active) return;
|
|
57
|
+
if (error !== undefined) { if (!validMessage(error)) fail(); (field.type === 'array' ? arrayErrors : fieldErrors)[name] = error; break; }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
try {
|
|
62
|
+
return await Promise.race([
|
|
63
|
+
(async () => {
|
|
64
|
+
await Promise.all([runField(), runField()]);
|
|
65
|
+
if (!active) return;
|
|
66
|
+
const result = request.includeForm ? await validate(request.values) : undefined;
|
|
67
|
+
if (!active) return;
|
|
68
|
+
if (result !== undefined) { only(result, ['fieldErrors', 'arrayErrors', 'formError']); }
|
|
69
|
+
if (result?.fieldErrors !== undefined) assertErrorTree(result.fieldErrors, fields, request.values, false, 300);
|
|
70
|
+
if (result?.arrayErrors !== undefined) assertArrayErrors(result.arrayErrors, fields, 300);
|
|
71
|
+
// 每个叶子合并,不能用整行覆盖同一行其他字段的跨字段错误。
|
|
72
|
+
const arrays = { ...result?.arrayErrors, ...arrayErrors };
|
|
73
|
+
const response = { formId, runId: request.runId, fieldErrors: nestErrors({ ...flattenErrors(result?.fieldErrors), ...fieldErrors }),
|
|
74
|
+
...(Object.keys(arrays).length ? { arrayErrors: arrays } : {}), ...(result?.formError === undefined ? {} : { formError: result.formError }) };
|
|
75
|
+
return assertValidationResponse(response, request, fields);
|
|
76
|
+
})(),
|
|
77
|
+
new Promise((_, reject) => { timer = setTimeout(() => { active = false; reject(new Error('runtime_form_validation_timeout')); }, VALIDATION_TIMEOUT_MS); }),
|
|
78
|
+
]);
|
|
79
|
+
} finally { active = false; busy = false; clearTimeout(timer); }
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export function isPlainDate(value: unknown): value is string;
|
|
2
|
+
export function isDateTime(value: unknown): value is string;
|
|
3
|
+
export function parseNumberDraft(value: unknown): { value: number | null; error?: string };
|
|
4
|
+
export function instantToLocal(value: string | null): string;
|
|
5
|
+
export function localToInstant(value: string): { value: string | null; error?: string };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** 平台值格式,不包含业务字段规则或缺省时区。 */
|
|
2
|
+
export function isPlainDate(value) {
|
|
3
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
|
4
|
+
const [year, month, day] = value.split('-').map(Number);
|
|
5
|
+
const date = new Date(0);
|
|
6
|
+
date.setUTCFullYear(year, month - 1, day);
|
|
7
|
+
return year > 0 && date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
|
8
|
+
}
|
|
9
|
+
export function isDateTime(value) {
|
|
10
|
+
if (typeof value !== 'string') return false;
|
|
11
|
+
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?(Z|[+-]\d{2}:\d{2})$/.exec(value);
|
|
12
|
+
return Boolean(match && isPlainDate(match[1]) && +match[2] < 24 && +match[3] < 60 && +(match[4] ?? 0) < 60 && Number.isFinite(Date.parse(value)));
|
|
13
|
+
}
|
|
14
|
+
export function parseNumberDraft(value) {
|
|
15
|
+
if (value === null || value === '') return { value: null };
|
|
16
|
+
if (typeof value === 'number') return Number.isFinite(value) ? { value } : { value: null, error: '请输入有效数字' };
|
|
17
|
+
if (typeof value !== 'string' || value.length > 512 || !/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(value)) return { value: null, error: '请输入有效数字' };
|
|
18
|
+
const number = Number(value);
|
|
19
|
+
return Number.isFinite(number) ? { value: number } : { value: null, error: '请输入有效数字' };
|
|
20
|
+
}
|
|
21
|
+
const pad = value => String(value).padStart(2, '0');
|
|
22
|
+
export function instantToLocal(value) {
|
|
23
|
+
if (!value) return '';
|
|
24
|
+
if (!isDateTime(value)) return value;
|
|
25
|
+
const date = new Date(value);
|
|
26
|
+
return `${String(date.getFullYear()).padStart(4, '0')}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
27
|
+
}
|
|
28
|
+
export function localToInstant(value) {
|
|
29
|
+
if (!value) return { value: null };
|
|
30
|
+
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})$/.exec(value);
|
|
31
|
+
if (!match || !isPlainDate(match[1]) || +match[2] > 23 || +match[3] > 59) return { value: null, error: '请输入有效日期时间' };
|
|
32
|
+
const wall = new Date(value);
|
|
33
|
+
const utc = new Date(value + ':00.000Z').getTime();
|
|
34
|
+
const offsets = new Set([-36, 0, 36].map(hours => new Date(wall.getTime() + hours * 3600000).getTimezoneOffset()));
|
|
35
|
+
const candidates = [...offsets].map(offset => new Date(utc + offset * 60000).toISOString()).filter(instant => instantToLocal(instant) === value);
|
|
36
|
+
if (candidates.length !== 1) return { value: null, error: '该本地时间不存在或有歧义,请选择其他时间' };
|
|
37
|
+
return { value: candidates[0] };
|
|
38
|
+
}
|
package/src/server/index.d.ts
CHANGED
|
@@ -221,6 +221,21 @@ export interface WorkspaceConnection {
|
|
|
221
221
|
/** 同步读取当前执行的连接快照;未连接或暂不可用时抛出安全错误,不在 getter 内刷新。 */
|
|
222
222
|
export function getWorkspaceConnection(): Readonly<WorkspaceConnection>;
|
|
223
223
|
|
|
224
|
+
/** 当前 Server Function 调用者在当前 Workspace 内的成员快照。 */
|
|
225
|
+
export interface CurrentUser {
|
|
226
|
+
/** Workspace 成员 ID;不是平台全局 User ID。 */
|
|
227
|
+
workspaceMemberId: string;
|
|
228
|
+
/** 当前 Workspace 中的显示名称。 */
|
|
229
|
+
displayName: string;
|
|
230
|
+
/** 当前 Workspace 中的主邮箱。 */
|
|
231
|
+
primaryEmailAddress: string | null;
|
|
232
|
+
/** 当前平台用户头像;未绑定平台用户时为空。 */
|
|
233
|
+
avatarUrl: string | null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 同步读取当前调用者;Lifecycle、Webhook 等无人触发入口会抛出 current_user_unavailable。 */
|
|
237
|
+
export function getCurrentUser(): Readonly<CurrentUser>;
|
|
238
|
+
|
|
224
239
|
/** Server Function 的一期函数合同。 */
|
|
225
240
|
export type ServerFunction<TArgs extends JsonValue[] = JsonValue[], TResult extends JsonValue = JsonValue> =
|
|
226
241
|
(...args: TArgs) => Promise<TResult>;
|
package/src/server/index.js
CHANGED
|
@@ -153,6 +153,26 @@ export function getWorkspaceConnection() {
|
|
|
153
153
|
});
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
export function getCurrentUser() {
|
|
157
|
+
const current = globalThis.ZHIKE_CURRENT_USER;
|
|
158
|
+
if (
|
|
159
|
+
!current ||
|
|
160
|
+
typeof current !== "object" ||
|
|
161
|
+
typeof current.workspaceMemberId !== "string" ||
|
|
162
|
+
typeof current.displayName !== "string"
|
|
163
|
+
) {
|
|
164
|
+
const error = new Error("current_user_unavailable");
|
|
165
|
+
error.code = "current_user_unavailable";
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
return Object.freeze({
|
|
169
|
+
workspaceMemberId: current.workspaceMemberId,
|
|
170
|
+
displayName: current.displayName,
|
|
171
|
+
primaryEmailAddress: typeof current.primaryEmailAddress === "string" ? current.primaryEmailAddress : null,
|
|
172
|
+
avatarUrl: typeof current.avatarUrl === "string" ? current.avatarUrl : null,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
156
176
|
// 仅兼容已构建的旧 Bundle;新源码入口由 .server.ts 的默认 async 函数确定。
|
|
157
177
|
export function defineServerFunction(handler) {
|
|
158
178
|
if (typeof handler !== "function") {
|