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

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.
@@ -0,0 +1,92 @@
1
+ import { assertJsonValue } from '../ui/validation.js';
2
+ import { isDateTime, isPlainDate } from './values.js';
3
+ import { assertRecordRef, recordObjectsForField } from './record-options.js';
4
+ const fail = () => { throw new Error('runtime_form_state_invalid'); };
5
+ const only = (value, keys) => {
6
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !keys.includes(key))) fail();
7
+ };
8
+ export function selectedNames(selection, fields) {
9
+ return selection === true ? Object.keys(fields) : Array.isArray(selection) ? selection : [];
10
+ }
11
+ export function assertFormStateSelection(selection, fields) {
12
+ only(selection, ['values', 'errors', 'submitting', 'validating', 'validatingFields', 'formError']);
13
+ for (const kind of ['values', 'errors', 'validatingFields']) {
14
+ const names = selection[kind];
15
+ if (names === undefined || names === true) continue;
16
+ if (!Array.isArray(names) || names.length > 30 || new Set(names).size !== names.length || names.some(name => typeof name !== 'string' || !Object.hasOwn(fields, name))) fail();
17
+ }
18
+ for (const key of ['submitting', 'validating', 'formError']) if (selection[key] !== undefined && typeof selection[key] !== 'boolean') fail();
19
+ return selection;
20
+ }
21
+ function assertValue(value, field) {
22
+ switch (field.type) {
23
+ case 'boolean': if (typeof value !== 'boolean') fail(); break;
24
+ case 'number': if (value !== null && (typeof value !== 'number' || !Number.isFinite(value))) fail(); break;
25
+ case 'plainDate': if (value !== null && !isPlainDate(value)) fail(); break;
26
+ case 'dateTime': if (value !== null && !isDateTime(value)) fail(); break;
27
+ case 'record': if (value !== null) assertRecordRef(value, recordObjectsForField(field)); break;
28
+ default: if (typeof value !== 'string' || value.length > 10000) fail();
29
+ }
30
+ }
31
+ export function assertFormStateSnapshot(snapshot, formId, fields, selection) {
32
+ assertJsonValue(snapshot);
33
+ assertFormStateSelection(selection, fields);
34
+ const keys = ['formId', 'revision', ...(selection.values !== undefined ? ['values'] : []), ...(selection.errors !== undefined ? ['errors'] : []), ...['submitting', 'validating', 'formError'].filter(key => selection[key]), ...(selection.validatingFields !== undefined ? ['validatingFields'] : [])];
35
+ only(snapshot, keys);
36
+ if (Object.keys(snapshot).length !== keys.length || snapshot.formId !== formId || !Number.isSafeInteger(snapshot.revision) || snapshot.revision < 1) fail();
37
+ if (selection.values !== undefined) {
38
+ const names = selectedNames(selection.values, fields);
39
+ only(snapshot.values, names);
40
+ if (Object.keys(snapshot.values).length !== names.length) fail();
41
+ for (const name of names) assertValue(snapshot.values[name], fields[name]);
42
+ }
43
+ if (selection.errors !== undefined) {
44
+ only(snapshot.errors, selectedNames(selection.errors, fields));
45
+ for (const value of Object.values(snapshot.errors)) if (typeof value !== 'string' || !value || value.length > 4096) fail();
46
+ }
47
+ for (const key of ['submitting', 'validating']) if (selection[key] && typeof snapshot[key] !== 'boolean') fail();
48
+ if (selection.formError && snapshot.formError !== null && (typeof snapshot.formError !== 'string' || !snapshot.formError || snapshot.formError.length > 4096)) fail();
49
+ if (selection.validatingFields !== undefined) {
50
+ only(snapshot.validatingFields, selectedNames(selection.validatingFields, fields));
51
+ if (Object.values(snapshot.validatingFields).some(value => typeof value !== 'boolean')) fail();
52
+ }
53
+ return snapshot;
54
+ }
55
+ export function freezeFormState(value) {
56
+ if (value && typeof value === 'object') { Object.values(value).forEach(freezeFormState); Object.freeze(value); }
57
+ return value;
58
+ }
59
+ export function initialFormState(formId, fields, initialValues, selection) {
60
+ const snapshot = { formId, revision: 1 };
61
+ if (selection.values !== undefined) snapshot.values = Object.fromEntries(selectedNames(selection.values, fields).map(name => {
62
+ const value = initialValues[name];
63
+ return [name, fields[name].type === 'dateTime' && value ? new Date(value).toISOString() : value && typeof value === 'object' ? { ...value } : value];
64
+ }));
65
+ if (selection.errors !== undefined) snapshot.errors = {};
66
+ for (const key of ['submitting', 'validating']) if (selection[key]) snapshot[key] = false;
67
+ if (selection.formError) snapshot.formError = null;
68
+ if (selection.validatingFields !== undefined) snapshot.validatingFields = {};
69
+ assertFormStateSnapshot(snapshot, formId, fields, selection);
70
+ return snapshot;
71
+ }
72
+ export function createWithState({ createElement, Fragment, useMemo, useState, useRef }, formId, schema, initialValues) {
73
+ function Observer({ selection, children }) {
74
+ const revision = useRef(0);
75
+ const [snapshot, setSnapshot] = useState(() => initialFormState(formId, schema.fields, initialValues, selection));
76
+ const onState = useMemo(() => next => {
77
+ assertFormStateSnapshot(next, formId, schema.fields, selection);
78
+ if (next.revision <= revision.current) return;
79
+ revision.current = next.revision;
80
+ setSnapshot(freezeFormState(next));
81
+ }, []);
82
+ const { formId: _, revision: _revision, ...state } = snapshot;
83
+ return createElement(Fragment, null,
84
+ createElement('FormStateObserver', { formId, selection, onState }),
85
+ children(freezeFormState(state)));
86
+ }
87
+ return function WithState({ selection, children }) {
88
+ assertFormStateSelection(selection, schema.fields);
89
+ if (typeof children !== 'function') fail();
90
+ return createElement(Observer, { key: JSON.stringify(selection), selection, children });
91
+ };
92
+ }
@@ -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, string | boolean | number | import("./record-options.js").RecordRef | null>; fields: string[]; includeForm: boolean;
6
+ }
7
+ export interface ValidationResponse { formId: string; runId: number; fieldErrors: 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'>, fields: Record<string, unknown>): ValidationResponse;
@@ -0,0 +1,83 @@
1
+ import { freezeFormState } from './state.js';
2
+ import { isDateTime, isPlainDate } from './values.js';
3
+ import { assertRecordRef, recordObjectsForField } from './record-options.js';
4
+
5
+ export const VALIDATION_TIMEOUT_MS = 10000;
6
+ const fail = () => { throw new Error('runtime_form_validation_invalid'); };
7
+ const only = (value, keys) => {
8
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !keys.includes(key))) fail();
9
+ };
10
+ const validMessage = value => typeof value === 'string' && value.trim() && value.length <= 300;
11
+ export function assertValidationMetadata(value, fields) {
12
+ only(value, ['fields', 'form', 'mode']);
13
+ if (!Array.isArray(value.fields) || value.fields.length > 30 || new Set(value.fields).size !== value.fields.length
14
+ || value.fields.some(name => typeof name !== 'string' || !Object.hasOwn(fields, name))
15
+ || typeof value.form !== 'boolean' || !['onBlur', 'onChange', 'onSubmit'].includes(value.mode)) fail();
16
+ return value;
17
+ }
18
+ export function assertValidationResponse(value, request, fields) {
19
+ only(value, ['formId', 'runId', 'fieldErrors', 'formError']);
20
+ if (value.formId !== request.formId || value.runId !== request.runId) fail();
21
+ only(value.fieldErrors, Object.keys(fields));
22
+ if (Object.values(value.fieldErrors).some(error => !validMessage(error)) || value.formError !== undefined && !validMessage(value.formError)) fail();
23
+ return value;
24
+ }
25
+ function assertRequest(request, formId, fields, validators, validate) {
26
+ only(request, ['formId', 'runId', 'reason', 'values', 'fields', 'includeForm']);
27
+ if (request.formId !== formId || !Number.isSafeInteger(request.runId) || request.runId < 1
28
+ || !['blur', 'change', 'submit'].includes(request.reason) || typeof request.includeForm !== 'boolean'
29
+ || request.includeForm && !validate || !Array.isArray(request.fields) || new Set(request.fields).size !== request.fields.length
30
+ || request.fields.some(name => !Object.hasOwn(validators, name))) fail();
31
+ only(request.values, Object.keys(fields));
32
+ if (Object.keys(request.values).length !== Object.keys(fields).length) fail();
33
+ for (const [name, field] of Object.entries(fields)) {
34
+ const value = request.values[name];
35
+ if (field.type === 'boolean') { if (typeof value !== 'boolean') fail(); }
36
+ else if (field.type === 'record') { if (value !== null) assertRecordRef(value, recordObjectsForField(field)); }
37
+ else if (field.type === 'number') { if (value !== null && (typeof value !== 'number' || !Number.isFinite(value))) fail(); }
38
+ else if (['plainDate', 'dateTime'].includes(field.type)) { if (value !== null && !(field.type === 'plainDate' ? isPlainDate(value) : isDateTime(value))) fail(); }
39
+ else if (typeof value !== 'string' || value.length > 10000) fail();
40
+ }
41
+ }
42
+ export function createValidationCallback(formId, fields, validators, validate) {
43
+ let busy = false;
44
+ return async input => {
45
+ assertRequest(input, formId, fields, validators, validate);
46
+ if (busy) fail();
47
+ busy = true;
48
+ const request = freezeFormState(structuredClone(input));
49
+ let active = true, timer, position = 0;
50
+ const fieldErrors = {};
51
+ const runField = async () => {
52
+ while (active && position < request.fields.length) {
53
+ const name = request.fields[position++], value = request.values[name];
54
+ if (!fields[name].required && (value === '' || value === null)) continue;
55
+ for (const validator of validators[name]) {
56
+ if (!active) return;
57
+ const error = await validator(value);
58
+ if (!active) return;
59
+ if (error !== undefined) { if (!validMessage(error)) fail(); fieldErrors[name] = error; break; }
60
+ }
61
+ }
62
+ };
63
+ try {
64
+ return await Promise.race([
65
+ (async () => {
66
+ await Promise.all([runField(), runField()]);
67
+ if (!active) return;
68
+ const result = request.includeForm ? await validate(request.values) : undefined;
69
+ if (!active) return;
70
+ if (result !== undefined) { only(result, ['fieldErrors', 'formError']); }
71
+ // 字段 custom 错误优先于跨字段错误。
72
+ const response = { formId, runId: request.runId, fieldErrors: { ...result?.fieldErrors, ...fieldErrors }, ...(result?.formError === undefined ? {} : { formError: result.formError }) };
73
+ if (result?.fieldErrors !== undefined) {
74
+ only(result.fieldErrors, Object.keys(fields));
75
+ if (Object.values(result.fieldErrors).some(error => !validMessage(error))) fail();
76
+ }
77
+ return assertValidationResponse(response, request, fields);
78
+ })(),
79
+ new Promise((_, reject) => { timer = setTimeout(() => { active = false; reject(new Error('runtime_form_validation_timeout')); }, VALIDATION_TIMEOUT_MS); }),
80
+ ]);
81
+ } finally { active = false; busy = false; clearTimeout(timer); }
82
+ };
83
+ }
@@ -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
+ }
@@ -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>;
@@ -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") {
@@ -1,3 +1,4 @@
1
+ import { componentTypes } from '../ui/components.generated.js';
1
2
  import { createRequire } from "node:module";
2
3
  import { createHash } from "node:crypto";
3
4
  import { mkdir, rm, writeFile } from "node:fs/promises";
@@ -245,6 +246,24 @@ function createSdkBuildPlugin(projectDirectory, sources, target) {
245
246
  return {
246
247
  name: "zhike-app-sdk-runtime",
247
248
  setup(buildApi) {
249
+ if (target === "remote-client") {
250
+ // 新构建固化 authoring 默认值;Guest 仍为不可变旧 Bundle 提供兼容模块。
251
+ buildApi.onResolve({ filter: /^@77-zhike\/app-sdk\/ui$/ }, args => args.namespace === "zhike-ui-authoring"
252
+ ? { path: args.path, external: true }
253
+ : { path: args.path, namespace: "zhike-ui-authoring" });
254
+ buildApi.onLoad({ filter: /.*/, namespace: "zhike-ui-authoring" }, () => ({ loader: "js", contents: `
255
+ import * as ui from "@77-zhike/app-sdk/ui";
256
+ import { createElement } from "react";
257
+ export const TextBlock = props => createElement(ui.TextBlock, { ...props, align: props.align ?? "left" });
258
+ export const Badge = props => {
259
+ if (props.tone !== undefined) throw new Error("Badge.tone 已移除,请使用 StatusBadge");
260
+ return createElement(ui.Badge, props);
261
+ };
262
+ export const TableCell = Object.defineProperties(props => createElement(ui.TableCell, props), { Avatar: { get: () => ui.Avatar }, Badge: { value: Badge } });
263
+ export const Table = Object.defineProperties(props => createElement(ui.Table, props), { Header: { get: () => ui.Table.Header }, HeaderCell: { get: () => ui.Table.HeaderCell }, Body: { get: () => ui.Table.Body }, Row: { get: () => ui.Table.Row }, Cell: { value: TableCell } });
264
+ export const { ${[...componentTypes.filter(type => !["TextBlock", "Badge", "Table", "TableCell"].includes(type)), "Typography", "Widget"].join(", ")} } = ui;
265
+ ` }));
266
+ }
248
267
  if (target !== "remote-client") buildApi.onResolve({ filter: /^@77-zhike\/app-sdk\/(client|server)$/ }, (args) => ({
249
268
  external: target === "server" && args.path.endsWith("/server"),
250
269
  path: args.path,
@@ -254,7 +273,10 @@ function createSdkBuildPlugin(projectDirectory, sources, target) {
254
273
  }));
255
274
  buildApi.onLoad({ filter: /.*/, namespace: "zhike-sdk-runtime" }, (args) => {
256
275
  if (args.path.endsWith("/client")) {
257
- return { contents: "export const defineFrontendExtension = (definition) => definition;", loader: "js" };
276
+ return {
277
+ contents: "export const defineFrontendExtension = (definition) => definition; export const definePage = (definition) => ({ ...definition, type: 'page' });",
278
+ loader: "js",
279
+ };
258
280
  }
259
281
  return { contents: "export const defineLifecycleEvent = (definition) => definition; export const defineServerFunction = (handler) => handler; export const defineWebhookHandler = (definition) => definition; export const createWebhookHandler = async () => { throw new Error('webhook_handler_runtime_unavailable'); }; export const listWebhookHandlers = async () => { throw new Error('webhook_handler_runtime_unavailable'); }; export const deleteWebhookHandler = async () => { throw new Error('webhook_handler_runtime_unavailable'); };", loader: "js" };
260
282
  });
@@ -293,7 +315,7 @@ function createManifest({ clientBytes, serverBytes, sources }) {
293
315
  sdkAbiVersion: "2",
294
316
  toolchainVersion: TOOLING_VERSION,
295
317
  ...(sources.frontendExtensions.length > 0
296
- ? { clientRuntime: { runtimeVersion: "remote-react.v2" } } : {}),
318
+ ? { clientRuntime: { runtimeVersion: "remote-react.v3" } } : {}),
297
319
  artifacts: {
298
320
  ...(clientBytes
299
321
  ? {
@@ -316,7 +338,7 @@ function createManifest({ clientBytes, serverBytes, sources }) {
316
338
  }
317
339
  : {}),
318
340
  },
319
- frontendExtensions: sources.frontendExtensions.map(({ key, type, entry, label, icon, objects }) => ({
341
+ frontendExtensions: sources.frontendExtensions.map(({ key, type, entry, label, icon, objects, name, order, variant, showInNavigation }) => ({
320
342
  artifact: "client",
321
343
  entry,
322
344
  key,
@@ -324,6 +346,10 @@ function createManifest({ clientBytes, serverBytes, sources }) {
324
346
  ...(label === undefined ? {} : { label }),
325
347
  ...(icon === undefined ? {} : { icon }),
326
348
  ...(objects === undefined ? {} : { objects }),
349
+ ...(name === undefined ? {} : { name }),
350
+ ...(order === undefined ? {} : { order }),
351
+ ...(variant === undefined ? {} : { variant }),
352
+ ...(showInNavigation === undefined ? {} : { showInNavigation }),
327
353
  })),
328
354
  lifecycleEvents: sources.lifecycleEvents.map(({ event, key }) => ({
329
355
  artifact: "server",