@77-zhike/app-sdk 0.2.0-d7b1.10
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 +251 -0
- package/package.json +71 -0
- package/src/bin.js +15 -0
- package/src/cli/api-client.js +249 -0
- package/src/cli/browser-auth.js +212 -0
- package/src/cli/credential-store.js +101 -0
- package/src/cli/development-app.js +77 -0
- package/src/cli/development-logs.js +183 -0
- package/src/cli/development-watch.js +205 -0
- package/src/cli/index.js +256 -0
- package/src/cli/local-commands.js +80 -0
- package/src/cli/logs.js +56 -0
- package/src/cli/project-init.js +259 -0
- package/src/cli/upload-bundle.js +73 -0
- package/src/client/analysis.d.ts +4 -0
- package/src/client/analysis.js +12 -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 +249 -0
- package/src/client/index.js +72 -0
- package/src/client/interactions.d.ts +23 -0
- package/src/client/interactions.js +62 -0
- package/src/client/points.generated.js +67 -0
- package/src/config.js +68 -0
- package/src/development-workspace.js +154 -0
- package/src/errors.js +20 -0
- package/src/forms/array-contract.d.ts +32 -0
- package/src/forms/array-contract.js +138 -0
- package/src/forms/components.generated.js +4 -0
- package/src/forms/factory.js +41 -0
- package/src/forms/field-array.d.ts +16 -0
- package/src/forms/field-array.js +52 -0
- package/src/forms/index.d.ts +118 -0
- package/src/forms/index.js +5 -0
- package/src/forms/normalize-options.js +19 -0
- package/src/forms/options.d.ts +30 -0
- package/src/forms/options.js +55 -0
- package/src/forms/record-options.d.ts +28 -0
- package/src/forms/record-options.js +92 -0
- package/src/forms/schema.d.ts +82 -0
- 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/index.d.ts +13 -0
- package/src/index.js +2 -0
- package/src/jsx-dev-runtime.d.ts +2 -0
- package/src/jsx-dev-runtime.js +1 -0
- package/src/jsx-runtime.d.ts +2 -0
- package/src/jsx-runtime.js +1 -0
- package/src/project-binding.js +84 -0
- package/src/server/index.d.ts +318 -0
- package/src/server/index.js +242 -0
- package/src/server/steps/index.d.ts +71 -0
- package/src/server/steps/index.js +15 -0
- package/src/server/triggers/index.d.ts +68 -0
- package/src/server/triggers/index.js +27 -0
- package/src/server/workflow-blocks/index.d.ts +72 -0
- package/src/server/workflow-blocks/index.js +32 -0
- package/src/toolchain/allowed-packages.json +7 -0
- package/src/toolchain/canonical-json.js +17 -0
- package/src/toolchain/dependency-policy.js +181 -0
- package/src/toolchain/index.js +15 -0
- package/src/toolchain/real-build.js +459 -0
- package/src/toolchain/source-discovery.js +919 -0
- package/src/toolchain/transport-tar.js +168 -0
- package/src/toolchain/webhook-response.js +50 -0
- package/src/toolchain/workflow-blocks/discovery.js +133 -0
- package/src/toolchain/workflow-steps/manifest.js +99 -0
- package/src/toolchain/workflow-triggers/manifest.js +47 -0
- package/src/ui/components.generated.js +5 -0
- package/src/ui/events.d.ts +10 -0
- package/src/ui/factory.js +70 -0
- package/src/ui/icons.generated.js +2 -0
- package/src/ui/index.d.ts +343 -0
- package/src/ui/index.js +4 -0
- package/src/ui/jsx.generated.d.ts +359 -0
- package/src/ui/native-dom.d.ts +49 -0
- package/src/ui/native-dom.js +44 -0
- package/src/ui/style.d.ts +5 -0
- package/src/ui/style.generated.js +2 -0
- package/src/ui/style.js +15 -0
- package/src/ui/validation.js +86 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
|
|
6
|
+
import { ZhikeCliError } from "./errors.js";
|
|
7
|
+
|
|
8
|
+
export const DEVELOPMENT_WORKSPACE_PATH = ".zhike/development.json";
|
|
9
|
+
|
|
10
|
+
export function parseDevelopmentWorkspaceState(value) {
|
|
11
|
+
if (
|
|
12
|
+
!value ||
|
|
13
|
+
typeof value !== "object" ||
|
|
14
|
+
Array.isArray(value) ||
|
|
15
|
+
Object.keys(value).sort().join(",") !== "currentWorkspaceId,schemaVersion" ||
|
|
16
|
+
value.schemaVersion !== 1 ||
|
|
17
|
+
!isStableId(value.currentWorkspaceId)
|
|
18
|
+
) {
|
|
19
|
+
throw new ZhikeCliError(
|
|
20
|
+
"zhike_development_workspace_invalid",
|
|
21
|
+
`${DEVELOPMENT_WORKSPACE_PATH} 不是受支持的Development Workspace状态`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return Object.freeze({ ...value });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function readDevelopmentWorkspace(projectDirectory) {
|
|
28
|
+
let content;
|
|
29
|
+
try {
|
|
30
|
+
content = await readFile(resolve(projectDirectory, DEVELOPMENT_WORKSPACE_PATH), "utf8");
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (error?.code === "ENOENT") return null;
|
|
33
|
+
throw new ZhikeCliError(
|
|
34
|
+
"zhike_development_workspace_read_failed",
|
|
35
|
+
"无法读取当前Development Workspace",
|
|
36
|
+
{ cause: error },
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
return parseDevelopmentWorkspaceState(JSON.parse(content));
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error instanceof ZhikeCliError) throw error;
|
|
43
|
+
throw new ZhikeCliError(
|
|
44
|
+
"zhike_development_workspace_invalid",
|
|
45
|
+
`${DEVELOPMENT_WORKSPACE_PATH} 不是受支持的Development Workspace状态`,
|
|
46
|
+
{ cause: error },
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function writeDevelopmentWorkspace(projectDirectory, workspaceId) {
|
|
52
|
+
const state = parseDevelopmentWorkspaceState({
|
|
53
|
+
schemaVersion: 1,
|
|
54
|
+
currentWorkspaceId: workspaceId,
|
|
55
|
+
});
|
|
56
|
+
const directory = resolve(projectDirectory, ".zhike");
|
|
57
|
+
const target = resolve(projectDirectory, DEVELOPMENT_WORKSPACE_PATH);
|
|
58
|
+
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
59
|
+
try {
|
|
60
|
+
await mkdir(directory, { recursive: true });
|
|
61
|
+
await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { flag: "wx" });
|
|
62
|
+
await rename(temporary, target);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
throw new ZhikeCliError(
|
|
65
|
+
"zhike_development_workspace_write_failed",
|
|
66
|
+
"无法保存当前Development Workspace",
|
|
67
|
+
{ cause: error },
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return state;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function resolveDevelopmentWorkspace({
|
|
74
|
+
workspaces,
|
|
75
|
+
currentWorkspaceId = null,
|
|
76
|
+
requestedWorkspaceId = null,
|
|
77
|
+
selectWorkspace = promptForWorkspace,
|
|
78
|
+
}) {
|
|
79
|
+
const normalized = normalizeWorkspaces(workspaces);
|
|
80
|
+
if (normalized.length === 0) {
|
|
81
|
+
throw new ZhikeCliError(
|
|
82
|
+
"zhike_manageable_workspace_missing",
|
|
83
|
+
"当前账号没有可用于App开发的Workspace管理权限",
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (requestedWorkspaceId) {
|
|
87
|
+
const requested = normalized.find(({ id }) => id === requestedWorkspaceId);
|
|
88
|
+
if (!requested) throw workspaceNotManageable();
|
|
89
|
+
return requested;
|
|
90
|
+
}
|
|
91
|
+
const current = normalized.find(({ id }) => id === currentWorkspaceId);
|
|
92
|
+
const selectedId = await selectWorkspace(normalized, current?.id ?? null);
|
|
93
|
+
const selected = normalized.find(({ id }) => id === selectedId);
|
|
94
|
+
if (!selected) throw workspaceNotManageable();
|
|
95
|
+
return selected;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function promptForWorkspace(workspaces, defaultWorkspaceId = null) {
|
|
99
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
100
|
+
try {
|
|
101
|
+
const defaultIndex = workspaces.findIndex(({ id }) => id === defaultWorkspaceId);
|
|
102
|
+
process.stdout.write("请选择当前Development Workspace:\n");
|
|
103
|
+
workspaces.forEach((workspace, index) => {
|
|
104
|
+
process.stdout.write(
|
|
105
|
+
` ${index + 1}. ${workspace.name} (${workspace.id})${index === defaultIndex ? " [上次选择,默认]" : ""}\n`,
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
const answer = (await prompt.question(defaultIndex >= 0
|
|
109
|
+
? `输入序号(回车选择 ${defaultIndex + 1}):`
|
|
110
|
+
: "输入序号:")).trim();
|
|
111
|
+
if (!answer) return workspaces[defaultIndex]?.id ?? "";
|
|
112
|
+
const index = /^\d+$/.test(answer) ? Number(answer) - 1 : -1;
|
|
113
|
+
return workspaces[index]?.id ?? "";
|
|
114
|
+
} finally {
|
|
115
|
+
prompt.close();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function normalizeWorkspaces(value) {
|
|
120
|
+
if (!Array.isArray(value)) throw workspaceResponseInvalid();
|
|
121
|
+
const ids = new Set();
|
|
122
|
+
return value.map((workspace) => {
|
|
123
|
+
if (
|
|
124
|
+
!workspace ||
|
|
125
|
+
typeof workspace !== "object" ||
|
|
126
|
+
!isStableId(workspace.id) ||
|
|
127
|
+
typeof workspace.name !== "string" ||
|
|
128
|
+
!workspace.name.trim() ||
|
|
129
|
+
ids.has(workspace.id)
|
|
130
|
+
) {
|
|
131
|
+
throw workspaceResponseInvalid();
|
|
132
|
+
}
|
|
133
|
+
ids.add(workspace.id);
|
|
134
|
+
return Object.freeze({ id: workspace.id, name: workspace.name.trim() });
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isStableId(value) {
|
|
139
|
+
return typeof value === "string" && /^[0-9A-HJKMNP-TV-Z]{26}$/.test(value);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function workspaceNotManageable() {
|
|
143
|
+
return new ZhikeCliError(
|
|
144
|
+
"zhike_workspace_not_manageable",
|
|
145
|
+
"选择的Workspace不存在或当前账号已无管理权限",
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function workspaceResponseInvalid() {
|
|
150
|
+
return new ZhikeCliError(
|
|
151
|
+
"zhike_manageable_workspaces_invalid",
|
|
152
|
+
"Developer Server返回了不受支持的Workspace列表",
|
|
153
|
+
);
|
|
154
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export class ZhikeCliError extends Error {
|
|
2
|
+
constructor(code, message, options = {}) {
|
|
3
|
+
super(message, options);
|
|
4
|
+
this.name = "ZhikeCliError";
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.exitCode = options.exitCode ?? 1;
|
|
7
|
+
this.retryable = options.retryable ?? false;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function asSafeCliError(error) {
|
|
12
|
+
if (error instanceof ZhikeCliError) {
|
|
13
|
+
return error;
|
|
14
|
+
}
|
|
15
|
+
return new ZhikeCliError(
|
|
16
|
+
"zhike_unexpected_error",
|
|
17
|
+
"操作失败,请检查 Developer Server 状态后重试",
|
|
18
|
+
{ cause: error, retryable: true },
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// 此文件由 scripts/generate-components.mjs 生成,请修改产品能力声明。
|
|
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
|
+
export const formComponent = "Form";
|
|
@@ -0,0 +1,41 @@
|
|
|
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';
|
|
5
|
+
import { boundComponents, formComponent } from './components.generated.js';
|
|
6
|
+
import { useNormalizedOptions } from './normalize-options.js';
|
|
7
|
+
export function createFormHook(React, { useBindCallback = () => callback => callback, useInvoke = () => () => Promise.reject(new Error('runtime_host_interaction_unavailable')) } = {}) {
|
|
8
|
+
const { createElement, useId, useMemo, useRef } = React;
|
|
9
|
+
|
|
10
|
+
return function useForm(options) {
|
|
11
|
+
const formId = useId();
|
|
12
|
+
const bindCallback = useBindCallback();
|
|
13
|
+
const invoke = useInvoke();
|
|
14
|
+
const initial = useRef(options);
|
|
15
|
+
return useMemo(() => {
|
|
16
|
+
const { initialValues } = initial.current;
|
|
17
|
+
const { schema, ...validation } = compileForm(initial.current, formId, bindCallback);
|
|
18
|
+
const arrays = createFieldArrays(React, formId, schema, invoke);
|
|
19
|
+
const field = type => function Field({ children, ...props }) {
|
|
20
|
+
const binding = arrays.useBinding(props.name);
|
|
21
|
+
if (type === 'Combobox' || type === 'RecordCombobox') {
|
|
22
|
+
const { options, ...rest } = props;
|
|
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 });
|
|
27
|
+
}
|
|
28
|
+
return createElement(type, { ...props, ...binding, formId }, children);
|
|
29
|
+
};
|
|
30
|
+
return Object.freeze({
|
|
31
|
+
FieldArray: arrays.FieldArray,
|
|
32
|
+
WithState: createWithState(React, formId, schema, initialValues),
|
|
33
|
+
Form: function Form({ children, onSubmit }) {
|
|
34
|
+
return createElement(formComponent, { formId, schema, initialValues, onSubmit, ...validation }, children);
|
|
35
|
+
},
|
|
36
|
+
...Object.fromEntries(boundComponents.map(type => [type, field(type)])),
|
|
37
|
+
});
|
|
38
|
+
}, [formId]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// 此文件由 scripts/generate-components.mjs 生成,请修改产品能力声明。
|
|
2
|
+
import type { ComponentType, ReactNode } from "react";
|
|
3
|
+
import type { FormSchema, FormValues, FieldName, FormSubmitResult, FormOptions } from "./schema.js";
|
|
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";
|
|
11
|
+
export type { Option, OptionsPage, OptionsProvider } from "./options.js";
|
|
12
|
+
export { Forms } from "./schema.js";
|
|
13
|
+
export type { FormField, FormSchema, FormValues, FieldName, FormSubmitResult, FormOptions, FormValidationResult, FormFieldErrors, FormArrayErrors, ArrayName } from "./schema.js";
|
|
14
|
+
export interface FormHandle<S extends FormSchema> {
|
|
15
|
+
WithState: WithStateComponent<S>;
|
|
16
|
+
FieldArray: FieldArrayComponent<S>;
|
|
17
|
+
Form: ComponentType<{
|
|
18
|
+
children?: ReactNode;
|
|
19
|
+
onSubmit: (values: FormValues<S>) => FormSubmitResult<S> | Promise<FormSubmitResult<S>>;
|
|
20
|
+
}>;
|
|
21
|
+
TextInput: ComponentType<{
|
|
22
|
+
name: FieldName<S, "string">;
|
|
23
|
+
description?: string;
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
type?: "text" | "email" | "password" | "tel" | "url";
|
|
27
|
+
}>;
|
|
28
|
+
Checkbox: ComponentType<{
|
|
29
|
+
name: FieldName<S, "boolean">;
|
|
30
|
+
description?: string;
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
labelHidden?: boolean;
|
|
33
|
+
}>;
|
|
34
|
+
Select: ComponentType<{
|
|
35
|
+
name: FieldName<S, "enum">;
|
|
36
|
+
description?: string;
|
|
37
|
+
placeholder?: string;
|
|
38
|
+
disabled?: boolean;
|
|
39
|
+
}>;
|
|
40
|
+
Combobox: ComponentType<{
|
|
41
|
+
name: FieldName<S, "string">;
|
|
42
|
+
description?: string;
|
|
43
|
+
placeholder?: string;
|
|
44
|
+
disabled?: boolean;
|
|
45
|
+
options: readonly Option[] | OptionsProvider;
|
|
46
|
+
}>;
|
|
47
|
+
MemberCombobox: ComponentType<{
|
|
48
|
+
name: FieldName<S, "string">;
|
|
49
|
+
description?: string;
|
|
50
|
+
placeholder?: string;
|
|
51
|
+
disabled?: boolean;
|
|
52
|
+
}>;
|
|
53
|
+
TeamCombobox: ComponentType<{
|
|
54
|
+
name: FieldName<S, "string">;
|
|
55
|
+
description?: string;
|
|
56
|
+
placeholder?: string;
|
|
57
|
+
disabled?: boolean;
|
|
58
|
+
}>;
|
|
59
|
+
SubmitButton: ComponentType<{
|
|
60
|
+
children?: string;
|
|
61
|
+
disabled?: boolean;
|
|
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
|
+
}>;
|
|
116
|
+
}
|
|
117
|
+
/** schema 和 initialValues 仅在首次挂载使用;重新渲染不会重置输入。 */
|
|
118
|
+
export function useForm<const S extends FormSchema>(options: FormOptions<S>): FormHandle<S>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { assertOptions, assertOptionsSearch, assertOptionsPage, assertOptionValue, assertSelectedOption } from './options.js';
|
|
2
|
+
export function useNormalizedOptions({ useRef, useMemo }, options) {
|
|
3
|
+
const source = useRef({ options: null, search: null, getOption: null, revision: 0 });
|
|
4
|
+
const search = options?.search, getOption = options?.getOption;
|
|
5
|
+
if (source.current.options !== options || source.current.search !== search || source.current.getOption !== getOption) {
|
|
6
|
+
source.current = { options, search, getOption, revision: source.current.revision + 1 };
|
|
7
|
+
}
|
|
8
|
+
const revision = source.current.revision;
|
|
9
|
+
const normalized = useMemo(() => {
|
|
10
|
+
if (Array.isArray(options)) return { options: assertOptions(options) };
|
|
11
|
+
if (!options || typeof search !== 'function' || typeof getOption !== 'function') throw new Error('runtime_options_invalid');
|
|
12
|
+
return {
|
|
13
|
+
sourceRevision: String(revision),
|
|
14
|
+
onSearch: input => Promise.resolve(search.call(options, assertOptionsSearch(input))).then(assertOptionsPage),
|
|
15
|
+
onGetOption: value => Promise.resolve(getOption.call(options, assertOptionValue(value))).then(option => assertSelectedOption(option, value)),
|
|
16
|
+
};
|
|
17
|
+
}, [revision]);
|
|
18
|
+
return normalized;
|
|
19
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { AppIconName, BadgeProps } from '../ui/index.js';
|
|
2
|
+
/** value 是持久化标识,label 只负责显示。无法回显不能清除原 value。 */
|
|
3
|
+
export type Option = {
|
|
4
|
+
value: string;
|
|
5
|
+
label: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
disabledReason?: string;
|
|
9
|
+
categoryLabel?: string;
|
|
10
|
+
avatarUrl?: string;
|
|
11
|
+
icon?: AppIconName;
|
|
12
|
+
color?: NonNullable<BadgeProps['color']>;
|
|
13
|
+
}
|
|
14
|
+
export type OptionsPage = {
|
|
15
|
+
items: Option[];
|
|
16
|
+
/** null 表示末页;游标只能在当前搜索下使用。 */
|
|
17
|
+
nextCursor: string | null;
|
|
18
|
+
}
|
|
19
|
+
export interface OptionsProvider {
|
|
20
|
+
search(input: { query: string; cursor?: string }): Promise<OptionsPage>;
|
|
21
|
+
/** null 表示成功查询但无法解析;读取失败应拒绝 Promise。 */
|
|
22
|
+
getOption(value: string): Promise<Option | null>;
|
|
23
|
+
}
|
|
24
|
+
export const OPTIONS_PAGE_LIMIT: number;
|
|
25
|
+
export function assertOption(value: unknown): Option;
|
|
26
|
+
export function assertOptionValue(value: unknown): string;
|
|
27
|
+
export function assertOptions(value: unknown): Option[];
|
|
28
|
+
export function assertOptionsSearch(value: unknown): { query: string; cursor?: string };
|
|
29
|
+
export function assertOptionsPage(value: unknown): OptionsPage;
|
|
30
|
+
export function assertSelectedOption(option: unknown, value: string): Option | null;
|