snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
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

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,118 @@
---
title: 命令菜单项
description: 使用 defineCommandMenuItem 将前端组件暴露为快速操作和命令菜单(Cmd+K)条目。
icon: terminal
---
**命令菜单项** 是用户与[前端组件](/l/zh/developers/extend/apps/layout/front-components)之间的桥梁。 它会在 Twenty 的命令菜单(Cmd+K)中注册该组件,并可选地将其作为页面右上角的固定快速操作按钮。
```ts src/command-menu-items/open-dashboard.command-menu-item.ts
import { defineCommandMenuItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
label: 'Open Dashboard',
shortLabel: 'Dashboard',
isPinned: true,
availabilityType: 'GLOBAL',
frontComponentUniversalIdentifier: '74c526eb-cb68-4cf7-b05c-0dd8c288d948',
});
```
## 配置字段
| 字段 | 必填 | 描述 |
| --------------------------------------- | -- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `universalIdentifier` | 是 | 该命令的稳定唯一 ID |
| `label` | 是 | 在命令菜单(Cmd+K)中显示的完整标签 |
| `frontComponentUniversalIdentifier` | 是 | 此命令打开的前端组件的 `universalIdentifier` |
| `shortLabel` | 否 | 固定的快速操作按钮上显示的较短标签 |
| `icon` | 否 | **已弃用** — 该设置会被应用程序图标取代并被忽略;如设置,构建时将发出警告 |
| `isPinned` | 否 | 为 `true` 时,会将该命令显示为页面右上角的快速操作按钮 |
| `availabilityType` | 否 | 控制命令出现的位置:`'GLOBAL'`(始终可用)、`'GLOBAL_OBJECT_CONTEXT'`(仅在具有对象上下文的页面上——索引页和记录页)、`'RECORD_SELECTION'`(仅在选择了记录时),或 `'FALLBACK'`(当没有其他命令匹配时显示) |
| `availabilityObjectUniversalIdentifier` | 否 | 将该命令限制在特定对象类型的页面上(例如仅在 Company 记录上) |
| `conditionalAvailabilityExpression` | 否 | 用于动态控制可见性的布尔表达式(见下文) |
## 无头命令
与[无头前端组件](/l/zh/developers/extend/apps/layout/front-components#headless-vs-non-headless)配对的命令菜单项,是交付一键操作(运行代码、导航,或确认并执行)的惯用方式。 Front Components 页面介绍了处理“执行操作并卸载”模式的 [SDK Command 组件](/l/zh/developers/extend/apps/layout/front-components#sdk-command-components)(`Command`、`CommandLink`、`CommandModal`、`CommandOpenSidePanelPage`)。
一个典型流程:一个 headless 组件会渲染 `<Command execute={...} />`(参见[完整示例](/l/zh/developers/extend/apps/layout/front-components#sdk-command-components)),并且命令菜单项会指向它:
```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',
});
```
## 条件可用性表达式
通过 `conditionalAvailabilityExpression` 字段,您可以基于当前页面上下文控制命令何时可见。 从 `twenty-sdk` 导入带类型的变量和运算符来构建表达式:
```ts src/command-menu-items/bulk-update.command-menu-item.ts
import {
defineCommandMenuItem,
objectPermissions,
everyEquals,
} from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: '...',
label: 'Bulk Update',
availabilityType: 'RECORD_SELECTION',
frontComponentUniversalIdentifier: '...',
conditionalAvailabilityExpression: everyEquals(
objectPermissions,
'canUpdateObjectRecords',
true,
),
});
```
<Note>
`RECORD_SELECTION` 已经意味着非空选择 — 仅在需要表达具体数量时使用 `numberOfSelectedRecords`(例如 `>= 2`)。
</Note>
### 上下文变量
这些变量表示页面的当前状态:
| 变量 | 类型 | 描述 |
| ------------------------------ | --------- | --------------------------------------------- |
| `pageType` | `string` | 当前页面类型(例如 'RecordIndexPage'、'RecordShowPage') |
| `isInSidePanel` | `boolean` | 组件是否在侧边栏中渲染 |
| `numberOfSelectedRecords` | `number` | 当前选中的记录数量 |
| `isSelectAll` | `boolean` | “全选”是否已激活 |
| `selectedRecords` | `array` | 已选记录对象 |
| `favoriteRecordIds` | `array` | 已收藏记录的 ID |
| `objectPermissions` | `object` | 当前对象类型的权限 |
| `targetObjectReadPermissions` | `object` | 目标对象的读取权限 |
| `targetObjectWritePermissions` | `object` | 目标对象的写入权限 |
| `featureFlags` | `object` | 当前启用的功能标志 |
| `objectMetadataItem` | `object` | 当前对象类型的元数据 |
| `hasAnySoftDeleteFilterOnView` | `boolean` | 当前视图是否包含软删除筛选器 |
### 运算符
将变量组合为布尔表达式:
| 运算符 | 描述 |
| ----------------------------------- | -------------------------------- |
| `isDefined(value)` | 当该值不是 null/undefined 时为 `true` |
| `isNonEmptyString(value)` | 当该值为非空字符串时为 `true` |
| `includes(array, value)` | 当数组包含该值时为 `true` |
| `includesEvery(array, prop, value)` | 当每个条目的属性都包含该值时为 `true` |
| `every(array, prop)` | 当该属性在每个条目上都为 truthy 时为 `true` |
| `everyDefined(array, prop)` | 当该属性在每个条目上都已定义时为 `true` |
| `everyEquals(array, prop, value)` | 当该属性在每个条目上都等于该值时为 `true` |
| `some(array, prop)` | 当至少一个条目上的该属性为 truthy 时为 `true` |
| `someDefined(array, prop)` | 当至少一个条目上的该属性已定义时为 `true` |
| `someEquals(array, prop, value)` | 当至少一个条目上的该属性等于该值时为 `true` |
| `someNonEmptyString(array, prop)` | 当至少一个条目上的该属性为非空字符串时为 `true` |
| `none(array, prop)` | 当该属性在每个条目上都为 falsy 时为 `true` |
| `noneDefined(array, prop)` | 当该属性在每个条目上都为 undefined 时为 `true` |
| `noneEquals(array, prop, value)` | 当该属性在任意条目上都不等于该值时为 `true` |

View File

@@ -0,0 +1,849 @@
---
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`文件。

View File

@@ -0,0 +1,46 @@
---
title: 导航菜单项
description: 向工作区侧边栏添加自定义条目——指向已保存视图或外部 URL 的链接。
icon: bars
---
**导航菜单项** 是左侧边栏中的一个条目。 使用 `defineNavigationMenuItem()` 发布自定义侧边栏链接——通常为你发布的每个[视图](/l/zh/developers/extend/apps/layout/views)提供一个链接——或指向外部 URL。
```ts src/navigation-menu-items/example-navigation-menu-item.ts
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from '../views/example-view';
export default defineNavigationMenuItem({
universalIdentifier: '9327db91-afa1-41b6-bd9d-2b51a26efb4c',
name: 'example-navigation-menu-item',
icon: 'IconList',
color: 'blue',
position: 0,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER,
});
```
## 关键点
* `type` 决定菜单项链接到什么。 每种类型都与一个特定的标识符字段配对:
| 类型 | 作用 | 必填字段 |
| ------------------------------------ | ----------- | ----------------------------------------------- |
| `NavigationMenuItemType.VIEW` | 打开已保存视图 | `viewUniversalIdentifier` |
| `NavigationMenuItemType.LINK` | 打开外部 URL | `link` |
| `NavigationMenuItemType.FOLDER` | 在一个标签下分组嵌套项 | `name`(子项通过 `folderUniversalIdentifier` 引用该文件夹) |
| `NavigationMenuItemType.OBJECT` | 打开对象的默认索引页面 | `targetObjectUniversalIdentifier` |
| `NavigationMenuItemType.PAGE_LAYOUT` | 打开独立页面布局 | `pageLayoutUniversalIdentifier` |
* `position` 控制在侧边栏中的排序。
* 该枚举还包含 `NavigationMenuItemType.RECORD`,在内部用于用户创建的记录收藏——在应用 manifest 中不可用(没有用于引用记录的字段)。
* `icon` 和 `color` 是可选的,用于自定义条目的外观。
* `folderUniversalIdentifier` 也可用于任意条目,将其嵌套到一个 `FOLDER` 类型的父级中。
<Note>
**常见陷阱:** 创建一个没有关联导航菜单项的对象会使该对象无法从侧边栏访问。 除非是技术性/内部对象,否则每个自定义对象都应在侧边栏中有一个指向它的条目。
</Note>

View File

@@ -0,0 +1,59 @@
---
title: 概览
description: 将你的应用嵌入 Twenty 的 UI 中——侧边栏条目、已保存视图、记录页面选项卡以及沙箱化的 React 组件。
icon: table-columns
---
Twenty 应用的**布局层**是用户看到的一切:应用在侧边栏中的呈现位置、它随附的列表视图、记录详情页的排布方式,以及在这些页面中渲染的自定义 React 组件。
```text
Sidebar Record list Record detail page
─────── ─────────── ──────────────────
[📋 My View] ────▶ ┌──────────┐ ┌─────────────────────┐
[📋 Drafts ] │ Companies│ │ Tabs: [Overview ] │
[📋 Inbox ] │ ──────── │ │ [Notes ] │
▲ │ Apple │ │ [Hello ]◀──── definePageLayoutTab
│ │ Acme │ │ │ adds a tab...
└ defineNavi- │ … │ │ ┌────────────────┐ │
gationMenu- └────▲─────┘ │ │ │ │
Item points │ │ │ React UI │◀── …with a
to a defineView │ │ │ (sandboxed in │ │ defineFrontComponent
└ defineView │ │ a Worker) │ │ widget inside
picks columns │ └────────────────┘ │
and filters └─────────────────────┘
```
## 本节内容
<CardGroup cols={2}>
<Card title="视图" icon="list" href="/l/zh/developers/extend/apps/layout/views">
`defineView` — 已保存的列表配置:可见列、筛选器、分组。
</Card>
<Card title="导航菜单项" icon="bars" href="/l/zh/developers/extend/apps/layout/navigation-menu-items">
`defineNavigationMenuItem` — 指向视图或外部 URL 的侧边栏条目。
</Card>
<Card title="页面布局" icon="table-columns" href="/l/zh/developers/extend/apps/layout/page-layouts">
`definePageLayout` 和 `definePageLayoutTab` — 记录详情页上的选项卡和小部件。
</Card>
<Card title="前端组件" icon="window-maximize" href="/l/zh/developers/extend/apps/layout/front-components">
`defineFrontComponent` — 在 Twenty 内部渲染的沙箱化 React 组件。
</Card>
<Card title="命令菜单项" icon="terminal" href="/l/zh/developers/extend/apps/layout/command-menu-items">
`defineCommandMenuItem` — 将前端组件注册为 Cmd+K 条目和快速操作。
</Card>
</CardGroup>
## 应用的呈现位置
| 呈现位置 | 控制内容 | 实体 |
| --------------- | -------------------------------- | ----------------------------------------- |
| **侧边栏** | 链接到已保存视图或外部 URL 的自定义条目 | `defineNavigationMenuItem` |
| **记录列表** | 对象的已保存配置——可见列、顺序、筛选器、分组 | `defineView` |
| **记录详情页** | 记录页面上的选项卡和小部件(你自己的对象或标准对象) | `definePageLayout`, `definePageLayoutTab` |
| **以上任意位置内部** | 自定义 React 小部件——按钮、表单、仪表盘、集成 | `defineFrontComponent` |
| **应用设置** | 位于应用“设置”选项卡中的自定义配置部分,用于替代默认的变量界面 | `defineSettingsFrontComponent` |
| **命令菜单(Cmd+K)** | 固定的快速操作或隐藏命令 | `defineCommandMenuItem` |
前端组件在使用 Remote DOM 的隔离 Web Worker 内运行——它们在页面中*原生*渲染(而不是在 iframe 中),但无法直接访问宿主页面或 DOM。 与 Twenty 的通信通过消息传递的宿主 API 完成。
它们仍在积极开发中:沙盒仅实现了部分 DOM,因此高级用法可能会失败。 请参阅[当前限制](/l/zh/developers/extend/apps/layout/front-components#current-limitations)。

View File

@@ -0,0 +1,155 @@
---
title: 页面布局
description: 使用 `definePageLayout` 和 `definePageLayoutTab` 自定义记录详情页——包括选项卡、小部件以及前端组件的渲染位置。
icon: table-columns
---
**页面布局(page layout)** 控制记录详情页的排布方式:显示哪些选项卡,以及这些选项卡中包含哪些小部件。 使用 `definePageLayout()` 为你拥有的对象声明一个布局,或者使用 `definePageLayoutTab()` 为已存在的布局(你的自定义布局或标准的 Twenty 布局)添加单个选项卡。
| 用例 | 实体 |
| ---------------------------- | --------------------- |
| 为你拥有的对象上的记录页面定义完整布局 | `definePageLayout` |
| 向现有布局中添加一个选项卡(你自己的对象或一个标准对象) | `definePageLayoutTab` |
## definePageLayout
当你拥有整个详情页时使用此方式——通常适用于你自己定义的自定义对象。
```ts src/page-layouts/example-record-page-layout.ts
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from '../objects/example-object';
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../front-components/hello-world';
export default definePageLayout({
universalIdentifier: '203aeb94-6701-46d6-9af1-be2bbcc9e134',
name: 'Example Record Page',
type: 'RECORD_PAGE',
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
tabs: [
{
universalIdentifier: '6ed26b60-a51d-4ad7-86dd-1c04c7f3cac5',
title: 'Hello World',
position: 50,
icon: 'IconWorld',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier: 'aa4234e0-2e5f-4c02-a96a-573449e2351d',
title: 'Hello World',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
],
});
```
### 关键点
* `type` 为 `'RECORD_INDEX'`、`'RECORD_PAGE'`、`'DASHBOARD'` 或 `'STANDALONE_PAGE'` 之一。 使用 `'RECORD_PAGE'` 来自定义特定对象的详情视图。
* `objectUniversalIdentifier` 指定此布局适用于哪个对象。
* 每个 `tab` 使用 `title`、`position` 和 `layoutMode` 定义页面的一个部分:记录页面和独立页面使用 `VERTICAL_LIST`,仪表板使用 `GRID`。 在 `VERTICAL_LIST` 选项卡中,单个小部件会以全宽方式渲染并占据整个选项卡;如果有多个小部件,它们会堆叠显示为卡片。 `GRID` 选项卡始终在 12 列网格上将其小部件布局为卡片,无论小部件数量多少都是如此,因此当你希望单个小部件填满页面时,请选择 `VERTICAL_LIST`。
* 请显式设置 `layoutMode`。 如果省略它,在 `STANDALONE_PAGE` 上会得到 `VERTICAL_LIST`,在其他地方会得到 `GRID`,而这在记录页面上通常不是你想要的效果。
* 选项卡内的每个 `widget` 可以渲染一个[前端组件](/l/zh/developers/extend/apps/layout/front-components)、关系列表或其他内置小部件类型。
* 选项卡上的 `position` 控制其顺序。 使用更高的值(例如 50)可将自定义选项卡放在内置选项卡之后。
### 字段小部件
`FIELD` 小部件会渲染记录中的一个字段。 对于关系字段,它还可以嵌入相关记录的列表:
```ts
{
universalIdentifier: 'c1c2c3c4-c5c6-4000-8000-000000000003',
title: 'People → Opportunities',
type: 'FIELD',
configuration: {
configurationType: 'FIELD',
fieldMetadataId: PEOPLE_FIELD_UNIVERSAL_IDENTIFIER,
fieldDisplayMode: 'TABLE',
nestedRelationFieldMetadataId: OPPORTUNITIES_FIELD_UNIVERSAL_IDENTIFIER,
},
}
```
* `fieldMetadataId` 接收布局对象上某个字段的全局标识符。
* `fieldDisplayMode` 的取值之一为 `'FIELD'`、`'CARD'`、`'EDITOR'`、`'VIEW'` 或 `'TABLE'`。 `TABLE` 会嵌入一个视图,用于列出一对多关系字段中的记录。
* `nestedRelationFieldMetadataId` 是可选的,并接收关系目标对象上一个一对多关系字段的通用标识符,用于列出跨两个关系跳数的记录(例如,在 Company 页面中列出该公司人员的机会,或在 Person 页面中列出该人员所在公司的机会)。 第一跳可以是一对多或多对一的关系字段,第二跳必须是一对多(不支持连接关系),并且需要 `fieldDisplayMode: 'TABLE'` — 将其与任何其他显示模式组合都会导致验证错误,因为嵌套小部件始终呈现为嵌入视图。
## definePageLayoutTab
当你只想在现有布局中**添加**一个选项卡时使用此方式——例如,在标准 Company 页面上添加一个分析选项卡,或者在你自己的对象布局上附加一个 AI 摘要选项卡。
```ts src/page-layouts/example-extra-tab.ts
import {
definePageLayoutTab,
PageLayoutTabLayoutMode,
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../front-components/hello-world';
export default definePageLayoutTab({
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000001',
pageLayoutUniversalIdentifier:
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.companyRecordPage
.universalIdentifier,
title: 'Hello World',
position: 1000,
icon: 'IconWorld',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000002',
title: 'Hello World',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
});
```
### 关键点
* `pageLayoutUniversalIdentifier` 是**必需的**,并且必须在安装时指向一个已存在的页面布局——可以是标准的 Twenty 布局,也可以是由你自己的应用定义的布局。 当前不支持跨应用引用由其他已安装应用拥有的布局。 当父布局缺失时,安装会失败,并给出清晰的验证错误。
* 对于标准 Twenty 布局,从 `twenty-sdk/define` 导入标识符:
```ts
import { STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.companyRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.personRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.taskRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.opportunityRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.noteRecordPage.universalIdentifier
// …
```
每个布局条目还会公开其 `tabs` 及其 `widgets`,因此你可以引用任意层级:
```ts
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.taskRecordPage.tabs.home.universalIdentifier
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.taskRecordPage.tabs.home.widgets.fields.universalIdentifier
```
还提供了一个简短别名 `STANDARD_PAGE_LAYOUT`:
```ts
import { STANDARD_PAGE_LAYOUT } from 'twenty-sdk/define';
STANDARD_PAGE_LAYOUT.companyRecordPage.universalIdentifier;
```
* `widgets` 仅作用于此选项卡——它们引用[前端组件](/l/zh/developers/extend/apps/layout/front-components)、视图等,其方式与在 `definePageLayout` 中内联定义的小部件完全相同。
* `position` 控制目标布局中相对于现有选项卡的排序。 选择一个取值,使你的选项卡相对于内置选项卡位于你想要的位置。
* 当你只想向现有布局进行添加时,请使用此功能,而不是 `definePageLayout`。 当你拥有整个布局时,请使用 `definePageLayout`。

View File

@@ -0,0 +1,113 @@
---
title: 视图
description: 为你的应用中的对象提供预配置的已保存视图——列顺序、筛选器、分组。
icon: list
---
**视图(view)** 是关于对象记录如何显示的已保存配置:包括显示哪些字段、它们的顺序、是否可见,以及应用的任何筛选器或分组。 使用 `defineView()` 随你的应用一起提供预配置的视图。
每个对象都会自动获得其主列表视图(服务器预置的“全部 …”表格,即 `OBJECT` 导航项打开的视图)——包括你的应用创建的对象,其中每个字段对应一列。 你使用 `defineView()` 定义的视图始终是基于该默认视图之上的**附加**视图。
```ts src/views/example-view.ts
import { defineView } from 'twenty-sdk/define';
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from '../objects/example-object';
import { NAME_FIELD_UNIVERSAL_IDENTIFIER } from '../objects/example-object';
export default defineView({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
name: 'All example items',
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconList',
position: 0,
fields: [
{
universalIdentifier: 'f926bdb7-6af7-4683-9a09-adbca56c29f0',
fieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
position: 0,
isVisible: true,
size: 200,
},
],
});
```
## 关键点
* `objectUniversalIdentifier` 指定此视图适用于哪个对象。 它可以是你定义的自定义对象,也可以是标准的 Twenty 对象。
* 对象的主列表视图由服务器拥有:`key` 已被弃用并会被忽略,因此清单视图永远无法声明它。 如果你希望你的视图在侧边栏中只需一次点击即可访问,请提供一个 `VIEW` 导航项。
* `fields` 控制显示哪些列以及它们的顺序。 每个字段引用一个 `fieldMetadataUniversalIdentifier`。 若要引用自动创建的系统字段(例如 `createdAt`),请参阅[定位系统元数据](/l/zh/developers/extend/apps/data/system-fields)。
* 你还可以声明 `filters`、`filterGroups`、`sorts`、`groups` 和 `fieldGroups` 以进行更高级的配置。
* 当同一对象存在多个视图时,`position` 控制其排序。
## 可选属性
| 属性 | 值 | 描述 |
| ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `type` | `ViewType.TABLE`(默认),`ViewType.LIST`,`ViewType.KANBAN`,`ViewType.CALENDAR` | 记录的排布方式。 (`FIELDS_WIDGET`、`TABLE_WIDGET`、`KANBAN_WIDGET`、`LIST_WIDGET` 和 `CALENDAR_WIDGET` 也存在,但由页面布局小部件在内部使用。) |
| `visibility` | `ViewVisibility.WORKSPACE`(默认),`ViewVisibility.UNLISTED` | 视图是对整个工作区可见,还是在选择器中隐藏。 |
| `openRecordIn` | 已弃用 | 不再读取:记录的打开位置现在是[对象](/l/zh/developers/extend/apps/data/objects)的一个属性(`defineObject()` 上的 `openRecordIn`),如果未设置该属性,则回退到每个成员自己的偏好设置。 |
| `排序` | `{ fieldMetadataUniversalIdentifier, direction: ViewSortDirection.ASC \| DESC }[]` | 默认排序顺序。 |
| `isCompact` | `boolean` | 紧凑的行显示。 |
| `mainGroupByFieldMetadataUniversalIdentifier` + `shouldHideEmptyGroups` | — | 按字段对记录进行分组(例如看板列)。 |
| `kanbanAggregateOperation`, `kanbanAggregateOperationFieldMetadataUniversalIdentifier`, `kanbanColumnWidth` | `AggregateOperations.*` | 看板列聚合和列宽设置。 |
| `calendarLayout`, `calendarFieldMetadataUniversalIdentifier`, `calendarEndFieldMetadataUniversalIdentifier` | `ViewCalendarLayout.DAY` / `WEEK` / `MONTH` | 日历视图:布局、用于定位记录的日期字段,以及可选的结束日期字段。 |
上述所有枚举都从 `twenty-sdk/define` 导出。
## 过滤器
视图可以附带预先应用的过滤器。 每个过滤器有三个坐标:被筛选的**字段**、**运算符**(如何比较)以及**值**(与之比较的内容)。 这三者必须全部对齐——在同步时,使用不适用于字段类型的运算符将会被拒绝。
```ts
import { ViewFilterOperand } from 'twenty-sdk/define';
filters: [
{
universalIdentifier: '...',
fieldMetadataUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
operand: ViewFilterOperand.IS,
value: ['ACTIVE'],
},
],
```
### 各字段类型支持的运算符
| 字段类型 | 受支持的运算符 |
| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `TEXT`, `EMAILS`, `FULL_NAME`, `ADDRESS`, `LINKS`, `PHONES`, `RAW_JSON`, `FILES`, `ACTOR`, `ARRAY` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `ACTOR.source`, `ACTOR.workspaceMemberId` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `SELECT` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `MULTI_SELECT` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `RELATION` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `NUMBER` | `IS`, `IS_NOT`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `RATING` | `IS`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `CURRENCY`, `CURRENCY.amountMicros` | `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `BOOLEAN` | `IS` |
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `TS_VECTOR` | `VECTOR_SEARCH` |
> 名称相似的字段类型可以使用完全不同的运算符 —— `SELECT` 和 `MULTI_SELECT` 就是常见情况。
### 每个运算符对应的值结构
`value` 字段始终是可序列化为 JSON 的值,但其期望的结构取决于所使用的运算符:
| 运算符类别 | 值结构 | 示例 |
| ----------------------------------------------------- | -------------------- | ------------------------ |
| `SELECT` 上的 `IS`, `IS_NOT` | 选项键(字符串)数组 | `['ACTIVE', 'PENDING']` |
| `MULTI_SELECT` 上的 `CONTAINS`, `DOES_NOT_CONTAIN` | 选项键(字符串)数组 | `['TAG_A']` |
| `RELATION` 上的 `IS`, `IS_NOT` | 记录 ID(uuid)数组 | `['c5a1...']` |
| 文本类字段上的 `CONTAINS`, `DOES_NOT_CONTAIN` | 字符串 | `'acme'` |
| `NUMBER` 上的 `IS`, `IS_NOT` | 字符串(该值) | `'5'` |
| `RATING` / `UUID` 上的 `IS` | 字符串(该值) | `'5'` |
| `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL` | 字符串(边界值) | `'10'` |
| `DATE` / `DATE_TIME` 上的 `IS`, `IS_BEFORE`, `IS_AFTER` | ISO 8601 字符串 | `'2025-01-01T00:00:00Z'` |
| `IS_EMPTY`, `IS_NOT_EMPTY` | 空字符串 | `''` |
| `BOOLEAN` 上的 `IS` | `'true'` 或 `'false'` | `'true'` |
## 视图在 UI 中如何显示
单独一个视图无法从侧边栏访问。 要让它显示在侧边栏中,请将其与类型为 `VIEW`、指向该视图 `universalIdentifier` 的[导航菜单项](/l/zh/developers/extend/apps/layout/navigation-menu-items)配对。 这是规范做法:每个自定义对象在创建时都会自动生成一个默认的列表视图,因此你只需提供用于打开它的侧边栏条目。