Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s
850 lines
45 KiB
Plaintext
850 lines
45 KiB
Plaintext
---
|
||
title: 前端组件
|
||
description: 构建可在 Twenty 的 UI 中渲染并具备沙盒隔离的 React 组件。
|
||
icon: window-maximize
|
||
---
|
||
|
||
前端组件是直接在 Twenty 的 UI 内渲染的 React 组件。 它们在使用 Remote DOM 的**隔离 Web Worker**中运行——你的代码在沙盒化的、不透明来源的 iframe 中执行,但其 UI 仍然原生渲染在页面中,而不是被限制在该 iframe 内。
|
||
|
||
<Warning>
|
||
前台组件仍在开发中。 您的代码与部分DOM相反,而不是一个真正的浏览器页面,所以高级的使用可能失败,常常是静默。 请参阅[当前限制](#current-limitations)。
|
||
</Warning>
|
||
|
||
## 前端组件可用位置
|
||
|
||
在 Twenty 中,前端组件可以在三个位置进行渲染:
|
||
|
||
* **侧边栏** — 非无头的前端组件会在右侧侧边栏中打开。 当前端组件从命令菜单触发时,这是默认行为。
|
||
* **小部件(仪表盘和记录页面)** — 前端组件可以作为小部件嵌入到[页面布局](/l/zh/developers/extend/apps/layout/page-layouts)中。 在配置仪表盘或记录页面布局时,用户可以添加前端组件小部件。
|
||
* **应用设置** — 使用 [`defineSettingsFrontComponent()`](#custom-settings-component) 定义后,该前端组件将作为一个部分渲染在应用的 **Settings** 选项卡中,以替代默认的变量配置界面。
|
||
|
||
单独存在的前端组件无法从界面中访问 —— 你需要将它*呈现*出来。 实现这一点有三种方式:
|
||
|
||
* **将它与[命令菜单项](/l/zh/developers/extend/apps/layout/command-menu-items)配对** —— 将其注册到命令菜单(Cmd+K)中,并可选地将其设为固定快速操作。
|
||
* **将它作为小部件嵌入到[页面布局](/l/zh/developers/extend/apps/layout/page-layouts)中** —— 将其放置在记录详情页面或仪表盘上。
|
||
* **使用 [`defineSettingsFrontComponent()`](#custom-settings-component) 定义** — 将其渲染为应用 **Settings** 选项卡中的一个部分,以替代默认的变量配置界面。
|
||
|
||
## 基础示例
|
||
|
||
最快看到前端组件实际效果的方式是将它与[`defineCommandMenuItem`](/l/zh/developers/extend/apps/layout/command-menu-items)配对,这样它就会显示为页面右上角的快速操作按钮:
|
||
|
||
```tsx src/front-components/hello-world.tsx
|
||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||
|
||
const HelloWorld = () => {
|
||
return (
|
||
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
|
||
<h1>Hello from my app!</h1>
|
||
<p>This component renders inside Twenty.</p>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: '74c526eb-cb68-4cf7-b05c-0dd8c288d948',
|
||
name: 'hello-world',
|
||
description: 'A simple front component',
|
||
component: HelloWorld,
|
||
});
|
||
```
|
||
|
||
```ts src/command-menu-items/hello-world.command-menu-item.ts
|
||
import { defineCommandMenuItem } from 'twenty-sdk/define';
|
||
|
||
export default defineCommandMenuItem({
|
||
universalIdentifier: 'd4e5f6a7-b8c9-0123-defa-456789012345',
|
||
shortLabel: 'Hello',
|
||
label: 'Hello World',
|
||
isPinned: true,
|
||
availabilityType: 'GLOBAL',
|
||
frontComponentUniversalIdentifier: '74c526eb-cb68-4cf7-b05c-0dd8c288d948',
|
||
});
|
||
```
|
||
|
||
使用 `yarn twenty dev` 同步后(或单次运行 `yarn twenty apply`),快速操作会出现在页面右上角:
|
||
|
||
<div style={{textAlign: 'center'}}>
|
||
<img src="/images/docs/developers/extends/apps/quick-action.png" alt="右上角的快速操作按钮" />
|
||
</div>
|
||
|
||
点击它以内联方式渲染该组件。
|
||
|
||
## 配置字段
|
||
|
||
| 字段 | 必填 | 描述 |
|
||
| --------------------- | -- | ---------------------------- |
|
||
| `universalIdentifier` | 否 | 该组件的稳定唯一 ID |
|
||
| `component` | 是 | 一个 React 组件函数 |
|
||
| `name` | 否 | 显示名称 |
|
||
| `description` | 否 | 组件的功能描述 |
|
||
| `isHeadless` | 否 | 如果组件没有可见的 UI,则设为 `true`(见下文) |
|
||
|
||
## 在页面上放置前端组件
|
||
|
||
除了命令之外,你还可以在**页面布局**中将其添加为小部件,从而将前端组件直接嵌入记录页面。 详情请参见[页面布局](/l/zh/developers/extend/apps/layout/page-layouts)。
|
||
|
||
## 自定义设置组件
|
||
|
||
要在应用的 **Settings** 选项卡中,用你自己的组件替换自动生成的变量配置界面,请使用 `defineSettingsFrontComponent` 而不是 `defineFrontComponent` 来进行定义。 它使用相同的[配置字段](#configuration-fields)(除了 `isHeadless`,由于设置组件始终会渲染可见的 UI,因此不接受该字段),并另外将该组件标记为应用的设置 UI。
|
||
|
||
该组件会渲染为设置选项卡内的一个部分,而不是替换整个选项卡。 Twenty 的系统管理部分——自动升级、App URL 和连接——始终渲染在它上方,且无法被应用覆盖。
|
||
|
||
```tsx src/front-components/app-settings.tsx
|
||
import { defineSettingsFrontComponent } from 'twenty-sdk/define';
|
||
|
||
const AppSettings = () => {
|
||
return (
|
||
<div style={{ padding: '20px' }}>
|
||
<h2>My app settings</h2>
|
||
{/* render your own configuration UI here */}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default defineSettingsFrontComponent({
|
||
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||
name: 'app-settings',
|
||
description: "Custom UI for the app's Settings tab",
|
||
component: AppSettings,
|
||
});
|
||
```
|
||
|
||
每个应用仅允许有一个设置前端组件;声明多个会导致构建失败。 如果存在,应用的 **Settings** 选项卡会渲染此组件来替代默认的变量配置 UI。
|
||
|
||
## 无头与非无头
|
||
|
||
前端组件有两种由 `isHeadless` 选项控制的渲染模式:
|
||
|
||
**非无头(默认)** — 该组件会渲染可见的 UI。 从命令菜单触发时,它会在侧边栏中打开。 当 `isHeadless` 为 `false` 或被省略时,这是默认行为。
|
||
|
||
**无头 (`isHeadless: true`)** — 该组件会在后台以不可见的方式挂载。 它不会打开侧边栏。 无头组件旨在用于执行逻辑后自行卸载的操作——例如运行异步任务、导航到某个页面或显示确认模态框。 它们与下文介绍的 SDK Command 组件天然契合。
|
||
|
||
```tsx src/front-components/sync-tracker.tsx
|
||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||
import { useSelectedRecordIds, enqueueSnackbar } from 'twenty-sdk/front-component';
|
||
import { useEffect } from 'react';
|
||
|
||
const SyncTracker = () => {
|
||
const [recordId] = useSelectedRecordIds();
|
||
|
||
useEffect(() => {
|
||
enqueueSnackbar({ message: `Tracking record ${recordId}`, variant: 'info' });
|
||
}, [recordId]);
|
||
|
||
return null;
|
||
};
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: '...',
|
||
name: 'sync-tracker',
|
||
description: 'Tracks record views silently',
|
||
isHeadless: true,
|
||
component: SyncTracker,
|
||
});
|
||
```
|
||
|
||
由于该组件返回 `null`,Twenty 会跳过为其渲染容器——布局中不会出现空白区域。 该组件仍可访问所有 hooks 和宿主通信 API。
|
||
|
||
## SDK Command 组件
|
||
|
||
`twenty-sdk` 包提供了四个为无头前端组件设计的 Command 辅助组件。 每个组件都会在挂载时执行一个操作,通过显示 snackbar 通知来处理错误,并在完成后自动卸载该前端组件。
|
||
|
||
从 `twenty-sdk/front-component` 导入它们:
|
||
|
||
* **`Command`** — 通过 `execute` 属性运行异步回调。
|
||
* **`CommandLink`** — 导航到某个应用路径。 属性:`to`、`params`、`queryParams`、`options`。
|
||
* **`CommandModal`** — 打开一个确认模态框。 如果用户确认,则执行 `execute` 回调。 属性:`title`、`subtitle`、`execute`、`confirmButtonText`、`confirmButtonAccent`。
|
||
* **`CommandOpenSidePanelPage`** — 打开一个侧边栏页面。 Props 取决于 `page` —— 例如,`ViewRecord` 需要 `recordId` + `objectNameSingular`(以及一个可选的 `tab` id,用于在特定标签页中打开该记录),其他页面需要 `pageTitle` + `pageIcon`。
|
||
|
||
下面是一个完整示例:无头前端组件使用 `Command` 从命令菜单运行一个操作:
|
||
|
||
```tsx src/front-components/run-action.tsx
|
||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||
import { Command } from 'twenty-sdk/front-component';
|
||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||
|
||
const RunAction = () => {
|
||
const execute = async () => {
|
||
const client = new CoreApiClient();
|
||
|
||
await client.mutation({
|
||
createTask: {
|
||
__args: { data: { title: 'Created by my app' } },
|
||
id: true,
|
||
},
|
||
});
|
||
};
|
||
|
||
return <Command execute={execute} />;
|
||
};
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: 'e5f6a7b8-c9d0-1234-efab-345678901234',
|
||
name: 'run-action',
|
||
description: 'Creates a task from the command menu',
|
||
component: RunAction,
|
||
isHeadless: true,
|
||
});
|
||
```
|
||
|
||
```ts src/command-menu-items/run-action.command-menu-item.ts
|
||
import { defineCommandMenuItem } from 'twenty-sdk/define';
|
||
|
||
export default defineCommandMenuItem({
|
||
universalIdentifier: 'f6a7b8c9-d0e1-2345-fabc-456789012345',
|
||
label: 'Run my action',
|
||
frontComponentUniversalIdentifier: 'e5f6a7b8-c9d0-1234-efab-345678901234',
|
||
});
|
||
```
|
||
|
||
另一个示例:使用 `CommandModal` 在执行前请求确认:
|
||
|
||
```tsx src/front-components/delete-draft.tsx
|
||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||
import { CommandModal } from 'twenty-sdk/front-component';
|
||
|
||
const DeleteDraft = () => {
|
||
const execute = async () => {
|
||
// perform the deletion
|
||
};
|
||
|
||
return (
|
||
<CommandModal
|
||
title="Delete draft?"
|
||
subtitle="This action cannot be undone."
|
||
execute={execute}
|
||
confirmButtonText="Delete"
|
||
confirmButtonAccent="danger"
|
||
/>
|
||
);
|
||
};
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: 'a7b8c9d0-e1f2-3456-abcd-567890123456',
|
||
name: 'delete-draft',
|
||
description: 'Deletes a draft with confirmation',
|
||
component: DeleteDraft,
|
||
isHeadless: true,
|
||
});
|
||
```
|
||
|
||
以及一个使用 `CommandOpenSidePanelPage` 的示例,用于在特定标签页的侧边栏中打开当前记录。 `tab` 是页面布局的标签页 id(默认布局使用类似 `company-tab-emails` 或 `company-tab-timeline` 这样的 id;自定义布局使用标签页自身的 id)。 如果该 id 在记录的布局中不存在,则会改为打开默认标签页:
|
||
|
||
```tsx src/front-components/open-company-emails.tsx
|
||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||
import {
|
||
CommandOpenSidePanelPage,
|
||
SidePanelPages,
|
||
useSelectedRecordIds,
|
||
} from 'twenty-sdk/front-component';
|
||
|
||
const OpenCompanyEmails = () => {
|
||
const selectedRecordIds = useSelectedRecordIds();
|
||
const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
|
||
|
||
if (!recordId) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<CommandOpenSidePanelPage
|
||
page={SidePanelPages.ViewRecord}
|
||
recordId={recordId}
|
||
objectNameSingular="company"
|
||
tab="company-tab-emails"
|
||
resetNavigationStack={false}
|
||
/>
|
||
);
|
||
};
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: 'b8c9d0e1-f2a3-4567-bcde-678901234567',
|
||
name: 'open-company-emails',
|
||
description: 'Opens the current company on its Emails tab',
|
||
component: OpenCompanyEmails,
|
||
isHeadless: true,
|
||
});
|
||
```
|
||
|
||
## 调用逻辑函数
|
||
|
||
前端组件在浏览器端的 Web Worker 中运行,该 Web Worker 被沙盒化在不透明来源的 iframe 中,而[逻辑函数](/l/zh/developers/extend/apps/logic/logic-functions)在服务器端运行。 二者之间没有直接的进程内调用——前端组件通过 HTTP 访问逻辑函数。
|
||
|
||
使用 `httpRouteTriggerSettings` 声明的逻辑函数,可以通过其路由路径在 HTTP 上进行访问。 `RestApiClient` 会将以 `/s/` 开头的路径视为应用路由,将其解析到你的函数提供服务的 URL,并使用 `TWENTY_APP_ACCESS_TOKEN` 对其进行认证。
|
||
|
||
> **在 Twenty Cloud 上,HTTP 触发的逻辑函数通过每个工作区的专用域名提供服务**,域名为 `https://\<your-workspace-subdomain>.withtwenty.com\<path>`。 对于外部调用方,请从函数的 **HTTP trigger** 设置或应用的 **Settings** 选项卡中复制准确的 URL。
|
||
|
||
无头前端组件可以通过 `Command` 组件在挂载时执行调用,然后自动卸载:
|
||
|
||
```tsx src/front-components/sync-prs.tsx
|
||
import { RestApiClient } from 'twenty-client-sdk/rest';
|
||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||
import { Command } from 'twenty-sdk/front-component';
|
||
|
||
const SyncPrs = () => {
|
||
const execute = async () => {
|
||
await new RestApiClient().post('/s/github/fetch-prs', {
|
||
owner: 'twentyhq',
|
||
repo: 'twenty',
|
||
});
|
||
};
|
||
|
||
return <Command execute={execute} />;
|
||
};
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: '...',
|
||
name: 'sync-prs',
|
||
description: 'Triggers the fetch-prs logic function',
|
||
isHeadless: true,
|
||
component: SyncPrs,
|
||
});
|
||
```
|
||
|
||
传递给 `RestApiClient` 的路径是逻辑函数的 `httpRouteTriggerSettings.path`,并以 `/s` 作为前缀。 保持 `isAuthRequired: true`;Twenty 为你的组件生成的 `TWENTY_APP_ACCESS_TOKEN` 会对请求进行认证:
|
||
|
||
```ts src/logic-functions/fetch-prs.logic-function.ts
|
||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||
|
||
const handler = async (event: RoutePayload) => {
|
||
const { owner, repo } = (event.body ?? {}) as { owner: string; repo: string };
|
||
// ...fetch from GitHub and persist records...
|
||
return { ok: true };
|
||
};
|
||
|
||
export default defineLogicFunction({
|
||
universalIdentifier: '...',
|
||
name: 'fetch-prs',
|
||
handler,
|
||
httpRouteTriggerSettings: {
|
||
path: '/github/fetch-prs',
|
||
httpMethod: 'POST',
|
||
isAuthRequired: true,
|
||
},
|
||
});
|
||
```
|
||
|
||
<Note>
|
||
`TWENTY_APP_ACCESS_TOKEN` 会被自动注入——参见 [应用变量](#application-variables)。 由于机密应用变量永远不会暴露给前端组件,请将 API 密钥和其他敏感逻辑保留在逻辑函数中,而不是前端组件中。
|
||
</Note>
|
||
|
||
### 调用 Twenty REST API
|
||
|
||
要在前端组件中调用应用 HTTP 路由或读取、写入 Twenty 记录,请使用来自 `twenty-client-sdk/rest` 的 `RestApiClient`。 它会将 `/s/...` 路径发送到你工作区的函数基础 URL,而将包括 `/rest/...` 在内的其他所有路径发送到 `TWENTY_API_URL`。
|
||
|
||
它始终以查看页面的人的身份行事。 `runAs: 'application'` 仅是逻辑函数选项:组件永远不会收到你自己的应用令牌,因此在此请求它会抛出错误。 将需要访问应用自身资源的工作放在逻辑函数后面,并改为调用该函数。
|
||
|
||
| 方法 | 描述 |
|
||
| --------------------------------- | ----------------------------- |
|
||
| `get(path, options?)` | 发送一个 `GET` 请求 |
|
||
| `post(path, body?, options?)` | 发送一个 `POST` 请求 |
|
||
| `put(path, body?, options?)` | 发送一个 `PUT` 请求 |
|
||
| `patch(path, body?, options?)` | 发送一个 `PATCH` 请求 |
|
||
| `delete(path, options?)` | 发送一个 `DELETE` 请求 |
|
||
| `request(method, path, options?)` | 使用任意 HTTP 方法的通用请求 |
|
||
| `resolveUrl(path, options?)` | 在不发送请求的情况下将路径解析为其完整 URL(用于链接) |
|
||
|
||
`options` 接受 `headers`、`query`(查询字符串参数记录;空值会被跳过),以及通过 `signal` 传入的 `AbortSignal`。 非 `FormData` 类型的对象 `body` 会被自动进行 JSON 序列化。 在收到 `401` 时,客户端会通过宿主刷新一次访问令牌,然后重试该请求。
|
||
|
||
基础 URL 和令牌默认会从环境中解析得到。 在需要时将覆盖项传递给构造函数——例如在测试中:
|
||
|
||
```ts
|
||
const client = new RestApiClient({
|
||
baseUrl: 'https://myworkspace.twenty.com',
|
||
token: 'my-token',
|
||
});
|
||
```
|
||
|
||
失败的请求会抛出 `RestApiClientError`,其中包含 `status`、`statusText`、`url` 和已解析的 `body`:
|
||
|
||
```tsx
|
||
import { RestApiClient, RestApiClientError } from 'twenty-client-sdk/rest';
|
||
|
||
const client = new RestApiClient();
|
||
|
||
try {
|
||
const people = await client.get('/rest/people', {
|
||
query: { limit: 10 },
|
||
});
|
||
} catch (error) {
|
||
if (error instanceof RestApiClientError) {
|
||
console.error(error.status, error.body);
|
||
}
|
||
}
|
||
```
|
||
|
||
## 访问运行时上下文
|
||
|
||
在组件内部,使用 SDK 的 hooks 获取当前用户、记录和组件实例:
|
||
|
||
```tsx src/front-components/record-info.tsx
|
||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||
import {
|
||
useUserId,
|
||
useSelectedRecordIds,
|
||
useFrontComponentId,
|
||
} from 'twenty-sdk/front-component';
|
||
|
||
const RecordInfo = () => {
|
||
const userId = useUserId();
|
||
const [recordId] = useSelectedRecordIds();
|
||
const componentId = useFrontComponentId();
|
||
|
||
return (
|
||
<div>
|
||
<p>User: {userId}</p>
|
||
<p>Record: {recordId ?? 'No record context'}</p>
|
||
<p>Component: {componentId}</p>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: 'b2c3d4e5-f6a7-8901-bcde-f23456789012',
|
||
name: 'record-info',
|
||
component: RecordInfo,
|
||
});
|
||
```
|
||
|
||
可用的 hooks:
|
||
|
||
| 钩子 | 返回值 | 描述 |
|
||
| --------------------------------------------- | -------------------- | ------------------------------------- |
|
||
| `useUserId()` | `string` 或 `null` | 当前用户的 ID |
|
||
| `useSelectedRecordIds()` | `字符串[]` | 所有已选择的记录 ID(如果未选择,则为空数组) |
|
||
| `useRecordId()` | `string` 或 `null` | **已弃用。** 请改用 `useSelectedRecordIds()` |
|
||
| `useFrontComponentId()` | `string` | 此组件实例的 ID |
|
||
| `useTimelineActivityId()` | `string` 或 `null` | 渲染自定义时间线行时的当前时间线活动 ID |
|
||
| `useColorScheme()` | `'light'` 或 `'dark'` | 宿主 UI 当前的配色方案(`System` 已解析) |
|
||
| `useFrontComponentExecutionContext(selector)` | 因情况而异 | 使用选择器函数访问完整的执行上下文 |
|
||
|
||
## 应用程序变量
|
||
|
||
在 [`defineApplication()`](/l/zh/developers/extend/apps/config/application) 中定义、且 `isSecret: false` 的应用程序变量,可以通过 `getApplicationVariable` 实用工具在前端组件中使用:
|
||
|
||
```tsx src/front-components/greeting.tsx
|
||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||
import { getApplicationVariable } from 'twenty-sdk/front-component';
|
||
|
||
const Greeting = () => {
|
||
const recipientName = getApplicationVariable('DEFAULT_RECIPIENT_NAME') ?? 'World';
|
||
|
||
return <p>Hello, {recipientName}!</p>;
|
||
};
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: '...',
|
||
name: 'greeting',
|
||
component: Greeting,
|
||
});
|
||
```
|
||
|
||
<Warning>
|
||
机密变量(`isSecret: true`)**不会**暴露给前端组件。 它们仅在服务器端运行的 [逻辑函数](/l/zh/developers/extend/apps/logic/logic-functions) 中可用。 这可以防止诸如 API 密钥之类的敏感值被发送到浏览器。
|
||
</Warning>
|
||
|
||
无论变量声明的 `type` 为何,`getApplicationVariable` 始终返回一个 **string**(或 `undefined`)。 该字符串会按照类型被一致地序列化(布尔值为 `"true"` / `"false"`,数字为十进制字符串,数组 / 对象为 JSON),与逻辑函数 `process.env` 使用的格式相同 —— 需要你自行解析(`Number(...)`、`JSON.parse(...)`、`=== 'true'`)。 参见[变量类型](/l/zh/developers/extend/apps/config/application#variable-types)。
|
||
|
||
以下系统变量始终可以通过 `process.env` 获取:
|
||
|
||
| 变量 | 描述 |
|
||
| ------------------------- | --------------------------------------------------- |
|
||
| `TWENTY_API_URL` | Twenty 核心 API 的基础 URL |
|
||
| `TWENTY_APP_ACCESS_TOKEN` | 短期令牌,其作用域是登录人员的角色与应用角色的交集,因此组件永远无法执行超出查看它的人员权限范围的操作 |
|
||
|
||
### `TWENTY_FUNCTIONS_URL`
|
||
|
||
Twenty 还会将 `TWENTY_FUNCTIONS_URL` 注入到前端组件和逻辑函数中:也就是你的应用的 HTTP 触发逻辑函数所提供服务的基础 URL。
|
||
|
||
之所以存在这个变量,是因为该 URL 并不总是 Twenty 服务器本身。 在 Twenty Cloud 上,应用路由通过每个工作区的专用域名提供服务(`https://\<your-workspace-subdomain>.withtwenty.com`,或者在已配置时为应用的主公共域名),以便应用生成的响应运行在一个与 Twenty 应用源不同的隔离源上。 自托管和本地实例会在服务器本身的 `/s` 前缀下提供应用路由,并且可能完全不会设置该变量。 由于基础 URL 会因工作区和实例而异,你的代码不能将其硬编码——服务器会在运行时注入正确的值。
|
||
|
||
你很少需要直接读取它。 通过 `RestApiClient` 使用带有 `/s/` 前缀的路径来调用你的路由,客户端会为你解析 URL:它会去掉 `/s` 前缀并将请求发往 `TWENTY_FUNCTIONS_URL`,在该变量未设置时则回退到 `\<TWENTY_API_URL>/s`。 使用 `resolveUrl('/s/\<path>')` 来在不发送请求的情况下获取绝对 URL,例如用于链接。 仅在手动构建 URL 时才直接读取该变量:
|
||
|
||
```ts
|
||
const routeUrl = `${process.env.TWENTY_FUNCTIONS_URL || `${process.env.TWENTY_API_URL}/s`}/documents/generate`;
|
||
```
|
||
|
||
## 宿主通信 API
|
||
|
||
前端组件可以使用来自 `twenty-sdk` 的函数触发导航、模态框和通知:
|
||
|
||
| 函数 | 描述 |
|
||
| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| `navigate(to, params?, queryParams?, options?)` | 在应用中导航到某个页面 |
|
||
| `openSidePanelPage(params)` | 打开侧边栏 |
|
||
| `closeSidePanel()` | 关闭侧边栏 |
|
||
| `openCommandConfirmationModal(params)` | 显示确认对话框 |
|
||
| `enqueueSnackbar(params)` | 显示一条 Toast 通知 |
|
||
| `unmountFrontComponent()` | 卸载该组件 |
|
||
| `updateProgress(progress)` | 更新进度指示器 |
|
||
| `uploadFile(file, { fieldMetadataId, fileName? })` | 将一个 `Blob` 上传到 FILES 字段;返回 `{ status: 'uploaded', file: { fileId, path, url, size, mimeType } }` 或 `{ status: 'failed', reason: 'invalid-params' \| 'upload-failed' }` |
|
||
|
||
下面是一个示例,使用宿主 API 在操作完成后显示一条 snackbar 并关闭侧边栏:
|
||
|
||
```tsx src/front-components/archive-record.tsx
|
||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||
import { enqueueSnackbar, closeSidePanel, useSelectedRecordIds } from 'twenty-sdk/front-component';
|
||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||
|
||
const ArchiveRecord = () => {
|
||
const [recordId] = useSelectedRecordIds();
|
||
|
||
const handleArchive = async () => {
|
||
const client = new CoreApiClient();
|
||
|
||
await client.mutation({
|
||
updateTask: {
|
||
__args: { id: recordId, data: { status: 'ARCHIVED' } },
|
||
id: true,
|
||
},
|
||
});
|
||
|
||
await enqueueSnackbar({
|
||
message: 'Record archived',
|
||
variant: 'success',
|
||
});
|
||
|
||
await closeSidePanel();
|
||
};
|
||
|
||
return (
|
||
<div style={{ padding: '20px' }}>
|
||
<p>Archive this record?</p>
|
||
<button onClick={handleArchive}>Archive</button>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: 'c9d0e1f2-a3b4-5678-cdef-789012345678',
|
||
name: 'archive-record',
|
||
description: 'Archives the current record',
|
||
component: ArchiveRecord,
|
||
});
|
||
```
|
||
|
||
### 存储
|
||
|
||
`localStorage` 和 `sessionStorage` 的工作方式与在普通页面中相同,使用标准的同步 API。 你的键会限定在你的应用安装和已登录用户范围内:其他应用无法读取它们,且当另一位用户在同一浏览器中登录时,将从一个空的存储开始。 写入 `localStorage` 的值在重新加载后仍会保留在设备上;`sessionStorage` 在整个浏览器会话期间有效。
|
||
|
||
```tsx src/front-components/note-draft.tsx
|
||
import { useState } from 'react';
|
||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||
|
||
const NoteDraft = () => {
|
||
const [draft, setDraft] = useState(() => localStorage.getItem('draft') ?? '');
|
||
|
||
const handleChange = (nextDraft: string) => {
|
||
setDraft(nextDraft);
|
||
localStorage.setItem('draft', nextDraft);
|
||
};
|
||
|
||
return (
|
||
<textarea value={draft} onChange={(event) => handleChange(event.target.value)} />
|
||
);
|
||
};
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: 'd0e1f2a3-b4c5-6789-def0-890123456789',
|
||
name: 'note-draft',
|
||
description: 'Keeps an unsaved note on this device',
|
||
component: NoteDraft,
|
||
});
|
||
```
|
||
|
||
Twenty 会代表你的应用存储这些值,因此写入会立即在本地生效,并在后台保存。 读取操作永不在主机上等待。 不会进行任何同步:这些值不会随着用户切换到其他浏览器或设备而迁移,因此对于任何必须在设备更换后仍然保留的数据,请使用逻辑函数的[key-value store](/l/zh/developers/extend/apps/logic/key-value-store)。
|
||
|
||
写入都有上限,并且所有限制都按字符而不是字节计算:键最多 512 个字符,单个值最多 262,144 个字符,每个存储区在每个应用和用户下最多 1,048,576 个字符。 违反任一限制的写入会抛出一个 `QuotaExceededError`,与浏览器 API 的行为相同。
|
||
|
||
### 处理多个记录
|
||
|
||
使用 `useSelectedRecordIds()` 来处理多个已选记录。 这对于批量操作很有用:
|
||
|
||
```tsx src/front-components/bulk-export.tsx
|
||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||
import { useSelectedRecordIds } from 'twenty-sdk/front-component';
|
||
import { enqueueSnackbar, closeSidePanel } from 'twenty-sdk/front-component';
|
||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||
|
||
const BulkExport = () => {
|
||
const selectedRecordIds = useSelectedRecordIds();
|
||
|
||
const handleExport = async () => {
|
||
const client = new CoreApiClient();
|
||
|
||
for (const recordId of selectedRecordIds) {
|
||
await client.mutation({
|
||
updateTask: {
|
||
__args: { id: recordId, data: { exported: true } },
|
||
id: true,
|
||
},
|
||
});
|
||
}
|
||
|
||
await enqueueSnackbar({
|
||
message: `Exported ${selectedRecordIds.length} records`,
|
||
variant: 'success',
|
||
});
|
||
|
||
await closeSidePanel();
|
||
};
|
||
|
||
return (
|
||
<div style={{ padding: '20px' }}>
|
||
<p>Export {selectedRecordIds.length} selected record(s)?</p>
|
||
<button onClick={handleExport}>Export</button>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: 'd0e1f2a3-b4c5-6789-defa-012345678901',
|
||
name: 'bulk-export',
|
||
description: 'Export selected records',
|
||
component: BulkExport,
|
||
});
|
||
```
|
||
|
||
通过仅限记录选择的[命令菜单项](/l/zh/developers/extend/apps/layout/command-menu-items)将其呈现出来:
|
||
|
||
```ts src/command-menu-items/bulk-export.command-menu-item.ts
|
||
import { defineCommandMenuItem } from 'twenty-sdk/define';
|
||
|
||
export default defineCommandMenuItem({
|
||
universalIdentifier: 'd0e1f2a3-b4c5-6789-defa-012345678902',
|
||
label: 'Bulk Export',
|
||
availabilityType: 'RECORD_SELECTION',
|
||
frontComponentUniversalIdentifier: 'd0e1f2a3-b4c5-6789-defa-012345678901',
|
||
});
|
||
```
|
||
|
||
## 公共资源
|
||
|
||
前端组件可以使用 `getPublicAssetUrl` 访问应用的 `public/` 目录中的文件:
|
||
|
||
```tsx
|
||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||
import { getPublicAssetUrl } from 'twenty-sdk/utils';
|
||
|
||
const Logo = () => <img src={getPublicAssetUrl('logo.png')} alt="Logo" />;
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: '...',
|
||
name: 'logo',
|
||
component: Logo,
|
||
});
|
||
```
|
||
|
||
详情请参见[公共资源部分](/l/zh/developers/extend/apps/config/public-assets)。
|
||
|
||
## 在前端组件之间共享依赖项
|
||
|
||
默认情况下,每个前端组件都会打包自己导入的库的副本,所以如果一个应用有五个组件,就会随应用一起打包五份 React。 在应用的 `package.json` 中声明共享依赖项,只构建这些库一次,让应用的每个组件都从单个缓存文件中加载它们:
|
||
|
||
```json package.json
|
||
{
|
||
"frontComponentSharedDependencies": ["react", "react-dom/client", "twenty-ui/input"]
|
||
}
|
||
```
|
||
|
||
之后每个组件仍然与之前完全相同地导入其依赖项——你的组件代码无需做任何更改:
|
||
|
||
```tsx src/front-components/counter.tsx
|
||
import { useState } from 'react';
|
||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||
|
||
const Counter = () => {
|
||
const [count, setCount] = useState(0);
|
||
|
||
return <button onClick={() => setCount(count + 1)}>{count}</button>;
|
||
};
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: '...',
|
||
name: 'counter',
|
||
component: Counter,
|
||
});
|
||
```
|
||
|
||
有几点需要了解:
|
||
|
||
* **每个应用一个共享依赖项 bundle。** 该 bundle 由应用自身的依赖项构建而成,因此你可以完全掌控要发布的版本。
|
||
* **列出你导入的精确说明符(specifier)。** `twenty-ui/input` 和 `twenty-ui/display` 是两个条目;仅有包名并不能涵盖其子路径。 列出 `react` 会自动涵盖 `react/jsx-runtime`。
|
||
* **将 `react-dom/client` 与 `react` 一起共享。** 每个组件都通过 `createRoot` 进行渲染,因此如果将其省略,每个组件仍会各自打包 React DOM。
|
||
* **bundle 会被缓存。** 它通过带有内容哈希的 URL 提供,并使用长期有效且不可变的缓存策略,因此只会被下载一次,并在应用的所有组件之间复用,直到其中某个依赖发生变化。
|
||
* **未导入任何共享包的组件永远不会下载该 bundle。**
|
||
|
||
## 样式
|
||
|
||
前端组件支持多种样式方案。 你可以使用:
|
||
|
||
* **内联样式** — `style={{ color: 'red' }}`
|
||
* **Twenty UI 组件** — Twenty 自身的组件库;请参阅下文的 [使用 Twenty UI 组件](#using-twenty-ui-components)
|
||
* **Emotion** — 使用 `@emotion/react` 的 CSS-in-JS
|
||
* **Styled-components** — `styled.div` 模式
|
||
* **Tailwind CSS** — 工具类
|
||
* **任何 CSS-in-JS 库**(与 React 兼容)
|
||
|
||
## 使用 Twenty UI 组件
|
||
|
||
Twenty 通过 [`twenty-ui`](https://www.npmjs.com/package/twenty-ui/v/1.0.0-alpha.1) 包提供其组件库。 前端组件可以将其用于按钮、标签、状态徽章、Chip、头像、图标、排版,以及能够自动匹配工作区明暗主题的主题令牌。
|
||
|
||
### 安装
|
||
|
||
将该包添加到你的应用中,并固定为你的 Twenty 实例所提供的版本:
|
||
|
||
```bash
|
||
yarn add twenty-ui@1.0.0-alpha.1
|
||
```
|
||
|
||
`twenty-ui` 会在构建时被打包进你的前端组件中,因此它只需要作为你的应用的依赖——在运行时无需任何配置。
|
||
|
||
### 导入组件
|
||
|
||
请从匹配的子路径而不是包根路径导入,这样只有你使用到的组件才会被打包进你的 bundle:
|
||
|
||
| 子路径 | 导出内容 |
|
||
| --------------------------- | --------------------------------------- |
|
||
| `twenty-ui/input` | `Button` 和表单输入组件 |
|
||
| `twenty-ui/data-display` | `Tag`、`Status`、`Chip`、`Avatar` 等 |
|
||
| `twenty-ui/feedback` | `Callout`、`Banner`、`Info` 等 |
|
||
| `twenty-ui/typography` | `H1Title`、`H2Title`、`H3Title`、`Label` 等 |
|
||
| `twenty-ui/icon` | `Icon*` 组件(例如 `IconCheck`) |
|
||
| `twenty-ui/theme-constants` | `ThemeProvider`、`themeCssVariables` |
|
||
|
||
```tsx
|
||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||
import { Status, Tag } from 'twenty-ui/data-display';
|
||
import { Button } from 'twenty-ui/input';
|
||
|
||
const StyledWidget = () => {
|
||
return (
|
||
<div style={{ padding: '16px', display: 'flex', gap: '8px' }}>
|
||
<Button title="Click me" onClick={() => alert('Clicked!')} />
|
||
<Tag text="Active" color="green" />
|
||
<Status color="green" text="Online" />
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: 'e5f6a7b8-c9d0-1234-efab-567890123456',
|
||
name: 'styled-widget',
|
||
component: StyledWidget,
|
||
});
|
||
```
|
||
|
||
### 图标
|
||
|
||
从 `twenty-ui/icon` 导入单个图标:
|
||
|
||
```tsx
|
||
import { IconBox, IconCheck } from 'twenty-ui/icon';
|
||
```
|
||
|
||
每个具名图标都支持 tree-shaking,因此只导入少量图标对 bundle 体积影响很小。 避免使用 `IconsProvider`、`useIcons` 和 `iconsState`——它们会引入完整的 Tabler 图标集(数 MB 大小)。
|
||
|
||
### 主题和主题令牌
|
||
|
||
Twenty UI 组件会自动匹配工作区的明暗主题——渲染器会在宿主上应用当前启用的配色方案,组件会基于该方案解析自己的颜色。
|
||
|
||
要在你自己的行内样式中使用相同的设计令牌,请调用 `useTheme()` hook。 它会返回与当前主题关联的 Twenty 主题令牌(间距、颜色、圆角、字体),你的组件中无需设置 `ThemeProvider`:
|
||
|
||
```tsx
|
||
import { useTheme } from 'twenty-ui/theme-constants';
|
||
|
||
const Card = () => {
|
||
const theme = useTheme();
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
padding: theme.spacing[4],
|
||
background: theme.background.secondary,
|
||
color: theme.font.color.primary,
|
||
}}
|
||
>
|
||
Themed card
|
||
</div>
|
||
);
|
||
};
|
||
```
|
||
|
||
由于 `useTheme()` 是一个 hook,你需要在组件主体内部读取令牌,因此这些值始终能反映实时的主题。 同一份令牌映射也作为常量 `themeCssVariables` 导出,但在前端组件中更推荐使用 `useTheme()`——在应用清单被抽取时,解引用 `themeCssVariables` 的模块级常量可能会是 undefined。
|
||
|
||
如果需要显式地根据当前配色方案做分支判断,可从 `twenty-sdk/front-component` 中使用 `useColorScheme()` 读取,它会返回 `'light'` 或 `'dark'`。
|
||
|
||
## 目前的限制
|
||
|
||
正在开发前台组件。 渲染、样式处理、事件处理、元素测量以及浏览器存储都运行良好。 任何 *超出* 这些范围的操作(在 ref 上调用 DOM 方法、监听元素尺寸变化、在组件树之外进行传送)目前都缺失或不完整,并且其中大多数都会静默失败:既不会抛出异常,也不会有 TypeScript 错误,因为脚手架是基于完整的浏览器 DOM 进行类型定义的。
|
||
|
||
如果其中任何一项阻碍了你,[打开一个 issue](https://github.com/twentyhq/twenty/issues/new/choose),以便它能被优先处理。
|
||
|
||
### 布局和测量
|
||
|
||
元素可以自行测量:宿主会将几何信息镜像到沙箱中,因此读取在本地完成,但可能会滞后一帧;而首次读取一个从未被测量过的元素时会返回零值。 写入之后,在 `requestAnimationFrame` 回调或 effect 中重新读取。
|
||
|
||
| API | 发生了什么 |
|
||
| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||
| `getBoundingClientRect()`, `offsetWidth`, `clientWidth`, `scrollWidth`, `offsetTop`, ... | 可用,来自镜像;给 `scrollTop` / `scrollLeft` 赋值不会产生任何效果(no-op) |
|
||
| `getClientRects()`, `window.matchMedia()` | 投出 |
|
||
| `window.innerWidth`, `innerHeight`, `devicePixelRatio`, `scrollX`, `scrollY` | 可用,报告的是**浏览器视口**;你的组件自身的尺寸是 `document.body.clientWidth` / `clientHeight` |
|
||
| `window.getComputedStyle()` | 仅返回元素的行内样式,而不会返回宿主计算得到的级联样式 |
|
||
| `ResizeObserver`, `IntersectionObserver` | `ReferenceError` (`typeof`警卫干工作) |
|
||
| `MutationObserver` | 可用,包括 `subtree`、`attributeFilter`、旧值和 `takeRecords()` |
|
||
|
||
基于 `getBoundingClientRect` 的定位现在可用了,但任何通过 `ResizeObserver` 监测尺寸变化的东西(recharts 的 `ResponsiveContainer`、Floating UI 的 `autoUpdate`)仍然不可用。 无论如何都应优先使用 CSS 进行布局:你的样式表可以作用到真实页面,因此 flexbox、grid、`aspect-ratio`、`clamp()` 和 `@container` 都会正常工作,没有帧延迟。
|
||
|
||
<Note>
|
||
`requestAnimationFramework`, `fetch`, `setTimeout` 和 `queueMicrotask` 在没有窗口前缀的情况下工作。 只有`window.requestAnimationFramework(...)`和朋友投掷.
|
||
</Note>
|
||
|
||
### DOM access
|
||
|
||
一个 `ref` 给予你一个沙盒元素,而不是一个 `HTMLEment` 。
|
||
|
||
| 您写的内容 | 发生了什么 | 改用 |
|
||
| ----------------------------------------------------------------------------------------------------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||
| `ref.current.focus()`, `.click()`, `.select()`, `.setSelectionRange()`, `.scrollIntoView()`, `video.play()` | 投出 | 受控组件;从 `event.target` 读取值 |
|
||
| `element.classList.add(...)` | 抛出(`classList` 是 `undefined`) | 构建您自己的 `className` 字符串 |
|
||
| `document.createTreeWalker()` | 投出 | `querySelector()` / `querySelectorAll()` 和 `getElementById()` 可用;`getElementsByClassName()` 也可用,但返回的是静态集合,而不是实时更新的 `HTMLCollection` |
|
||
| `document.activeElement` | 总是`未定义` | 使用 `onFocus` / `onBlur` 跟踪重点 |
|
||
| `canvas` | 渲染无任何东西,没有错误 | SVG,或者在离屏位置绘制并在 `img` 元素中显示结果 |
|
||
| `createPortal(node, document.body)` | 渲染无处不在,而`isConnected`报告成功 | 覆盖“位置:绝对位置”,或传递您自己的容器元素 |
|
||
|
||
Portal间隙是为什么Radix、Headless UI、MUI和react-selected popover默认不会渲染任何内容。 大多数人接受一个容器prop;指向一个你渲染的元素。
|
||
|
||
### 事件
|
||
|
||
鼠标、指针、触摸、拖拽、键盘、焦点,`input`/`change`/`submit`,`scroll`/`wheel`/`contextmenu` 以及 `animationend`/`transitionend` 会传递到宿主元素,另外还有一些按元素区分的事件:`load`/`error` 在 `img` 上,剪贴板和输入法合成事件在 `input`/`textarea` 上,媒体事件在 `video`/`audio` 上,`toggle` 在 `details`/`dialog` 上。 其他任何事件(`onAuxClick`、`onSelect`、`onInvalid`、`onReset`、`onAnimationStart`、指针捕获、非 `img` 上的 `onLoad`)都会在没有任何警告的情况下被丢弃。
|
||
|
||
`document.addEventListener()` 和 `window ddEventListener()`注册时没有错误,永远不发生火灾,这就是为什么当指针离开开始的元素时拖动停止。 `event.preventDefault()`也没有跨过;表单提交、`draego`/`drop`和链接点击已经是你的守卫。
|
||
|
||
### 属性和样式
|
||
|
||
每个元素都会将其自身的属性转发到宿主 DOM(`a` 上的 `href`,`img` 上的 `src`/`alt`,`input` 上的 `value`/`placeholder`/`disabled` 等),并且在每个元素上都有一组通用属性:`id`、`className`、`style`、`title`、`tabIndex`、`role`、`draggable` 以及任何 `aria-*` / `data-*` 属性(使用连字符形式,因此会丢弃 `ariaLabel`)。 外面的任何东西都被静默丢弃了,因此表达了自定义为 `data *` 。
|
||
|
||
组件的 CSS(无论来自 `import './styles.css'`、CSS-in-JS 还是 `style` 元素)都会被注入到宿主页面的 `head` 中,且是**未作用域化的(unscoped)**。 所以类名称与 Twenty 自己碰撞(前缀它们,永远不会写上`div ● v.... }`选择器'和`@media`匹配浏览器窗口而不是你的小部件 (使用 `@container` 和你自己的 `container-type`)。 内联 `style` props 不受影响。
|
||
|
||
### 存储和网络
|
||
|
||
`localStorage` 和 `sessionStorage` 由 Twenty 提供,而不是由浏览器提供:组件在一个不透明来源的 worker 中运行,因此主机会代表你的应用存储这些值。 有关它们的作用域和限制,请参见[storage](#storage)。 IndexedDB、cookies、Cache API 和 `BroadcastChannel` 仍然不可用。 要在多设备之间持久化状态,请调用[logic function](/l/zh/developers/extend/apps/logic/logic-functions)并使用其[key-value store](/l/zh/developers/extend/apps/logic/key-value-store)。
|
||
|
||
`fetch` 工作, 附有警告:
|
||
|
||
* 调用20API和您的应用路由由主机代理,所以偏好[`RestApiClient`](#calling-the-twenty-rest-api)。 在代理来电时,`AbortSignal` 和另一个 `RequestInit` 选项被丢弃,只支持 `string` 和 `URLSearchParams`。
|
||
* 其它来源将沙盒留给`原始:null`,所以第三方API只有在发送`Access-Control-Allow-ource:*`时才能解答。 从逻辑函数调用它。
|
||
* `fetch('/rest/people)`永远不匹配20个API,因为沙盒没有页面URL解决相对路径。
|
||
|
||
### 媒体捕获
|
||
|
||
`navigator.mediaDevices.getUserMedia()` 和 `MediaRecorder` 通过 sandbox polyfill 在 front 组件中运行,因此标准录制代码可以不做修改地运行,并且 `MediaRecorder.isTypeSupported` 会对常见的容器/编解码器组合给出支持情况。 详细的 `getUserMedia` 约束对象会被接受但不会被转发 —— 宿主会使用其针对所请求媒体类型的默认设置进行捕获 —— 并且在应用之间同一时间只能有一个捕获处于活动状态。 使用 `uploadFile` 宿主函数存储已录制的 `Blob`。
|
||
|
||
### 其他差距
|
||
|
||
* **文件内容。** 类型为 `file` 的 `input` 只会向你的处理函数提供文件的元数据,而不是字节本身,因此无法使用 `FileReader`。 要上传你的代码已经持有的 `Blob`(例如由 `MediaRecorder` 生成的 `Blob`),请使用 `uploadFile` 宿主函数。
|
||
* **拖放payloads.** 拖动事件触发,但`event.dataTransfer` 是未定义的。
|
||
* **Node 内置。** `fs`、`path` 和 `node:crypto` 失败,所以将其移动到一个[逻辑函数](/l/zh/developers/extend/apps/logic/logic-functions)。 Web Crypto, `fetch`, `TextEncoder` 和 `URL` 都是可用的。
|
||
* **`iframe`** 始终会在没有 `allow-same-origin` 的情况下重新沙箱化,因此依赖其自身会话的嵌入内容会呈现为未登录状态。 它也没有`onLoad`文件。
|