@77-zhike/app-sdk 0.2.0-d7b1.6 → 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.
package/README.md CHANGED
@@ -25,3 +25,195 @@ zhike logs --workspace <workspace-id> --since 1d --level error --keyword timeout
25
25
  - 服务端需要部署支持 `/api/developer/cli/apps/{app_id}/logs` 的 Developer Server;旧服务端不会自动回退到 Web 凭据或实时订阅。
26
26
 
27
27
  `zhike dev` 的实时订阅仍是独立用途,七天断线补读窗口不代表历史查询可超过三天。
28
+
29
+ ## Object Action 与 Bulk Record Action
30
+
31
+ 对象列表上的两类 Action 仍使用 `src/app/extensions/<key>/extension.tsx`:
32
+
33
+ ```tsx
34
+ import { defineFrontendExtension, showToast } from '@77-zhike/app-sdk/client'
35
+
36
+ export default defineFrontendExtension({
37
+ key: 'sync-selected-customers',
38
+ type: 'bulk-record-action',
39
+ label: '同步选中客户',
40
+ objects: ['accounts'],
41
+ async onTrigger({ object, recordIds }) {
42
+ await syncCustomers({ object, recordIds: [...recordIds] })
43
+ await showToast({ message: `已提交 ${recordIds.length} 条客户`, tone: 'success' })
44
+ },
45
+ })
46
+ ```
47
+
48
+ - `object-action` 显示在列表右上角对象操作菜单,只接收 `{ object }`。
49
+ - `bulk-record-action` 只在产品列表允许当前页显式多选时出现,接收 `{ object, recordIds }`;`recordIds` 为点击瞬间按当前页顺序冻结的 1~100 个公开 ID。
50
+ - `objects` 只允许 `accounts`、`contacts`、`opportunities` 和公开的 `custom_*` slug。任务、跟进记录、笔记不是 SDK Record Surface,不能用于 `record-action`、`object-action` 或 `bulk-record-action`。SDK 不暴露查询、权限、revision、内部 definition ID、系统 mutation 或产品 Action slot。
51
+ - Web/H5 使用同一产品目录和执行合同,但分别渲染菜单、Dialog/Sheet 与选择交互。没有可执行 Bulk Action 时不会出现空选择入口。
52
+ - Action 发现不会启动 Worker;点击后才校验当前 Installation 并 Lazy 执行。Action 本期不能调用 `navigate()`。
53
+ - Destination 的可导航对象与 Record 扩展白名单是两个独立合同;某类产品对象可以被打开,不代表 App 可以向它注册 Record Action。
54
+
55
+ ## Record Widget、Record Section 与 Record Tab
56
+
57
+ 记录页面组件继续使用 `src/app/extensions/<key>/extension.tsx`。Widget 由管理员加入卡片区;Record Section 进入统一 Section 目录,当前列表式设置默认将新 placement 加入 Overview;Tab 安装后进入产品 Tab 目录。三者都只接收平台签发的公开记录身份:
58
+
59
+ ```tsx
60
+ import {
61
+ defineFrontendExtension,
62
+ type RecordComponentProps,
63
+ } from '@77-zhike/app-sdk/client'
64
+ import { Badge, Widget } from '@77-zhike/app-sdk/ui'
65
+
66
+ function HealthWidget({ object, recordId }: RecordComponentProps) {
67
+ return (
68
+ <Widget.TextWidget onTrigger={() => openHealthDetails({ object, recordId })}>
69
+ <Widget.Title>客户健康度</Widget.Title>
70
+ <Widget.Text.Primary>92</Widget.Text.Primary>
71
+ <Widget.Text.Secondary>{`记录:${recordId}`}</Widget.Text.Secondary>
72
+ <Widget.Decoration>
73
+ <Badge tone="success">正常</Badge>
74
+ </Widget.Decoration>
75
+ </Widget.TextWidget>
76
+ )
77
+ }
78
+
79
+ export default defineFrontendExtension({
80
+ key: 'customer-health',
81
+ type: 'record-widget',
82
+ label: '客户健康度',
83
+ objects: ['accounts'],
84
+ Widget: HealthWidget,
85
+ })
86
+ ```
87
+
88
+ Record Section 使用同一声明形态,把 `type` 改为 `record-section` 并以 `Section` 提供组件;它按 final plan 在 placement 指定的 Section 区域渲染。`recordDetails.supportingSections` 与 `overview.sections` 只是位置,共用同一 capability 和 View。Record Tab 则使用 `record-tab` 和 `Tab`。`objects` 省略时适用于客户、联系人、商机和全部自定义对象;具体 `custom_*` 只匹配同名公开对象。任务、跟进记录和笔记不是 SDK Record Surface。
89
+
90
+ ```tsx
91
+ import {
92
+ defineFrontendExtension,
93
+ type RecordComponentProps,
94
+ } from '@77-zhike/app-sdk/client'
95
+ import { Button, Stack, TextBlock } from '@77-zhike/app-sdk/ui'
96
+
97
+ function BusinessSummary({ object, recordId }: RecordComponentProps) {
98
+ return (
99
+ <Stack gap="medium">
100
+ <TextBlock>当前记录:{`${object} / ${recordId}`}</TextBlock>
101
+ <Button onClick={() => refreshSummary({ object, recordId })}>刷新摘要</Button>
102
+ </Stack>
103
+ )
104
+ }
105
+
106
+ export default defineFrontendExtension({
107
+ key: 'business-summary',
108
+ type: 'record-section',
109
+ label: '经营摘要',
110
+ objects: ['accounts', 'opportunities'],
111
+ Section: BusinessSummary,
112
+ })
113
+ ```
114
+
115
+ `record-widget` 表示 Overview 顶部卡片。唯一根节点必须是 `Widget.TextWidget`,首次数据尚未就绪时可以返回 `Widget.Loading`。`Widget.Title`、`Widget.Text.Primary`、`Widget.Text.Secondary` 与可选的 `Widget.Decoration` 只描述卡片语义;其中 Decoration 只接受 `Badge` 或 `StatusBadge`。Web/H5 Host 使用各自 `components/ui` 实现外框、间距、字体、可信归属与 loading/error,App 不要在根节点再次套 `Card` 或自行模拟卡片样式。产品原生重点字段也复用同一套 Overview Widget 原子组件,因此 App 与产品卡片在同一端保持一致。
116
+
117
+ `Widget.TextWidget.onTrigger` 用于整卡点击,Host 会提供 pending 单飞与失败反馈;发现和渲染本身不会触发该回调。未提供 `Widget.Title` 时,Host 使用 capability label 作为受信回退标题。全宽内容必须使用 `record-section`,不能复用 `record-widget`。
118
+
119
+ - 产品只保存稳定 capability placement,不保存 App 版本或 Worker 身份;升级、卸载后重装仍可恢复原布局。Widget、Section、Tab 分别进入固定共享贡献点,不为每个组件创建新贡献点。
120
+ - Web/H5 使用同一份布局和 `{ object, recordId }`,但分别渲染适合当前宿主的 UI。H5 不支持的 Forms/`LookupCell` 只让当前 Widget/Section/Tab 进入 `unsupported`,不会拖垮详情或同 App 的其他组件。
121
+ - Widget/Section/Tab 标签和设置候选的发现不启动 Worker;可见 Widget/Section 或当前选中 Tab 才挂载 App View。Section 挂载后可自行初始化查询,Host 不提供数据预取合同。Server Function 仍必须由用户在已挂载组件中显式触发。
122
+ - App 不能声明 capability ID、布局位置、排序 tier、产品 route、`viewName`、Workspace/Installation 身份或 Native availability metadata。
123
+ - 当前 Record Page 三类组件不开放命令式 `navigate()`;详情 Tab 的 URL 与刷新恢复由产品 Host 统一管理。
124
+
125
+ ## Workspace Page
126
+
127
+ Page 源码放在 `src/app/pages/<slug>/page.tsx`。一级目录名就是稳定 slug;Page 只声明名称、排序、布局和纯 React 组件,不接收 Router、Workspace、鉴权 token 或宿主 props。
128
+
129
+ ```tsx
130
+ import {
131
+ definePage,
132
+ Destinations,
133
+ } from '@77-zhike/app-sdk/client'
134
+ import {
135
+ Card,
136
+ Link,
137
+ Stack,
138
+ TextBlock,
139
+ } from '@77-zhike/app-sdk/ui'
140
+
141
+ function QueuePage() {
142
+ return (
143
+ <Stack gap="large">
144
+ <Card title="任务队列">
145
+ <TextBlock>当前没有待处理任务。</TextBlock>
146
+ </Card>
147
+ <Link destination={Destinations.appPage('overview')}>
148
+ 返回总览
149
+ </Link>
150
+ </Stack>
151
+ )
152
+ }
153
+
154
+ export default definePage({
155
+ name: '任务队列',
156
+ order: 20,
157
+ variant: 'centered',
158
+ Page: QueuePage,
159
+ })
160
+ ```
161
+
162
+ 同一 App 内,有 `order` 的 Page 排在前面并按 `order + slug` 稳定排序;未声明 `order` 的 Page 排在后面并按 slug 排序。`name` trim 后长度为 1~80 个 Unicode code points。`variant: 'full' | 'centered'` 只表达布局语义,Desktop 与 H5 分别决定具体页面壳。
163
+
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 会整体显示“不支持”,不会静默丢节点或部分提交。
165
+
166
+ 导航只提交结构化 Destination:
167
+
168
+ - `Destinations.appPage(slug)` 打开当前 App 的另一个 Page。
169
+ - `Destinations.record({ object, recordId })` 支持 `accounts`、`contacts`、`opportunities`、`tasks`、`activities` 和当前 Workspace 可见的 `custom_*` 公开 API slug。
170
+ - `Link` 保留普通链接、复制地址和新标签行为;普通点击仍会在跳转前重验当前 Page 身份。
171
+ - `navigate()` 当前只允许从已挂载的 Page 调用,三类 Action 都不具备该能力。
172
+
173
+ 目录展示不会启动 Worker。首次打开 Page 时,宿主才会下载并校验 Manifest/Bundle、启动 Installation Worker;因此“Apps 菜单可见”不等于代码已经验证可运行。页面数据读写仍应通过受控 Server Function 完成。
174
+
175
+ ## 当前 Workspace 用户
176
+
177
+ Client 与用户触发的 Server Function 都可以同步读取当前 Workspace 成员:
178
+
179
+ ```tsx
180
+ import { getCurrentUser } from '@77-zhike/app-sdk/client'
181
+
182
+ const currentUser = getCurrentUser()
183
+ // currentUser.workspaceMemberId 是 Workspace 成员 ID,不是平台全局 User ID。
184
+ ```
185
+
186
+ ```ts
187
+ import { getCurrentUser } from '@77-zhike/app-sdk/server'
188
+
189
+ export default async function readForCurrentMember() {
190
+ const currentUser = getCurrentUser()
191
+ return { workspaceMemberId: currentUser.workspaceMemberId }
192
+ }
193
+ ```
194
+
195
+ 返回值只包含 `workspaceMemberId`、`displayName`、`primaryEmailAddress`、`avatarUrl`,且为只读快照。它不包含 Token、角色、权限集合或平台全局 User ID;App 仍须通过 Server Function 与公开 API 完成数据授权,不能用该快照自行推断权限。Lifecycle、Webhook 等无人触发入口没有当前用户,调用时抛出 `current_user_unavailable`;不得回退到管理员、安装者或 Connection 所有者。
196
+
197
+ ## 基础UI组件扩展
198
+
199
+ 新增Avatar、DescriptionList、Divider、Json、ExternalLink、StatusBadge、TextBlock与Typography,补齐Banner动作、Badge颜色、Card呈现、Grid容器模式、EmptyState操作编排和Table.HeaderCell。原flat exports与默认调用保留,复合写法使用同一节点。
200
+
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准入合同。
202
+
203
+ 新增组件的日常验证:修改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
+
205
+ 仓库内还提供无业务数据库的独立组件预览,命令和证据边界见`integration-app-runtime/verification/fixtures/ui-parity/README.md`。该预览不替代真实安装、权限与Server Function验证。
206
+
207
+ ## 表单组件
208
+
209
+ Desktop 与 H5 均支持 17 个可视 Forms 节点,以及只读 `WithState`。文本、多行、数字、日期、时间、选择、分组等输入复用各端基础 UI;Host 继续持有 RHF 状态,组件选择不会自动保存。数字空值为 null,日期为 YYYY-MM-DD,日期时间提交为带时区 ISO;旧 string/boolean/enum 语义保留。
210
+
211
+ `RecordCombobox` 默认只需在 schema 声明单个 `objectSlug`(或 `Forms.record(objectSlug)`),由 Host 使用当前安装的 Records Read 权限读取公开记录,保存值是 `{ objectSlug, recordId }`。空词沿用 Query 分页,非空词使用最多 25 条、无分页的 Record Search,已选值独立 Get。自定义数据源才提供 `RecordOptionsProvider.search/getOption` 并可自行分页;业务有效性及显式保存仍由消费方负责。`WithState` 仅订阅指定的 values/errors/submitting,不提供状态修改或程序化提交。
212
+
213
+ 可复制的非 CRM 示例与真实 tarball/Worker/SF/Settings/PostgreSQL 验证入口见 `integration-app-runtime/verification/fixtures/forms-parity/README.md`。当前尚未发布,所有新增 Forms 能力并入 remote-react.v3,ABI=2 不变;沿用 v1/v2 合同校验。SDK 与双端 Host 应整体交付,不引入新的版本分叉。
214
+
215
+ ## Dialog
216
+
217
+ 通过 `showDialog({ title, Dialog })` 打开临时界面;内容组件接收 `hideDialog()`。Desktop 复用 Dialog,H5 复用 Sheet,继续组合已有 UI/Form。当前不提供 DialogList、嵌套 Dialog 或固定 Footer,按钮直接放在正文中。
218
+
219
+ 从 Page/Settings 事件或 Record Action 调用。普通关闭完成 Promise;身份失效或打开失败拒绝。Action 正常返回后已受理 Dialog 可继续使用;保存必须由 App 显式调用 Server Function。Dialog 内不提供 Page 导航身份。详见开发者文档 `app-sdk/dialogs/show-dialog`。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@77-zhike/app-sdk",
3
- "version": "0.2.0-d7b1.6",
3
+ "version": "0.2.0-d7b1.7",
4
4
  "description": "\u667a\u5ba2 Integration App \u4e00\u671f SDK \u4e0e\u672c\u5730 Toolchain",
5
5
  "type": "module",
6
6
  "engines": {
@@ -157,6 +157,7 @@ export async function scaffoldProject({ targetDirectory, binding }) {
157
157
  {
158
158
  compilerOptions: {
159
159
  lib: ["ES2022", "DOM"],
160
+ jsx: "react-jsx",
160
161
  module: "ESNext",
161
162
  moduleResolution: "Bundler",
162
163
  noEmit: true,
@@ -0,0 +1,25 @@
1
+ /** 有界字符串业务参数;不包含宿主身份,不授予记录访问权。 */
2
+ export type PageParams = Readonly<Record<string, string>>;
3
+
4
+ export interface AppPageDestination {
5
+ readonly kind: 'app-page';
6
+ /** 当前 App 内的 Page slug。 */
7
+ readonly pageSlug: string;
8
+ readonly params?: PageParams;
9
+ }
10
+
11
+ export interface RecordDestination {
12
+ readonly kind: 'record';
13
+ /** 标准对象复数 slug,或当前 Workspace 可见的 custom_* 公开 API slug。 */
14
+ readonly object: string;
15
+ readonly recordId: string;
16
+ }
17
+
18
+ export type AppDestination = AppPageDestination | RecordDestination;
19
+
20
+ export function assertAppDestination(value: unknown): Readonly<AppDestination>;
21
+ export function assertPageParams(value: unknown): PageParams;
22
+ export const Destinations: Readonly<{
23
+ appPage(pageSlug: string, options?: { params?: PageParams }): Readonly<AppPageDestination>;
24
+ record(input: { object: string; recordId: string }): Readonly<RecordDestination>;
25
+ }>;
@@ -0,0 +1,70 @@
1
+ const PAGE_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
2
+ const OBJECT_SLUG = /^[a-z][a-z0-9_]{0,99}$/;
3
+ const STANDARD_RECORD_OBJECTS = new Set([
4
+ 'accounts',
5
+ 'contacts',
6
+ 'opportunities',
7
+ 'tasks',
8
+ 'activities',
9
+ ]);
10
+
11
+ const fail = () => { throw new TypeError('runtime_destination_invalid'); };
12
+ const only = (value, keys) => {
13
+ if (!value || typeof value !== 'object' || Array.isArray(value)
14
+ || Object.keys(value).some(key => !keys.includes(key))) fail();
15
+ };
16
+
17
+ /** 参数是地址中的不可信业务输入,不承载宿主身份或授权。 */
18
+ export function assertPageParams(value) {
19
+ if (!value || typeof value !== 'object' || Array.isArray(value)
20
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) fail();
21
+ const keys = Object.keys(value).sort();
22
+ if (keys.length > 16) fail();
23
+ const entries = keys.map(key => {
24
+ const item = value[key];
25
+ if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key)
26
+ || ['__proto__', 'prototype', 'constructor'].includes(key)
27
+ || typeof item !== 'string' || [...item].length > 512
28
+ || /[\p{Cc}\p{Cs}]/u.test(item)) fail();
29
+ return [key, item];
30
+ });
31
+ const params = Object.fromEntries(entries);
32
+ if (new TextEncoder().encode(JSON.stringify(params)).byteLength > 2048) fail();
33
+ return Object.freeze(params);
34
+ }
35
+
36
+ export function assertAppDestination(value) {
37
+ only(value, value?.kind === 'app-page' ? ['kind', 'pageSlug', 'params'] : value?.kind === 'record'
38
+ ? ['kind', 'object', 'recordId'] : []);
39
+ if (value.kind === 'app-page') {
40
+ if (typeof value.pageSlug !== 'string' || value.pageSlug.length > 64 || !PAGE_SLUG.test(value.pageSlug)) fail();
41
+ const params = value.params === undefined ? undefined : assertPageParams(value.params);
42
+ return Object.freeze({ kind: 'app-page', pageSlug: value.pageSlug,
43
+ ...(params && Object.keys(params).length ? { params } : {}),
44
+ });
45
+ }
46
+ if (value.kind === 'record') {
47
+ const custom = typeof value.object === 'string' && value.object.startsWith('custom_');
48
+ if (
49
+ typeof value.object !== 'string'
50
+ || !OBJECT_SLUG.test(value.object)
51
+ || (!STANDARD_RECORD_OBJECTS.has(value.object) && !custom)
52
+ || typeof value.recordId !== 'string'
53
+ || value.recordId.length < 1
54
+ || value.recordId.length > 160
55
+ ) fail();
56
+ return Object.freeze({ kind: 'record', object: value.object, recordId: value.recordId });
57
+ }
58
+ fail();
59
+ }
60
+
61
+ export const Destinations = Object.freeze({
62
+ appPage(pageSlug, options) {
63
+ if (options !== undefined) only(options, ['params']);
64
+ return assertAppDestination({ kind: 'app-page', pageSlug, ...(options?.params === undefined ? {} : { params: options.params }) });
65
+ },
66
+ record(input) {
67
+ only(input, ['object', 'recordId']);
68
+ return assertAppDestination({ kind: 'record', object: input.object, recordId: input.recordId });
69
+ },
70
+ });
@@ -0,0 +1,9 @@
1
+ /** SDK 与 Host 重复验证同一有界 HTTPS 合同,不接受浏览器自动纠正的危险写法。 */
2
+ export function assertExternalUrl(value) {
3
+ if (typeof value !== 'string' || !value.length || value.length > 2048 || value !== value.trim()
4
+ || /[\p{Cc}\\]/u.test(value) || !/^https:\/\//i.test(value)) throw new Error('runtime_external_url_invalid');
5
+ let url;
6
+ try { url = new URL(value); } catch { throw new Error('runtime_external_url_invalid'); }
7
+ if (url.protocol !== 'https:' || !url.hostname || url.username || url.password) throw new Error('runtime_external_url_invalid');
8
+ return url.href;
9
+ }
@@ -1,6 +1,13 @@
1
1
  import type { ComponentType } from "react";
2
2
  import type { AppIconName } from "../ui/index.js";
3
3
 
4
+ /** SDK Record 扩展明确开放的产品对象;任务、跟进记录和笔记不属于此合同。 */
5
+ export type RecordExtensionObject =
6
+ | "accounts"
7
+ | "contacts"
8
+ | "opportunities"
9
+ | `custom_${string}`;
10
+
4
11
  /** Settings 只声明组件,表单状态不代表持久化配置。 */
5
12
  export interface SettingsExtensionDefinition {
6
13
  key: string;
@@ -14,7 +21,7 @@ export interface RecordActionContext {
14
21
  /** 当前记录的公开 ID。 */
15
22
  recordId: string;
16
23
  /** 当前对象的公开 API slug。 */
17
- object: string;
24
+ object: RecordExtensionObject;
18
25
  }
19
26
 
20
27
  /** 在通用记录详情菜单中贡献一个操作。 */
@@ -27,15 +34,145 @@ export interface RecordActionDefinition {
27
34
  /** 统一 SDK 图标目录中的名称。 */
28
35
  icon?: AppIconName;
29
36
  /** 省略时匹配所有能提供公开身份的记录对象。 */
30
- objects?: string | readonly string[];
37
+ objects?: RecordExtensionObject | readonly RecordExtensionObject[];
31
38
  /** 用户触发操作时执行;成功无需返回值。 */
32
39
  onTrigger(context: RecordActionContext): void | Promise<void>;
33
40
  component?: never;
34
41
  handler?: never;
35
42
  }
43
+
44
+ /** 当前对象列表提供的公开对象身份;不包含查询、权限或原生 mutation 身份。 */
45
+ export interface ObjectActionContext {
46
+ /** 当前对象的公开 API slug。 */
47
+ object: RecordExtensionObject;
48
+ }
49
+
50
+ /** 在对象列表右上角菜单中贡献一个操作。 */
51
+ export interface ObjectActionDefinition {
52
+ /** App 内唯一 lowercase kebab-case key。 */
53
+ key: string;
54
+ type: "object-action";
55
+ /** 菜单中展示的操作名称。 */
56
+ label: string;
57
+ /** 统一 SDK 图标目录中的名称。 */
58
+ icon?: AppIconName;
59
+ /** 省略时匹配所有能提供公开身份的对象列表。 */
60
+ objects?: RecordExtensionObject | readonly RecordExtensionObject[];
61
+ /** 用户触发操作时执行;成功无需返回值。 */
62
+ onTrigger(context: ObjectActionContext): void | Promise<void>;
63
+ component?: never;
64
+ handler?: never;
65
+ }
66
+
67
+ /** 当前显式选择快照;只暴露公开对象和 1..100 个 Record ID。 */
68
+ export interface BulkRecordActionContext {
69
+ /** 当前对象的公开 API slug。 */
70
+ object: RecordExtensionObject;
71
+ /** 点击瞬间冻结的公开 Record ID,保持当前页可见顺序。 */
72
+ recordIds: readonly string[];
73
+ }
74
+
75
+ /** 在对象列表选择模式中贡献一个批量操作。 */
76
+ export interface BulkRecordActionDefinition {
77
+ /** App 内唯一 lowercase kebab-case key。 */
78
+ key: string;
79
+ type: "bulk-record-action";
80
+ /** 菜单中展示的操作名称。 */
81
+ label: string;
82
+ /** 统一 SDK 图标目录中的名称。 */
83
+ icon?: AppIconName;
84
+ /** 省略时匹配所有支持显式多选的公开对象列表。 */
85
+ objects?: RecordExtensionObject | readonly RecordExtensionObject[];
86
+ /** 用户触发操作时执行;成功无需返回值。 */
87
+ onTrigger(context: BulkRecordActionContext): void | Promise<void>;
88
+ component?: never;
89
+ handler?: never;
90
+ }
91
+
92
+ /** Record Page 组件获得的公开记录身份;不能作为服务端授权凭据。 */
93
+ export interface RecordComponentProps {
94
+ /** 当前对象的公开 API slug。 */
95
+ readonly object: RecordExtensionObject;
96
+ /** 当前记录的公开 ID。 */
97
+ readonly recordId: string;
98
+ }
99
+
100
+ /** 在记录概览的重点信息卡片容器中贡献一个小卡片 Widget。 */
101
+ export interface RecordWidgetDefinition {
102
+ /** App 内唯一 lowercase kebab-case key。 */
103
+ key: string;
104
+ type: "record-widget";
105
+ /** Widget 标题,trim 后最多 80 个 Unicode code points。 */
106
+ label: string;
107
+ /** 省略时匹配客户、联系人、商机和全部自定义对象。 */
108
+ objects?: readonly RecordExtensionObject[];
109
+ /** 纯 React Widget 正文,只接收公开记录身份;卡片外框由 Host 提供。 */
110
+ Widget: ComponentType<RecordComponentProps>;
111
+ component?: never;
112
+ handler?: never;
113
+ onTrigger?: never;
114
+ }
115
+
116
+ /** 为 Record Page 贡献一个可由布局放入 Section 区域的受管组件。 */
117
+ export interface RecordSectionDefinition {
118
+ /** App 内唯一 lowercase kebab-case key。 */
119
+ key: string;
120
+ type: "record-section";
121
+ /** Section 标题,trim 后最多 80 个 Unicode code points。 */
122
+ label: string;
123
+ /** 省略时匹配客户、联系人、商机和全部自定义对象。 */
124
+ objects?: readonly RecordExtensionObject[];
125
+ /** 纯 React Section 正文,只接收公开记录身份;标题与外框由 Host 提供。 */
126
+ Section: ComponentType<RecordComponentProps>;
127
+ component?: never;
128
+ handler?: never;
129
+ onTrigger?: never;
130
+ }
131
+
132
+ /** 在记录详情的产品 Tab 区域中贡献一个 Tab。 */
133
+ export interface RecordTabDefinition {
134
+ /** App 内唯一 lowercase kebab-case key。 */
135
+ key: string;
136
+ type: "record-tab";
137
+ /** Tab 标题,trim 后最多 80 个 Unicode code points。 */
138
+ label: string;
139
+ /** 省略时匹配客户、联系人、商机和全部自定义对象。 */
140
+ objects?: readonly RecordExtensionObject[];
141
+ /** 纯 React Tab component,只接收公开记录身份。 */
142
+ Tab: ComponentType<RecordComponentProps>;
143
+ component?: never;
144
+ handler?: never;
145
+ onTrigger?: never;
146
+ }
147
+
148
+ export interface PageProps {
149
+ /** 当前地址的业务输入,缺省为空对象;不代表记录访问权。 */
150
+ readonly params: import('./destinations.js').PageParams;
151
+ }
152
+
153
+ /** Workspace Page 不接收宿主路由或身份;导航只能通过 SDK 能力发起。 */
154
+ export interface PageDefinition {
155
+ /** Apps 菜单和 Page 标题使用的名称。 */
156
+ name: string;
157
+ /** 有值的 Page 优先按数值排序;缺失时按 slug 稳定排序。 */
158
+ order?: number;
159
+ /** Page 自身的产品布局语义,不选择 Dialog、Sheet 或 Sidebar。 */
160
+ variant: "full" | "centered";
161
+ /** 缺省可见;只影响 Apps 菜单,不改变页面授权或可访问性。 */
162
+ showInNavigation?: boolean;
163
+ /** 纯 React Page component,只接收有界业务参数。 */
164
+ Page: ComponentType<PageProps>;
165
+ }
166
+
167
+ export type DefinedPage = Readonly<PageDefinition & { type: "page" }>;
36
168
  export type FrontendExtensionDefinition =
37
169
  | SettingsExtensionDefinition
38
- | RecordActionDefinition;
170
+ | RecordActionDefinition
171
+ | ObjectActionDefinition
172
+ | BulkRecordActionDefinition
173
+ | RecordWidgetDefinition
174
+ | RecordSectionDefinition
175
+ | RecordTabDefinition;
39
176
 
40
177
  export function defineFrontendExtension(
41
178
  definition: SettingsExtensionDefinition,
@@ -43,15 +180,61 @@ export function defineFrontendExtension(
43
180
  export function defineFrontendExtension(
44
181
  definition: RecordActionDefinition,
45
182
  ): Readonly<RecordActionDefinition>;
183
+ export function defineFrontendExtension(
184
+ definition: ObjectActionDefinition,
185
+ ): Readonly<ObjectActionDefinition>;
186
+ export function defineFrontendExtension(
187
+ definition: BulkRecordActionDefinition,
188
+ ): Readonly<BulkRecordActionDefinition>;
189
+ export function defineFrontendExtension(
190
+ definition: RecordWidgetDefinition,
191
+ ): Readonly<RecordWidgetDefinition>;
192
+ export function defineFrontendExtension(
193
+ definition: RecordSectionDefinition,
194
+ ): Readonly<RecordSectionDefinition>;
195
+ export function defineFrontendExtension(
196
+ definition: RecordTabDefinition,
197
+ ): Readonly<RecordTabDefinition>;
198
+
199
+ /** 声明一个由约定目录提供 slug 的 Workspace Page。 */
200
+ export function definePage(definition: PageDefinition): DefinedPage;
46
201
 
47
202
  export type { AppIconName } from "../ui/index.js";
203
+ export { Destinations } from './destinations.js';
204
+ export type { AppDestination, AppPageDestination, RecordDestination, PageParams } from './destinations.js';
48
205
  export type { ToastOptions, ConfirmOptions } from './interactions.js';
49
206
  /** 受管结果提示;不接收 HTML,回调完成后可继续显示,Workspace 会话结束时清理。 */
50
207
  export function showToast(options: import('./interactions.js').ToastOptions): Promise<void>;
51
208
  /** 用户取消返回 false;所属页面关闭时拒绝,不能当作确认。 */
52
209
  export function confirm(options: import('./interactions.js').ConfirmOptions): Promise<boolean>;
210
+ /** Page view 可用;RecordAction invocation 仅可打开同 App Page。Dialog 不可直接导航。 */
211
+ export function navigate(destination: import('./destinations.js').AppDestination): Promise<void>;
212
+
213
+ export interface DialogProps {
214
+ /** 只关闭当前 Dialog;重复调用无效果,不代表保存或回滚。 */
215
+ hideDialog(): void;
216
+ }
217
+ export interface DialogOptions {
218
+ /** 宿主固定标题,去空白后 1~200 字符。 */
219
+ title: string;
220
+ Dialog: ComponentType<DialogProps>;
221
+ }
222
+ /** 从有效 Action/UI 回调打开;普通关闭完成,失效/失败拒绝,不支持嵌套。 */
223
+ export function showDialog(options: DialogOptions): Promise<void>;
53
224
  /**
54
225
  * 由 Web Host 在新页签打开绝对 HTTPS URL;不返回窗口或远端加载状态。
55
226
  * @platform Web
56
227
  */
57
228
  export function openExternalUrl(url: string): Promise<void>;
229
+
230
+ /** 当前用户在当前 Workspace 内的成员快照。 */
231
+ export interface CurrentUser {
232
+ /** Workspace 成员 ID;不是平台全局 User ID。 */
233
+ workspaceMemberId: string;
234
+ displayName: string;
235
+ primaryEmailAddress: string | null;
236
+ avatarUrl: string | null;
237
+ }
238
+
239
+ /** 同步读取当前前端 Workspace 会话的成员快照。 */
240
+ export function getCurrentUser(): Readonly<CurrentUser>;
@@ -1,23 +1,65 @@
1
1
  import { frontendPoint } from './points.generated.js';
2
+ export { Destinations } from './destinations.js';
2
3
  const FRONTEND_EXTENSION_MARKER = Symbol.for("zhike.frontend-extension.v1");
4
+ const PAGE_VARIANTS = new Set(["full", "centered"]);
3
5
 
4
6
  export function defineFrontendExtension(definition) {
5
7
  if (!definition || typeof definition !== "object" || Array.isArray(definition)) {
6
8
  throw new TypeError("Frontend Extension definition 必须是对象");
7
9
  }
8
10
  const point = frontendPoint(definition.type);
9
- if (!point) {
11
+ if (!point || definition.type === "page") {
10
12
  throw new TypeError("Frontend Extension type 不受支持");
11
13
  }
12
- const property = point.kind === "view" ? "component" : (point.callbackName || "handler");
13
- const forbidden = point.kind === "view" ? ["handler", "onTrigger"] : ["component", ...(property === "handler" ? ["onTrigger"] : ["handler"])];
14
+ const property = point.kind === "view" ? (point.componentName || "component") : (point.callbackName || "handler");
15
+ const forbidden = point.kind === "view"
16
+ ? ["component", "Widget", "Section", "Tab", "Page", "handler", "onTrigger"].filter(name => name !== property)
17
+ : ["component", "Widget", "Section", "Tab", "Page", ...(property === "handler" ? ["onTrigger"] : ["handler"])];
14
18
  if (typeof definition[property] !== "function" || forbidden.some(name => name in definition)) {
15
19
  throw new TypeError(`Frontend Extension 必须只提供 ${property} 函数`);
16
20
  }
17
21
  return Object.freeze({ ...definition, [FRONTEND_EXTENSION_MARKER]: true });
18
22
  }
19
23
 
24
+ export function definePage(definition) {
25
+ if (!definition || typeof definition !== "object" || Array.isArray(definition)) {
26
+ throw new TypeError("Page definition 必须是对象");
27
+ }
28
+ const keys = Object.keys(definition).sort();
29
+ const allowed = new Set(["Page", "name", "order", "variant", "showInNavigation"]);
30
+ if (keys.some(key => !allowed.has(key))) {
31
+ throw new TypeError("Page definition 包含不支持的字段");
32
+ }
33
+ const name = typeof definition.name === "string" ? definition.name.trim() : "";
34
+ if ([...name].length === 0 || [...name].length > 80 || /\p{Cc}/u.test(name)) {
35
+ throw new TypeError("Page name 必须是 1~80 个非控制字符");
36
+ }
37
+ if (!PAGE_VARIANTS.has(definition.variant)) {
38
+ throw new TypeError("Page variant 必须是 full 或 centered");
39
+ }
40
+ if (definition.order !== undefined && !Number.isSafeInteger(definition.order)) {
41
+ throw new TypeError("Page order 必须是安全整数");
42
+ }
43
+ if (typeof definition.Page !== "function") {
44
+ throw new TypeError("Page definition 必须提供 React Page component");
45
+ }
46
+ if (definition.showInNavigation !== undefined && typeof definition.showInNavigation !== 'boolean') {
47
+ throw new TypeError('Page showInNavigation 必须是布尔值');
48
+ }
49
+ return Object.freeze({
50
+ Page: definition.Page,
51
+ name,
52
+ ...(definition.order === undefined ? {} : { order: definition.order }),
53
+ type: "page",
54
+ variant: definition.variant,
55
+ ...(definition.showInNavigation === undefined ? {} : { showInNavigation: definition.showInNavigation }),
56
+ });
57
+ }
58
+
20
59
  // 受管 Guest 注入实际请求出口;脱离前端运行环境不能调用产品交互。
21
60
  export function showToast() { return Promise.reject(new Error('runtime_host_interaction_unavailable')); }
22
61
  export function confirm() { return Promise.reject(new Error('runtime_host_interaction_unavailable')); }
62
+ export function navigate() { return Promise.reject(new Error('runtime_host_interaction_unavailable')); }
63
+ export function showDialog() { return Promise.reject(new Error('runtime_host_interaction_unavailable')); }
23
64
  export function openExternalUrl() { return Promise.reject(new Error('runtime_host_interaction_unavailable')); }
65
+ export function getCurrentUser() { throw new Error('current_user_unavailable'); }
@@ -1,10 +1,14 @@
1
+ export interface OpenExternalOptions { href: string }
1
2
  export interface ToastOptions { message: string; tone?: 'info' | 'success' | 'error' }
2
3
  export interface ConfirmOptions { title: string; description?: string; confirmLabel?: string; cancelLabel?: string }
3
4
  export interface OpenExternalUrlOptions { url: string }
4
- export type HostInteraction = {operation:'showToast';input:ToastOptions} | {operation:'confirm';input:ConfirmOptions} | {operation:'openExternalUrl';input:OpenExternalUrlOptions};
5
- export function assertHostInteraction(operation: unknown, input: unknown): Readonly<ToastOptions | ConfirmOptions | OpenExternalUrlOptions>;
6
- export function createClientInteractions(invoke: (operation: string, input: ToastOptions | ConfirmOptions | OpenExternalUrlOptions) => Promise<unknown>): {
5
+ export interface NavigateOptions { destination: import('./destinations.js').AppDestination }
6
+ export type HostInteraction = {operation:'showToast';input:ToastOptions} | {operation:'confirm';input:ConfirmOptions} | {operation:'navigate';input:NavigateOptions} | {operation:'openExternal';input:OpenExternalOptions} | {operation:'openExternalUrl';input:OpenExternalUrlOptions};
7
+ export function assertHostInteraction(operation: unknown, input: unknown): Readonly<ToastOptions | ConfirmOptions | NavigateOptions | OpenExternalOptions | OpenExternalUrlOptions>;
8
+ export function createClientInteractions(invoke: (operation: string, input: ToastOptions | ConfirmOptions | NavigateOptions | OpenExternalOptions | OpenExternalUrlOptions) => Promise<unknown>): {
7
9
  showToast(input: ToastOptions): Promise<void>;
8
10
  confirm(input: ConfirmOptions): Promise<boolean>;
9
11
  openExternalUrl(url: string): Promise<void>;
12
+ openExternal(href: string): Promise<boolean>;
13
+ navigate(destination: import('./destinations.js').AppDestination): Promise<void>;
10
14
  };