@77-zhike/app-sdk 0.2.0-d7b1.7 → 0.2.0-d7b1.9

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 +13 -7
  2. package/package.json +9 -3
  3. package/src/cli/development-logs.js +5 -2
  4. package/src/cli/index.js +11 -23
  5. package/src/cli/local-commands.js +1 -0
  6. package/src/cli/project-init.js +2 -1
  7. package/src/forms/array-contract.d.ts +32 -0
  8. package/src/forms/array-contract.js +138 -0
  9. package/src/forms/factory.js +9 -4
  10. package/src/forms/field-array.d.ts +16 -0
  11. package/src/forms/field-array.js +52 -0
  12. package/src/forms/index.d.ts +5 -1
  13. package/src/forms/schema.d.ts +30 -11
  14. package/src/forms/schema.js +29 -6
  15. package/src/forms/state.d.ts +6 -4
  16. package/src/forms/state.js +14 -24
  17. package/src/forms/validation.d.ts +3 -3
  18. package/src/forms/validation.js +24 -26
  19. package/src/server/index.d.ts +40 -5
  20. package/src/server/index.js +21 -0
  21. package/src/server/steps/index.d.ts +71 -0
  22. package/src/server/steps/index.js +15 -0
  23. package/src/server/triggers/index.d.ts +68 -0
  24. package/src/server/triggers/index.js +27 -0
  25. package/src/server/workflow-blocks/index.d.ts +72 -0
  26. package/src/server/workflow-blocks/index.js +32 -0
  27. package/src/toolchain/allowed-packages.json +7 -0
  28. package/src/toolchain/dependency-policy.js +175 -0
  29. package/src/toolchain/index.js +0 -88
  30. package/src/toolchain/real-build.js +59 -17
  31. package/src/toolchain/source-discovery.js +76 -65
  32. package/src/toolchain/workflow-blocks/discovery.js +133 -0
  33. package/src/toolchain/workflow-steps/manifest.js +99 -0
  34. package/src/toolchain/workflow-triggers/manifest.js +47 -0
  35. package/src/ui/components.generated.js +3 -2
  36. package/src/ui/factory.js +1 -1
  37. package/src/ui/index.d.ts +5 -5
  38. package/src/ui/index.js +1 -1
  39. package/src/ui/validation.js +3 -3
  40. package/src/cli/validate-bundle.js +0 -36
package/README.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # 智客 App SDK 与 CLI
2
2
 
3
+ ## 获准依赖
4
+
5
+ 工具链从 `src/toolchain/allowed-packages.json` 读取平台维护的包白名单。目前允许 `@77-zhike/qiqi-openapi@0.1.0` 的服务端包根入口 `.`;前端不可导入。项目安装依赖后直接使用静态 ESM import,真实解析的包名、精确版本、入口及间接模块在打包时核对,代码合并进 Artifact。App 的 Manifest、Settings 或 `package.json` 不能扩展名单。
6
+
7
+ Node builtin、CommonJS、动态 import、原生模块、未获准包和路径越界仍禁止。项目内 `file:` 包可用,但同名同版本不是来源证明。白名单只控制构建兼容性;不替代运行时沙箱、网络策略、租户隔离及 API 权限,不进行运行时 npm 安装。
8
+
3
9
  ## 统一构建
4
10
 
5
11
  `zhike dev` 是监听、构建、上传和应用开发安装的快捷工作流,不是另一种运行时。
@@ -133,7 +139,7 @@ import {
133
139
  } from '@77-zhike/app-sdk/client'
134
140
  import {
135
141
  Card,
136
- Link,
142
+ ZhikeLink,
137
143
  Stack,
138
144
  TextBlock,
139
145
  } from '@77-zhike/app-sdk/ui'
@@ -144,9 +150,9 @@ function QueuePage() {
144
150
  <Card title="任务队列">
145
151
  <TextBlock>当前没有待处理任务。</TextBlock>
146
152
  </Card>
147
- <Link destination={Destinations.appPage('overview')}>
153
+ <ZhikeLink destination={Destinations.appPage('overview')}>
148
154
  返回总览
149
- </Link>
155
+ </ZhikeLink>
150
156
  </Stack>
151
157
  )
152
158
  }
@@ -161,13 +167,13 @@ export default definePage({
161
167
 
162
168
  同一 App 内,有 `order` 的 Page 排在前面并按 `order + slug` 稳定排序;未声明 `order` 的 Page 排在后面并按 slug 排序。`name` trim 后长度为 1~80 个 Unicode code points。`variant: 'full' | 'centered'` 只表达布局语义,Desktop 与 H5 分别决定具体页面壳。
163
169
 
164
- Page UI 必须使用 `@77-zhike/app-sdk/ui` 和 `@77-zhike/app-sdk/forms` 的公开组件,不能依赖 DOM、React Router 或自行复制产品控件。当前 Desktop 支持 Components 与 Forms;H5 支持 TextBlock、布局、反馈、Button、Tabs、Table、Card、Grid、EmptyState、Link 等 portable Components,并支持 Forms,暂不支持 `LookupCell`。含有不支持能力的 Page 在 H5 会整体显示“不支持”,不会静默丢节点或部分提交。
170
+ Page UI 必须使用 `@77-zhike/app-sdk/ui` 和 `@77-zhike/app-sdk/forms` 的公开组件,不能依赖 DOM、React Router 或自行复制产品控件。当前 Desktop 支持 Components 与 Forms;H5 支持 TextBlock、布局、反馈、Button、Tabs、Table、Card、Grid、EmptyState、Link、ZhikeLink 等 portable Components,并支持 Forms,暂不支持 `LookupCell`。含有不支持能力的 Page 在 H5 会整体显示“不支持”,不会静默丢节点或部分提交。
165
171
 
166
172
  导航只提交结构化 Destination:
167
173
 
168
174
  - `Destinations.appPage(slug)` 打开当前 App 的另一个 Page。
169
175
  - `Destinations.record({ object, recordId })` 支持 `accounts`、`contacts`、`opportunities`、`tasks`、`activities` 和当前 Workspace 可见的 `custom_*` 公开 API slug。
170
- - `Link` 保留普通链接、复制地址和新标签行为;普通点击仍会在跳转前重验当前 Page 身份。
176
+ - `ZhikeLink` 保留普通链接、复制地址和新标签行为;普通点击仍会在跳转前重验当前 Page 身份。外部 HTTPS 使用 `Link`。
171
177
  - `navigate()` 当前只允许从已挂载的 Page 调用,三类 Action 都不具备该能力。
172
178
 
173
179
  目录展示不会启动 Worker。首次打开 Page 时,宿主才会下载并校验 Manifest/Bundle、启动 Installation Worker;因此“Apps 菜单可见”不等于代码已经验证可运行。页面数据读写仍应通过受控 Server Function 完成。
@@ -196,9 +202,9 @@ export default async function readForCurrentMember() {
196
202
 
197
203
  ## 基础UI组件扩展
198
204
 
199
- 新增Avatar、DescriptionList、Divider、Json、ExternalLink、StatusBadge、TextBlock与Typography,补齐Banner动作、Badge颜色、Card呈现、Grid容器模式、EmptyState操作编排和Table.HeaderCell。原flat exports与默认调用保留,复合写法使用同一节点。
205
+ 新增Avatar、DescriptionList、Divider、Json、外部Link、StatusBadge、TextBlock与Typography,补齐Banner动作、Badge颜色、Card呈现、Grid容器模式、EmptyState操作编排和Table.HeaderCell。复合写法使用同一节点。
200
206
 
201
- Desktop注册42项,H5注册41项(不含LookupCell),其中包括 `Widget.TextWidget`、`Widget.Title`、`Widget.Text.Primary`、`Widget.Text.Secondary`、`Widget.Loading` 与 `Widget.Decoration` 六个 Widget 语义节点。Link仍仅限Page;H5支持Forms,仍不开放Settings入口。新包要求`remote-react.v3`,发布前必须先部署对应Host及Bundle准入合同。
207
+ Desktop与H5注册的组件合同中,`ZhikeLink` 仍仅限Page,`Link` 是受治理的外部HTTPS链接;H5支持Forms,仍不开放Settings入口。新包要求`remote-react.v3`,发布前必须先部署对应Host及Bundle准入合同。
202
208
 
203
209
  新增组件的日常验证:修改Core `components/contracts.mjs`后执行`npm run generate:components --workspace @77-zhike/app-sdk`和`npm run check:components --workspace @77-zhike/app-sdk`,同步双端manifest与adapter。App项目运行`zhike dev --workspace <workspace-id>`,从对应Workspace的Apps入口打开Page;Desktop Settings仅验证其允许的子集。
204
210
 
package/package.json CHANGED
@@ -1,7 +1,12 @@
1
1
  {
2
2
  "name": "@77-zhike/app-sdk",
3
- "version": "0.2.0-d7b1.7",
4
- "description": "\u667a\u5ba2 Integration App \u4e00\u671f SDK \u4e0e\u672c\u5730 Toolchain",
3
+ "version": "0.2.0-d7b1.9",
4
+ "description": "智客 Integration App 一期 SDK 与本地 Toolchain",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/tonyjiang88215/TruthLoop.git",
8
+ "directory": "developer-tools/packages/app-sdk"
9
+ },
5
10
  "type": "module",
6
11
  "engines": {
7
12
  "node": ">=20"
@@ -53,5 +58,6 @@
53
58
  "react": {
54
59
  "optional": true
55
60
  }
56
- }
61
+ },
62
+ "gitHead": "d820fb6f04634518f062b82cf214d3b08d00c481"
57
63
  }
@@ -32,9 +32,12 @@ export function formatAppLog(app, entry, {
32
32
  const levelLabel = { log: "INFO ", warn: "WARN ", error: "ERROR" }[level];
33
33
  const levelColor = { log: colors.cyan, warn: colors.yellow, error: colors.red }[level];
34
34
  const timestamp = formatTimestamp(entry.received_at);
35
+ const version = entry.app_version_id ? safe(entry.app_version_id).replace(/\n/g, " ").slice(0, 64) : "";
36
+ const request = entry.invocation_id ? safe(entry.invocation_id).replace(/\n/g, " ").slice(0, 64) : "";
37
+ const identity = version || request ? ` [${[version, request].filter(Boolean).join(" · ")}]` : "";
35
38
  const prefix = color
36
- ? `${colors.dim}${timestamp}${colors.reset} ${levelColor}${levelLabel}${colors.reset} ${colors.bold}${label}${colors.reset}`
37
- : `${timestamp} ${levelLabel} ${label}`;
39
+ ? `${colors.dim}${timestamp}${colors.reset} ${levelColor}${levelLabel}${colors.reset} ${colors.bold}${label}${colors.reset}${identity}`
40
+ : `${timestamp} ${levelLabel} ${label}${identity}`;
38
41
  return safe(entry.message).split("\n").map((line) => `${prefix} ${line}`).join("\n");
39
42
  }
40
43
 
package/src/cli/index.js CHANGED
@@ -2,7 +2,7 @@ import { resolve } from "node:path";
2
2
 
3
3
  import { loadCliConfig } from "../config.js";
4
4
  import { asSafeCliError, ZhikeCliError } from "../errors.js";
5
- import { buildAppBundle, buildMockArtifact } from "../toolchain/index.js";
5
+ import { buildAppBundle } from "../toolchain/index.js";
6
6
  import { authenticateCli, openSystemBrowser } from "./browser-auth.js";
7
7
  import { createSystemCredentialStore } from "./credential-store.js";
8
8
  import {
@@ -10,7 +10,6 @@ import {
10
10
  initializeProject,
11
11
  validateAppSlug,
12
12
  } from "./project-init.js";
13
- import { validateBundleUpload } from "./validate-bundle.js";
14
13
  import { uploadAppBundle } from "./upload-bundle.js";
15
14
  import { watchDevelopmentApp } from "./development-watch.js";
16
15
  import { runLocalCommand } from "./local-commands.js";
@@ -60,6 +59,16 @@ export async function runCli(
60
59
  );
61
60
  return result;
62
61
  }
62
+ if (command === "validate") {
63
+ _rejectArguments(rest);
64
+ const result = await buildAppBundle({
65
+ projectDirectory: cwd,
66
+ });
67
+ output.write(
68
+ `真实 App 源码校验通过:${result.artifacts.length} Artifacts,Bundle ${result.sha256} (${result.size} bytes);未写入 dist\n`,
69
+ );
70
+ return result;
71
+ }
63
72
 
64
73
  const onProgress = (message) => output.write(`${message}\n`);
65
74
  let remoteContext = null;
@@ -186,26 +195,6 @@ export async function runCli(
186
195
  if (result && !ready) await onReady(result);
187
196
  return result;
188
197
  }
189
- if (command === "experimental" && rest[0] === "validate-bundle") {
190
- _rejectArguments(rest.slice(1));
191
- const { config, store } = getRemoteContext();
192
- const authenticatedApi = await authenticate({
193
- ...config,
194
- credentialStore: store,
195
- fetchImpl,
196
- openBrowser,
197
- onProgress,
198
- });
199
- const result = await validateBundleUpload({
200
- projectDirectory: cwd,
201
- apiBaseUrl: config.apiBaseUrl,
202
- authenticatedApi,
203
- });
204
- output.write(
205
- `${result.upload.status === "accepted" ? "Bundle 已受理" : "Bundle 校验失败"}:${result.upload.id}\n`,
206
- );
207
- return result;
208
- }
209
198
  throw new ZhikeCliError(
210
199
  "zhike_command_unknown",
211
200
  `未知命令:${command}`,
@@ -260,5 +249,4 @@ export {
260
249
  initializeProject,
261
250
  scaffoldProject,
262
251
  } from "./project-init.js";
263
- export { validateBundleUpload } from "./validate-bundle.js";
264
252
  export { uploadAppBundle } from "./upload-bundle.js";
@@ -13,6 +13,7 @@ export const commandHelp = {
13
13
  doctor: "doctor [--json]",
14
14
  login: "login",
15
15
  init: "init <app-slug>",
16
+ validate: "validate",
16
17
  build: "build",
17
18
  upload: "upload",
18
19
  dev: "dev [--workspace <workspace-id>]",
@@ -100,6 +100,7 @@ export async function scaffoldProject({ targetDirectory, binding }) {
100
100
  - 开发文档:${docsUrl}
101
101
 
102
102
  先读取文档索引,再按当前任务读取相关的 App SDK、CLI、扩展、连接和 API 文档;不要仅凭记忆猜测接口、参数或运行时能力。
103
+ 每使用一个复合 UI 或 Form 组件,必须打开该组件的专属文档,核对允许的直属子节点、嵌套顺序和入口限制。TypeScript、zhike validate、zhike build 或上传成功,不代表组件树已经符合运行时合同。
103
104
  索引中的相对链接以开发文档地址为基准解析。若无法访问文档,请明确说明访问失败,请用户提供相关文档,不要把猜测当作已确认能力。
104
105
  项目绑定环境与 App 身份见 .zhike/project.json;切换平台环境后应同步更新上述文档地址。
105
106
  `;
@@ -137,10 +138,10 @@ export async function scaffoldProject({ targetDirectory, binding }) {
137
138
  type: "module",
138
139
  scripts: {
139
140
  zhike: "zhike",
141
+ validate: "zhike validate",
140
142
  build: "zhike build",
141
143
  dev: "zhike dev",
142
144
  upload: "zhike upload",
143
- "validate:mock": "zhike experimental validate-bundle",
144
145
  },
145
146
  devDependencies: {
146
147
  "@77-zhike/app-sdk": TOOLING_VERSION,
@@ -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,29 +1,34 @@
1
+ import { createFieldArrays } from './field-array.js';
1
2
  import { compileForm } from './schema.js';
2
3
  import { createWithState } from './state.js';
3
4
  import { useNormalizedRecordOptions, recordObjectsForField } from './record-options.js';
4
5
  import { boundComponents, formComponent } from './components.generated.js';
5
6
  import { useNormalizedOptions } from './normalize-options.js';
6
- export function createFormHook(React, { useBindCallback = () => callback => callback } = {}) {
7
+ export function createFormHook(React, { useBindCallback = () => callback => callback, useInvoke = () => () => Promise.reject(new Error('runtime_host_interaction_unavailable')) } = {}) {
7
8
  const { createElement, useId, useMemo, useRef } = React;
8
9
 
9
10
  return function useForm(options) {
10
11
  const formId = useId();
11
12
  const bindCallback = useBindCallback();
13
+ const invoke = useInvoke();
12
14
  const initial = useRef(options);
13
15
  return useMemo(() => {
14
16
  const { initialValues } = initial.current;
15
17
  const { schema, ...validation } = compileForm(initial.current, formId, bindCallback);
18
+ const arrays = createFieldArrays(React, formId, schema, invoke);
16
19
  const field = type => function Field({ children, ...props }) {
20
+ const binding = arrays.useBinding(props.name);
17
21
  if (type === 'Combobox' || type === 'RecordCombobox') {
18
22
  const { options, ...rest } = props;
19
23
  const normalized = type === 'RecordCombobox'
20
- ? useNormalizedRecordOptions({ useRef, useMemo }, options, recordObjectsForField(schema.fields[props.name]))
24
+ ? useNormalizedRecordOptions({ useRef, useMemo }, options, recordObjectsForField(binding.arrayBinding ? schema.fields[binding.arrayBinding.name].item.fields[binding.name] : schema.fields[props.name]))
21
25
  : useNormalizedOptions({ useRef, useMemo }, options);
22
- return createElement(type, { ...rest, ...normalized, formId });
26
+ return createElement(type, { ...rest, ...normalized, ...binding, formId });
23
27
  }
24
- return createElement(type, { ...props, formId }, children);
28
+ return createElement(type, { ...props, ...binding, formId }, children);
25
29
  };
26
30
  return Object.freeze({
31
+ FieldArray: arrays.FieldArray,
27
32
  WithState: createWithState(React, formId, schema, initialValues),
28
33
  Form: function Form({ children, onSubmit }) {
29
34
  return createElement(formComponent, { formId, schema, initialValues, onSubmit, ...validation }, children);
@@ -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
+ }
@@ -3,14 +3,17 @@ import type { ComponentType, ReactNode } from "react";
3
3
  import type { FormSchema, FormValues, FieldName, FormSubmitResult, FormOptions } from "./schema.js";
4
4
  import type { Option, OptionsPage, OptionsProvider } from "./options.js";
5
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";
6
8
  import type { WithStateComponent } from "./state.js";
7
9
  export type { FormStateSelection, SelectedFormState } from "./state.js";
8
10
  export type { RecordRef, RecordObjectOption, RecordOption, RecordOptionsPage, RecordOptionsProvider } from "./record-options.js";
9
11
  export type { Option, OptionsPage, OptionsProvider } from "./options.js";
10
12
  export { Forms } from "./schema.js";
11
- export type { FormField, FormSchema, FormValues, FieldName, FormSubmitResult, FormOptions, FormValidationResult } from "./schema.js";
13
+ export type { FormField, FormSchema, FormValues, FieldName, FormSubmitResult, FormOptions, FormValidationResult, FormFieldErrors, FormArrayErrors, ArrayName } from "./schema.js";
12
14
  export interface FormHandle<S extends FormSchema> {
13
15
  WithState: WithStateComponent<S>;
16
+ FieldArray: FieldArrayComponent<S>;
14
17
  Form: ComponentType<{
15
18
  children?: ReactNode;
16
19
  onSubmit: (values: FormValues<S>) => FormSubmitResult<S> | Promise<FormSubmitResult<S>>;
@@ -69,6 +72,7 @@ export interface FormHandle<S extends FormSchema> {
69
72
  description?: string;
70
73
  placeholder?: string;
71
74
  disabled?: boolean;
75
+ step?: number;
72
76
  }>;
73
77
  PlainDateInput: ComponentType<{
74
78
  name: FieldName<S, "plainDate">;
@@ -7,26 +7,44 @@ export type FormField = (
7
7
  | { type: "number"; label: string; required?: boolean; min?: number; max?: number }
8
8
  | { type: "plainDate" | "dateTime"; label: string; required?: boolean; min?: string; max?: string }
9
9
  | ({ type: "record"; label: string; required?: boolean } & ({ objectSlug: string; objects?: never } | { objectSlug?: never; objects: readonly [RecordObjectOption] }))) & { messages?: RuleMessages };
10
- export interface FormSchema { fields: Readonly<Record<string, FormField | { type: FormField['type']; label: (text: string) => unknown }>>; }
11
- export type FormValues<S extends FormSchema> = {
12
- [K in keyof S["fields"]]: S["fields"][K] extends { type: "boolean" } ? boolean
13
- : S["fields"][K] extends { type: "record" } ? (S["fields"][K] extends { objectSlug: infer O extends string } ? RecordRef & { objectSlug: O } : RecordRef) | null
14
- : S["fields"][K] extends { type: "number" } ? number | null
15
- : S["fields"][K] extends { type: "plainDate" | "dateTime" } ? string | null : string;
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>;
16
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;
17
28
  export type FieldName<S extends FormSchema, T extends FormField['type']> = {
18
- [K in keyof S['fields']]: S['fields'][K]['type'] extends T ? K : never;
19
- }[keyof S['fields']] & string;
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>>;
20
36
  /** void 兼容既有提交;失败保留输入,字段名必须属于当前表单。 */
21
37
  export type FormSubmitResult<S extends FormSchema> = void | { ok: true } | {
22
38
  ok: false;
23
39
  code: string;
24
40
  message: string;
25
- fieldErrors?: Partial<Record<Extract<keyof S['fields'], string>, string>>;
41
+ fieldErrors?: FormFieldErrors<S>;
42
+ arrayErrors?: FormArrayErrors<S>;
26
43
  };
27
44
 
28
45
  export type FormValidationResult<S extends FormSchema> = void | {
29
- fieldErrors?: Partial<Record<Extract<keyof S['fields'], string>, string>>;
46
+ fieldErrors?: FormFieldErrors<S>;
47
+ arrayErrors?: FormArrayErrors<S>;
30
48
  formError?: string;
31
49
  };
32
50
  type FieldValue<F extends FormField> = FormValues<{ fields: { value: F } }>['value'];
@@ -52,7 +70,8 @@ export type FieldBuilder<F extends FormField> = Omit<F, 'label'> & {
52
70
  } : {});
53
71
  // builder 的 label 是方法,wire 的 label 是字符串;只有编译后才跨 Worker 边界。
54
72
  export const Forms: {
55
- schema<const F extends Record<string, { type: FormField['type']; label: (text: string) => unknown }>>(fields: F): { readonly fields: F };
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>;
56
75
  string(): FieldBuilder<Extract<FormField, { type: 'string' }>>;
57
76
  number(): FieldBuilder<Extract<FormField, { type: 'number' }>>;
58
77
  boolean(): FieldBuilder<Extract<FormField, { type: 'boolean' }>>;