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,124 @@
---
title: 应用程序配置
description: 使用 defineApplication 声明应用的身份、默认角色、变量和应用市场元数据。
icon: rocket
---
每个应用必须且只能有一个 `defineApplication` 调用。 它声明:
* **应用的身份** — 通用标识符、显示名称、描述。
* **权限** — 其逻辑函数和前端组件在何种角色下运行。
* **变量**(可选)— 以环境变量形式暴露给代码的键值对。
* **安装前/安装后/卸载函数**(可选)— 参见 [逻辑函数](/l/zh/developers/extend/apps/logic/logic-functions)。
```ts src/application-config.ts
import { defineApplication } from 'twenty-sdk/define';
export default defineApplication({
universalIdentifier: '39783023-bcac-41e3-b0d2-ff1944d8465d',
displayName: 'My Twenty App',
description: 'My first Twenty app',
applicationVariables: {
DEFAULT_RECIPIENT_NAME: {
universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de',
description: 'Default recipient name for postcards',
value: 'Jane Doe',
isSecret: false,
},
},
});
```
备注:
* `universalIdentifier` 字段是你拥有的确定性 ID。 只需生成一次,并在多次同步过程中保持稳定不变。
* `applicationVariables` 会变成你的函数和前端组件可用的环境变量。 在逻辑函数(服务端)中,可以通过 `process.env.VARIABLE_NAME` 使用它们。 在前端组件中,使用 `twenty-sdk/front-component` 中的 `getApplicationVariable('VARIABLE_NAME')`。 标记为 `isSecret: true` 的变量只会注入到逻辑函数中。 前端组件只会接收非机密变量。
* 默认角色会根据使用 [`defineApplicationRole()`](/l/zh/developers/extend/apps/config/roles) 标记的角色文件自动检测——你不需要在 `defineApplication()` 中引用它。
* 在构建清单时会自动检测安装前、安装后和卸载函数——无需在 `defineApplication()` 中引用它们。
* 显式传递 `defaultRoleUniversalIdentifier` 仍然受支持以保持向后兼容性,但已弃用,推荐改用 `defineApplicationRole()`。
* `serverVariables` 是实例级的配置和机密信息(例如 API 密钥)。 与 `applicationVariables` 不同,它们不会在 manifest 中声明具体值——工作区运维人员会在应用设置中填写这些值,并且它们只有在被设置后才会被注入到逻辑函数中。
* 两种类型的变量都可以接受 `isDeprecated: true`。 使用它来停用变量而不是删除它:保留已声明的键可以保留存储的值(删除它会销毁操作员输入的值),并且变量仍然会被注入,所以你的代码仍然可以回退到它 — `process.env.NEW_API_KEY ?? process.env.API_KEY`。 一旦弃用变量没有任何值,它就会从应用的设置中消失,并且永远不会计入应用的配置检查中,因此 `isDeprecated` 的优先级高于 `isRequired`。
* 要在应用的 **Settings** 选项卡中渲染自定义配置界面(替换默认的变量配置部分),请在其独立文件中使用 [`defineSettingsFrontComponent()`](/l/zh/developers/extend/apps/layout/front-components#custom-settings-component) 声明一个前端组件。 每个应用只允许有一个。 系统管理的部分(自动升级、App URL、连接)将始终保持可见。
## 变量类型
`applicationVariables` 和 `serverVariables` 都接受一个可选的 `type`(且对于 `SELECT` / `MULTI_SELECT`,还可以接受一个 `options` 列表)。 支持的类型:`TEXT`(默认)、`BOOLEAN`、`NUMBER`、`NUMERIC`、`DATE`、`DATE_TIME`、`SELECT`、`MULTI_SELECT`、`ARRAY`、`RAW_JSON`、`RICH_TEXT`。
```ts src/application-config.ts
import { defineApplication, FieldType } from 'twenty-sdk/define';
export default defineApplication({
// ...identity, role...
applicationVariables: {
MAX_POSTCARDS: {
universalIdentifier: '5f4497e4-9030-4085-85eb-2c48b8d53713',
description: 'Maximum postcards per batch',
type: FieldType.NUMBER,
value: 10,
},
DEFAULT_REGION: {
universalIdentifier: '76c5c321-b6b6-46eb-b4fc-f9f04bb04227',
description: 'Default shipping region',
type: FieldType.SELECT,
options: [
{ label: 'Europe', value: 'eu' },
{ label: 'United States', value: 'us' },
],
value: 'eu',
},
},
});
```
`type` 只影响**展示和校验**——它会在工作区设置界面中选择匹配的输入控件(开关、数字字段、下拉框、日期选择器、JSON 编辑器等)。 并让构建过程校验你的配置(例如,`SELECT` / `MULTI_SELECT` 必须声明非空的 `options`)。 它**不会**改变该值到达你代码的方式。
值**始终以字符串注入**——这是环境变量固有的特性(`process.env.*` 只能是字符串)。 当你的逻辑函数运行时,执行器会在构建 `process.env` 时按声明的 `type` 序列化每个值,因此无论该值是如何设置的(清单默认值、设置界面或先前的版本),字符串格式都是一致的:
| 类型 | `process.env` 字符串 |
| ---------------------------------- | --------------------------------- |
| `TEXT`、`SELECT`、`DATE`、`DATE_TIME` | 原始值(`"eu"`、`"2026-01-01"`) |
| `BOOLEAN` | `"true"` / `"false"` |
| `NUMBER`、`NUMERIC` | 十进制字符串(`"10"`、`"2.5"`) |
| `MULTI_SELECT`、`ARRAY` | JSON 数组(`'["email","postcard"]'`) |
| `RAW_JSON`、`RICH_TEXT` | JSON 对象(`'{"retries":3}'`) |
将该字符串再解析回你所期望的类型:
```ts
const maxCards = Number(process.env.MAX_POSTCARDS); // "10" -> 10
const enabled = process.env.ENABLE_TRACKING === 'true'; // "true" -> true
const channels = JSON.parse(process.env.ENABLED_CHANNELS ?? '[]'); // '["email"]' -> ["email"]
const config = JSON.parse(process.env.PROVIDER_CONFIG ?? '{}'); // '{"retries":3}' -> { retries: 3 }
```
同样适用于通过 `getApplicationVariable('VARIABLE_NAME')` 读取值的前端组件——返回值是字符串;按需进行解析。
## 默认函数角色
使用 [`defineApplicationRole()`](/l/zh/developers/extend/apps/config/roles) 声明的角色控制应用的逻辑函数和前端组件可以访问的内容:
* 注入到您的逻辑函数中的运行时令牌来源于该角色。 以某个人的身份执行的调用会进一步受限于该人能够执行的操作,因此其权限永远不会超过两者中的任一方。 请参阅[调用使用谁的访问权限](/l/zh/developers/extend/apps/logic/logic-functions#whose-access-a-call-uses)。
* 类型化 API 客户端将受限于授予该角色的权限。
* 遵循最小权限原则:只声明你的函数所需的权限。
当你使用脚手架创建新应用时,CLI 会在 `src/roles/default-role.ts` 中创建一个入门角色文件。 完整参考请参见 [角色与权限](/l/zh/developers/extend/apps/config/roles)。
## 应用市场元数据
如果你计划[发布你的应用](/l/zh/developers/extend/apps/operations/publishing),这些可选字段将控制你的应用在应用市场中的展示:
| 字段 | 描述 |
| ------------------ | -------------------------------------------------------------- |
| `作者` | 作者或公司名称 |
| `类别` | 用于应用市场筛选的应用类别 |
| `logo` | 捆绑在`public/`中的应用徽标的路径(例如`public/logo.png`) |
| `galleryImages` | 在`public/`中捆绑的相册图像路径数组(例如`public/screenshot-1.png`) |
| `aboutDescription` | 用于“关于”选项卡的更长的 Markdown 描述。 如果省略,市场将使用该软件包在 npm 上的 `README.md`。 |
| `websiteUrl` | 你的网站链接 |
| `termsUrl` | 服务条款链接 |
| `emailSupport` | 支持电子邮件地址 |
| `issueReportUrl` | 问题跟踪器链接 |
<Note>
`logoUrl`和`screshots`被废弃的 `logo` 和 `GalleryImages` 的别名。 这些字段不支持外部绝对链接 (`http://` 或 `https://`) 。它们会在构建时被丢弃,并附有警告。 将图像捆绑在你的应用的 "public/" 文件夹中。
</Note>

View File

@@ -0,0 +1,188 @@
---
title: 安装钩子
description: 在安装、升级或卸载生命周期中运行逻辑——预置数据、备份记录、验证升级、清理外部资源。
icon: wrench
---
安装钩子是在安装、升级或卸载生命周期期间运行的特殊逻辑函数。 它们与常规的[逻辑函数](/l/zh/developers/extend/apps/logic/logic-functions)共享相同的处理程序运行时,但使用自己的定义函数声明,并且存在于普通触发模型(HTTP、cron、数据库事件)之外。 安装钩子接收一个 `InstallPayload`(`{ previousVersion?: string; newVersion: string }`——在全新安装时 `previousVersion` 为 `undefined`);卸载钩子接收一个 `UninstallPayload`(`{ version?: string }`——要被移除的版本)。
每个应用每种钩子(预安装、后安装、卸载)最多只能定义一个。 如果检测到任一类型多于一个,清单构建将报错。
```
┌─────────────────────────────────────────────────────────────┐
│ install flow │
│ │
│ upload package → [pre-install] → metadata migration → │
│ generate SDK → [post-install] │
│ │
│ old schema visible new schema visible │
└─────────────────────────────────────────────────────────────┘
```
## 一览
| | `definePreInstallLogicFunction` | `definePostInstallLogicFunction` |
| ---- | ------------------------------- | --------------------------------------------------------- |
| 运行 | 元数据迁移之前——**先前**的模式和数据仍然完好无损 | 迁移和 SDK 生成之后——**新的**模式已就位 |
| 执行 | 始终为同步;会阻塞安装 | 默认异步(排队,重试 3 次);可通过 `shouldRunSynchronously: true` 选择同步 |
| 失败时 | 安装在任何模式更改之前被**中止** | 异步:最多重试 3 次。 同步:调用方会收到 `POST_INSTALL_ERROR`(模式更改**不会**回滚) |
| 典型用途 | 备份或修复迁移会丢失的数据;通过抛出异常拒绝存在风险的升级 | 预填充默认数据、配置工作区、注册外部资源 |
**经验法则:** 默认使用 post-install。 仅当迁移本身具有破坏性,且你需要在其丢失之前拦截先前状态时,才使用安装前。
| 你想要... | 使用 |
| ------------------ | ------------------------------------------------ |
| 预填充数据、配置工作区、注册外部资源 | `post-install` |
| 不应阻塞安装响应的长时间运行任务 | `post-install`(默认异步模式,带工作线程重试) |
| 安装返回后调用方会立即依赖的快速设置 | `post-install`,配合 `shouldRunSynchronously: true` |
| 读取或备份即将被迁移丢失的数据 | `pre-install` |
| 拒绝会损坏现有数据的升级 | `pre-install`(从处理程序中抛出异常) |
| 在每次升级时执行对账 | 任一钩子配合 `shouldRunOnVersionUpgrade: true` |
## 两个钩子共享的行为
* 该配置等同于 `defineLogicFunction` 的配置减去触发器设置,再加上 `shouldRunOnVersionUpgrade`。
* **运行时机**:默认情况下,仅在全新安装时运行。 将 `shouldRunOnVersionUpgrade: true` 设为 true 以便在升级时也运行。 使用 `previousVersion` / `newVersion` 按升级路径分支处理。
* **幂等性很重要**:异步 post-install 可能会被重试,而且当开启 `shouldRunOnVersionUpgrade` 时,任一钩子都会在升级时重新运行。
* 会注入常规的逻辑函数环境(`APPLICATION_ID`、`APP_ACCESS_TOKEN`、`API_URL`),因此你可以使用应用的令牌调用 Twenty API。
* 该钩子会在构建时自动附加到应用清单上(`preInstallLogicFunction` / `postInstallLogicFunction`)——在 [`defineApplication()`](/l/zh/developers/extend/apps/config/application) 中无需额外引用。
* 默认的 `timeoutSeconds` 为 300,以便支持更长的设置任务,例如数据填充。
* **在开发模式下不会执行**:`yarn twenty dev` 会跳过安装流程并直接同步文件,因此钩子在其中不会运行。 改为手动触发它们:
```bash filename="Terminal"
yarn twenty dev:function:exec --postInstall
yarn twenty dev:function:exec --preInstall
```
<AccordionGroup>
<Accordion title="definePostInstallLogicFunction" description="在应用工作区元数据迁移之后运行">
在应用完成安装后运行:元数据已同步、SDK 客户端已生成、新模式可被查询。 示例——在全新安装时预填充一个默认记录:
```ts src/logic-functions/post-install.ts
import { definePostInstallLogicFunction, type InstallPayload } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async ({ previousVersion }: InstallPayload): Promise<void> => {
if (previousVersion) return; // fresh installs only
const client = new CoreApiClient();
await client.mutation({
createPostCard: {
__args: { data: { name: 'Welcome to Postcard', content: 'Your first card!' } },
id: true,
},
});
};
export default definePostInstallLogicFunction({
universalIdentifier: 'f7a2b9c1-3d4e-5678-abcd-ef9876543210',
name: 'post-install',
description: 'Seeds a welcome post card after install.',
timeoutSeconds: 300,
shouldRunOnVersionUpgrade: false,
shouldRunSynchronously: false,
handler,
});
```
`shouldRunSynchronously` 标志控制执行模型:
* `false` *(默认)*——放入消息队列(`retryLimit: 3`)并由工作线程运行。 安装响应会在任务被放入队列后立即返回。 **用于长时间运行的任务**——例如预填充大型数据集、调用缓慢的第三方 API。
* `true`——在安装流程中内联执行。 安装请求会阻塞直至处理程序完成;抛出的错误会以 `POST_INSTALL_ERROR` 的形式暴露给调用方(不重试)。 **用于必须在返回响应前完成的快速任务。** 此时迁移已应用,因此失败不会回滚模式更改——只会将错误暴露出来。
</Accordion>
<Accordion title="definePreInstallLogicFunction" description="在应用工作区元数据迁移之前运行">
在元数据迁移之前、针对**先前**模式运行——适合在迁移会删除数据前对其进行备份,或拒绝存在风险的升级。 在执行之前,服务器会运行一次纯增量的“精简同步”,仅注册新版本的 pre-install 函数;当你的处理程序运行时,其他一切——上一版本的对象、字段和数据——都不会被触及。
安装前始终为**同步**,并会阻塞安装。 如果处理程序抛出异常,安装会在任何模式更改之前被中止——工作区将保持在上一版本且处于一致状态。 这是有意为之:安装前是你拒绝高风险升级的最后机会。
示例——在迁移删除旧字段之前复制该旧字段的值:
```ts src/logic-functions/pre-install.ts
import { definePreInstallLogicFunction, type InstallPayload } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async ({ previousVersion, newVersion }: InstallPayload): Promise<void> => {
// Only the 1.x → 2.x upgrade drops the legacy `notes` field.
if (!previousVersion?.startsWith('1.') || !newVersion.startsWith('2.')) {
return;
}
const client = new CoreApiClient();
const { postCards } = await client.query({
postCards: {
__args: { filter: { notes: { isNot: null } } },
edges: { node: { id: true, notes: true } },
},
});
// Copy legacy `notes` into `description` before the migration drops the
// column. If this fails, the upgrade aborts and the workspace stays on v1.
for (const { node } of postCards.edges) {
await client.mutation({
updatePostCard: {
__args: { id: node.id, data: { description: node.notes } },
id: true,
},
});
}
};
export default definePreInstallLogicFunction({
universalIdentifier: 'a1b2c3d4-5678-90ab-cdef-1234567890ab',
name: 'pre-install',
description: 'Backs up legacy notes into description before the v2 migration.',
timeoutSeconds: 300,
shouldRunOnVersionUpgrade: true,
handler,
});
```
</Accordion>
</AccordionGroup>
## 卸载钩子
`defineUninstallLogicFunction` 声明一个在用户卸载你的应用时运行的钩子。 它在应用的元数据、数据和代码被移除之前执行——一旦删除迁移运行完成,就不再有任何内容可供执行——因此你的处理程序仍然可以查询应用的对象和记录。 将其用于清理外部资源:取消预置 API 资源、删除剩余的机器人、撤销网络钩子。
备注:
* 该钩子是尽力执行:它同步运行,但失败会被记录,并且**绝不会阻止卸载**——清理操作绝不能导致应用无法被移除。
* 它接收 `UninstallPayload`(`{ version?: string }`——要被移除的版本)。
* 在回滚失败的全新安装时,它**不会**运行——该应用从未完成安装。
* 钩子无法在应用被删除后运行,因此依赖应用数据的外部清理(例如存储在记录中的机器人 ID)应放在这里,而不是放在外部计划任务中。
* 与安装钩子类似,它在开发模式下**不会执行**——请改为手动触发:
```bash filename="Terminal"
yarn twenty dev:function:exec --uninstall
```
```ts src/logic-functions/uninstall.ts
import { defineUninstallLogicFunction, type UninstallPayload } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async (_payload: UninstallPayload): Promise<void> => {
const client = new CoreApiClient();
const { meetingBots } = await client.query({
meetingBots: { edges: { node: { id: true, externalBotId: true } } },
});
// Delete the provider-side bots so nothing keeps recording after uninstall.
for (const { node } of meetingBots.edges) {
await fetch(`https://api.recorder.example/bots/${node.externalBotId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${process.env.RECORDER_API_KEY}` },
});
}
};
export default defineUninstallLogicFunction({
universalIdentifier: 'b2c3d4e5-6789-01bc-def0-234567890abc',
name: 'uninstall',
description: 'Deletes remaining recorder bots when the app is uninstalled.',
timeoutSeconds: 300,
handler,
});
```

View File

@@ -0,0 +1,56 @@
---
title: 概览
description: 配置应用本身——它的身份、默认权限,以及在安装时运行的内容。
icon: screwdriver-wrench
---
Twenty 应用的 **配置层(config layer)** 用来向 *平台* 描述应用——它的身份、所持有的权限,以及在安装或升级期间运行的代码。 这些声明不会添加新的数据结构或运行时行为;它们告诉 Twenty *这个应用是谁*,以及 *如何完成设置*。
```text
┌────────────────────────────────────────────────────────┐
│ Application — identity, default role, variables, │
│ marketplace metadata │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Role — what the app's logic functions can read │ │
│ │ and write (referenced by Application) │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────┘
│
▼ (at install / upgrade time)
┌──────────────────────────────────┐
│ Pre-install hook │ before metadata migration
└──────────────────────────────────┘
┌──────────────────────────────────┐
│ Post-install hook │ after metadata migration
└──────────────────────────────────┘
│
▼ (at uninstall time)
┌──────────────────────────────────┐
│ Uninstall hook │ before app removal
└──────────────────────────────────┘
```
## 本节内容
<CardGroup cols={2}>
<Card title="应用程序配置" icon="rocket" href="/l/zh/developers/extend/apps/config/application">
`defineApplication` —— 身份、默认角色、变量、应用市场元数据。
</Card>
<Card title="角色和权限" icon="shield-halved" href="/l/zh/developers/extend/apps/config/roles">
`defineRole` —— 声明应用的逻辑函数可以读取和写入什么。
</Card>
<Card title="安装钩子" icon="wrench" href="/l/zh/developers/extend/apps/config/install-hooks">
`definePreInstallLogicFunction`、`definePostInstallLogicFunction` 和 `defineUninstallLogicFunction` —— 备份数据、写入默认值、验证升级、在卸载时清理。
</Card>
</CardGroup>
## 各部分之间的关系
* **Application** 是入口点。 每个应用都有且仅有一次 `defineApplication()` 调用,并将一个 **Role** 作为其默认角色。
* **Role** 控制应用的逻辑函数和前端组件可以读取和写入什么。 遵循最小权限原则:只授予代码实际需要的权限。
* **Install Hooks(安装钩子)** 在安装或升级过程中运行——预安装钩子在元数据迁移之前运行(因此可以拒绝存在风险的升级),后安装钩子在迁移之后运行(因此可以根据新模式写入默认数据)。 卸载钩子会在应用被移除之前立即运行,因此它可以在应用数据仍然可读时清理外部资源。
<Note>
安装钩子与 [logic function](/l/zh/developers/extend/apps/logic/logic-functions) 共用运行时——相同的处理器签名、相同的环境变量、相同的类型化 API 客户端——但它们使用各自的 define 函数声明,并且位于常规触发模型(HTTP、cron、数据库事件)之外。
</Note>

View File

@@ -0,0 +1,67 @@
---
title: 公共资源
description: 通过 public/ 文件夹将静态文件(图像、图标、字体)与应用一起发布。
icon: folder-open
---
应用根目录中的 `public/` 文件夹包含静态文件——图像、图标、字体,或应用在运行时所需的任何其他资源。 这些文件会在构建时自动包含、在开发模式下同步,并上传到服务器。
放置在 `public/` 中的文件:
* **公开可访问**——同步到服务器后,资源将通过公共 URL 提供服务。 访问它们无需身份验证。
* **在前端组件中可用**——使用资源 URL 在 React 组件中显示图像、图标或任何媒体。
* **在逻辑函数中可用**——在电子邮件、API 响应或任何服务端逻辑中引用资源 URL。
* **用于市场元数据**——`defineApplication()` 中的 `logo` 和 `galleryImages` 字段引用此文件夹中的文件(例如,`public/logo.png`)。 应用发布后,这些内容会显示在市场中。 对于这些字段,外部绝对 URL 会被忽略——请改为将图像打包到 `public/` 中。 每个图片文件大小不得超过 10 MB。
* **在开发模式下自动同步**——当在 `public/` 中添加、更新或删除文件时,会自动同步到服务器。 无需重启。
* **包含在构建中**——`yarn twenty dev:build` 会将所有公共资源打包到分发产物中。
## 使用 `getPublicAssetUrl` 访问公共资源
使用来自 `twenty-sdk` 的 `getPublicAssetUrl` 辅助函数获取 `public/` 目录中文件的完整 URL。 它可在 **逻辑函数** 和 **前端组件** 中使用。
**在逻辑函数中:**
```ts src/logic-functions/send-invoice.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { getPublicAssetUrl } from 'twenty-sdk/utils';
const handler = async (): Promise<any> => {
const logoUrl = getPublicAssetUrl('logo.png');
const invoiceUrl = getPublicAssetUrl('templates/invoice.png');
// Fetch the file content (no auth required — public endpoint)
const response = await fetch(invoiceUrl);
const buffer = await response.arrayBuffer();
return { logoUrl, size: buffer.byteLength };
};
export default defineLogicFunction({
universalIdentifier: 'a1b2c3d4-...',
name: 'send-invoice',
description: 'Sends an invoice with the app logo',
timeoutSeconds: 10,
handler,
});
```
**在前端组件中:**
```tsx src/front-components/company-card.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { getPublicAssetUrl } from 'twenty-sdk/utils';
const CompanyCard = () => {
const logoUrl = getPublicAssetUrl('logo.png');
return <img src={logoUrl} alt="App logo" />;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'company-card',
component: CompanyCard,
});
```
`path` 参数是相对于应用的 `public/` 文件夹的。 `getPublicAssetUrl('logo.png')` 和 `getPublicAssetUrl('public/logo.png')` 均解析为相同的 URL——如果存在,`public/` 前缀会被自动移除。

View File

@@ -0,0 +1,203 @@
---
title: 角色和权限
description: 声明你的应用的逻辑函数和前端组件可以读取和写入哪些对象和字段。
icon: shield-halved
---
**角色(role)** 是一组权限:应用可以读取或写入哪些对象,可以查看哪些字段,以及可以使用哪些平台级能力。 每个应用的逻辑函数和前端组件都会继承使用 `defineApplicationRole()` 标记的角色的权限(参见下文的 [默认函数角色](#the-default-function-role))。
```ts src/roles/restricted-company-role.ts
import {
defineRole,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
SystemPermissionFlag,
} from 'twenty-sdk/define';
export default defineRole({
universalIdentifier: '2c80f640-2083-4803-bb49-003e38279de6',
label: 'My new role',
description: 'A role that can be used in your workspace',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
objectPermissions: [
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
fieldPermissions: [
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
fieldUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.name
.universalIdentifier,
canReadFieldValue: false,
canUpdateFieldValue: false,
},
],
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS],
});
```
## 行级安全
对象和字段权限决定某个角色可以访问_哪些对象和字段_。 **行级
权限谓词**更进一步,决定某个角色可以查看和操作_哪些记录_——例如,一个自助式角色,其中每个外部用户只能看到自己的记录。
在角色上使用 `rowLevelPermissionPredicates` 来声明谓词。 与清单的其余部分类似,
每个谓词都有自己的 `universalIdentifier`,并引用一个对象和一个字段(通过它们的 `universalIdentifier`)、一个 `operand`,以及(可选的)在查询时注入其值的 workspaceMember 字段——这样你就可以表达:“记录的所有者关系**是**当前的工作区成员”:
```ts src/roles/partner-role.ts
import {
defineRole,
RowLevelPermissionPredicateOperand,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { ACCOUNT_OWNER_FIELD_UNIVERSAL_IDENTIFIER } from '../fields/account-owner.field';
export default defineRole({
universalIdentifier: 'c3c1dc2e-1a08-4de5-abb7-2139b3d99343',
label: 'Partner',
description: 'External partner — sees only its own records',
canBeAssignedToUsers: true,
objectPermissions: [
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
},
],
rowLevelPermissionPredicates: [
{
universalIdentifier: 'd0f0c1a2-3b4c-4d5e-8f60-111111111111',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
fieldUniversalIdentifier: ACCOUNT_OWNER_FIELD_UNIVERSAL_IDENTIFIER,
operand: RowLevelPermissionPredicateOperand.IS,
workspaceMemberFieldUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.fields.id
.universalIdentifier,
},
],
});
```
由于谓词随清单一起提供,它们会在每次安装和升级时与该角色一起创建、更新和删除——不需要单独的安装后步骤来保持同步。
### 将谓词与组组合使用
默认情况下,角色的谓词通过 `AND` 进行组合。 要将其中一些谓词通过 `OR` 进行组合(或嵌套逻辑),请声明一个 `rowLevelPermissionPredicateGroups` 条目,并通过 `predicateGroupUniversalIdentifier` 将每个谓词指向该条目。 此角色允许合作伙伴查看其**拥有**或其是**联系人**的商机:
```ts src/roles/partner-opportunities-role.ts
import {
defineRole,
RowLevelPermissionPredicateGroupLogicalOperator,
RowLevelPermissionPredicateOperand,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
const OPPORTUNITY = STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity;
const CURRENT_MEMBER =
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.fields.id
.universalIdentifier;
export default defineRole({
universalIdentifier: 'b2a1c0d9-8e7f-4a6b-9c5d-222222222222',
label: 'Partner (opportunities)',
canBeAssignedToUsers: true,
objectPermissions: [
{
objectUniversalIdentifier: OPPORTUNITY.universalIdentifier,
canReadObjectRecords: true,
},
],
rowLevelPermissionPredicateGroups: [
{
universalIdentifier: 'c3b2a1d0-9f8e-4b7a-8d6c-333333333333',
objectUniversalIdentifier: OPPORTUNITY.universalIdentifier,
logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator.OR,
},
],
rowLevelPermissionPredicates: [
{
universalIdentifier: 'd4c3b2a1-0e9f-4c8b-9e7d-444444444444',
objectUniversalIdentifier: OPPORTUNITY.universalIdentifier,
fieldUniversalIdentifier: OPPORTUNITY.fields.owner.universalIdentifier,
operand: RowLevelPermissionPredicateOperand.IS,
workspaceMemberFieldUniversalIdentifier: CURRENT_MEMBER,
predicateGroupUniversalIdentifier: 'c3b2a1d0-9f8e-4b7a-8d6c-333333333333',
},
{
universalIdentifier: 'e5d4c3b2-1f0e-4d9c-8f8e-555555555555',
objectUniversalIdentifier: OPPORTUNITY.universalIdentifier,
fieldUniversalIdentifier:
OPPORTUNITY.fields.pointOfContact.universalIdentifier,
operand: RowLevelPermissionPredicateOperand.IS,
workspaceMemberFieldUniversalIdentifier: CURRENT_MEMBER,
predicateGroupUniversalIdentifier: 'c3b2a1d0-9f8e-4b7a-8d6c-333333333333',
},
],
});
```
备注:
* 为每个谓词和组分配一个稳定的 `universalIdentifier`(任意 uuid)——它在各次升级中作为实体的键,谓词通过它来引用组。
* 谓词可以引用由你的应用拥有的对象和字段,或由 Twenty 的标准对象拥有的对象和字段。
* 行级安全会在包含该功能的套餐中的工作区上强制执行;在其他套餐中谓词仍会同步,只是不会被强制执行。
## 默认函数角色
当你使用脚手架创建新应用时,CLI 会创建一个使用 `defineApplicationRole()` 声明的默认角色文件:
```ts src/roles/default-role.ts
import { defineApplicationRole } from 'twenty-sdk/define';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'b648f87b-1d26-4961-b974-0908fd991061';
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Default function role',
description: 'Default role for function Twenty client',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
objectPermissions: [],
fieldPermissions: [],
permissionFlagUniversalIdentifiers: [],
});
```
`defineApplicationRole()` 是 `defineRole()` 的一个轻量包装,它会标记在应用安装时用作应用默认角色的**该**角色。 验证与 `defineRole` 完全相同,但构建流水线会自动将其 `universalIdentifier` 注入到应用清单的 `defaultRoleUniversalIdentifier` 中——因此你无需在 [`defineApplication`](/l/zh/developers/extend/apps/config/application) 中自行引用它。
备注:
* 每个应用只允许有且仅有 **一个** `defineApplicationRole(...)`——如果发现多于一个,清单构建将失败。
* 对于你的应用随附的任何**额外**角色,请使用 `defineRole()`(而不是 `defineApplicationRole()`)。
* 在 `defineApplication()` 上显式设置 `defaultRoleUniversalIdentifier` 仍然受支持以保持向后兼容性,但已经弃用,推荐使用 `defineApplicationRole()`。
## 最佳实践
* 从脚手架生成的角色开始,然后逐步收紧它的权限——默认角色授予广泛的读取访问权限,而这在生产环境中很少是你想要的。
* 将 `objectPermissions` 和 `fieldPermissions` 替换为你的函数实际需要的精确对象和字段。
* `permissionFlagUniversalIdentifiers` 控制对平台级能力的访问。 尽量保持最小化。
* 查看一个工作示例:[[`exampes/hello-world/src/roles/default-role.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/hello-world/src/roles/default-role.ts)

View File

@@ -0,0 +1,52 @@
---
title: 扩展对象
description: 向标准 Twenty 对象添加字段(Person、Company 等) 或使用 defineField 向其他应用的对象添加字段。
icon: wand-magic-sparkles
---
使用 `defineField()` 为你不拥有的对象添加字段——例如像 Person 或 Company 这样的标准 Twenty 对象,或由其他已安装应用提供的对象。 与在 [`defineObject`](/l/zh/developers/extend/apps/data/objects) 中声明的内联字段不同,独立字段需要一个 `objectUniversalIdentifier` 来指定它们要扩展的对象。
```ts src/fields/company-loyalty-tier.field.ts
import { defineField, FieldType } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: 'f2a1b3c4-d5e6-7890-abcd-ef1234567890',
objectUniversalIdentifier: '701aecb9-eb1c-4d84-9d94-b954b231b64b', // Company object
name: 'loyaltyTier',
type: FieldType.SELECT,
label: 'Loyalty Tier',
icon: 'IconStar',
options: [
{ value: 'BRONZE', label: 'Bronze', position: 0, color: 'orange' },
{ value: 'SILVER', label: 'Silver', position: 1, color: 'gray' },
{ value: 'GOLD', label: 'Gold', position: 2, color: 'yellow' },
],
});
```
## 关键点
* `objectUniversalIdentifier` 用于标识目标对象。 对于标准 Twenty 对象,从 `twenty-sdk` 导入常量:
```ts
import { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
// STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier
// STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier
// STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier
// …
```
* 在 **`defineObject()` 内联定义字段** 时,**不需要** `objectUniversalIdentifier`——它会从父对象继承。
* `defineField()` 是为非通过 `defineObject()` 创建的对象添加字段的唯一方式。
* 文件位置由你决定。 约定是使用 `src/fields/\<name>.field.ts`,但 SDK 会在整个 `src/` 中检测字段。
* `writability` 也可以按字段单独设置:`MetadataWritability.APPLICATION` 将写入限制为你应用自身的逻辑函数,`MetadataWritability.SYSTEM` 保留用于平台管理的数据。 字段的可写性只能比其所属对象的级别更严格,读取操作不会受到影响。 有关完整语义,请参见[对象](/l/zh/developers/extend/apps/data/objects)。
* 要在标准页面布局(例如任务或公司详情页面)中添加一个选项卡,请使用 [`definePageLayoutTab`](/l/zh/developers/extend/apps/layout/page-layouts#definepagelayouttab),并结合来自 `twenty-sdk/define` 的 `STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS`。
## 向现有对象添加关系
要添加关系字段(例如将你的自定义对象链接到标准 `Person`),请将 `defineField()` 与 `FieldType.RELATION` 一起使用。 模式与内联关系相同,但需要显式设置 `objectUniversalIdentifier`。 有关双向模式,请参见[关系](/l/zh/developers/extend/apps/data/relations)。

View File

@@ -0,0 +1,143 @@
---
title: 对象
description: 使用 defineObject 声明新的记录类型——具有其自身字段的自定义表。
icon: table
---
自定义**对象**是你的应用在工作区中添加的新记录类型——Post Card、Invoice、Subscription,或任何特定于你业务领域的内容。 每个对象都会声明其模式(字段、关系、默认值)以及一个在多次同步和部署中保持稳定的通用标识符。
```ts src/objects/post-card.object.ts
import { defineObject, FieldType } from 'twenty-sdk/define';
enum PostCardStatus {
DRAFT = 'DRAFT',
SENT = 'SENT',
DELIVERED = 'DELIVERED',
RETURNED = 'RETURNED',
}
export default defineObject({
universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
nameSingular: 'postCard',
namePlural: 'postCards',
labelSingular: 'Post Card',
labelPlural: 'Post Cards',
description: 'A post card object',
icon: 'IconMail',
fields: [
{
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
name: 'content',
type: FieldType.TEXT,
label: 'Content',
description: "Postcard's content",
icon: 'IconAbc',
},
{
universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac',
name: 'recipientName',
type: FieldType.FULL_NAME,
label: 'Recipient name',
icon: 'IconUser',
},
{
universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266',
name: 'recipientAddress',
type: FieldType.ADDRESS,
label: 'Recipient address',
icon: 'IconHome',
},
{
universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
name: 'status',
type: FieldType.SELECT,
label: 'Status',
icon: 'IconSend',
defaultValue: `'${PostCardStatus.DRAFT}'`,
options: [
{ value: PostCardStatus.DRAFT, label: 'Draft', position: 0, color: 'gray' },
{ value: PostCardStatus.SENT, label: 'Sent', position: 1, color: 'orange' },
{ value: PostCardStatus.DELIVERED, label: 'Delivered', position: 2, color: 'green' },
{ value: PostCardStatus.RETURNED, label: 'Returned', position: 3, color: 'orange' },
],
},
{
universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
name: 'deliveredAt',
type: FieldType.DATE_TIME,
label: 'Delivered at',
icon: 'IconCheck',
isNullable: true,
defaultValue: null,
},
],
});
```
## 关键点
* `universalIdentifier` 必须在各次部署间保持唯一且稳定。
* 每个字段都需要 `name`、`type`、`label` 以及其自身稳定的 `universalIdentifier`。
* `fields` 数组是可选的——你可以定义没有自定义字段的对象。
* `openRecordIn` 设置在点击此对象的记录时,它们会在何处打开:`ObjectOpenRecordIn.USER_CHOICE`(默认,遵循每位工作区成员在 Settings → Experience 中的个人偏好)、`ObjectOpenRecordIn.SIDE_PANEL`,或 `ObjectOpenRecordIn.RECORD_PAGE`。 将其固定为 `RECORD_PAGE`,用于那些需要完整页面才能正常使用的记录(就像工作流和仪表盘那样),或者固定为 `SIDE_PANEL`,用于那些只适合作为快速面板显示的记录(就像日历事件那样)。
* `writability` 控制在角色权限生效之前,哪些主体可以对该对象的记录进行任何写入操作:`MetadataWritability.OPEN`(默认值 — 由工作区角色决定)、`MetadataWritability.APPLICATION`(只有你的应用自身的逻辑函数可以创建、更新或删除记录;将其用于类似配置的对象,这类对象的记录定义行为,因此具有广泛记录访问权限的工作区成员无法通过 API 编辑这些记录)、或者 `MetadataWritability.SYSTEM`(保留用于平台管理的数据)。 读取不受影响 — 这是在服务器端强制执行的,不同于 `isUIEditable`,后者只会隐藏 UI 的可操作入口。 它同样存在于每个字段上,但其限制只能比对象级别更严格。
* 此处定义的内联字段**不**需要 `objectUniversalIdentifier`——它会从父对象继承。 使用[`defineField()`](/l/zh/developers/extend/apps/data/extending-objects)为你不拥有的对象添加字段。
* 你可以使用 `yarn twenty dev:add object` 脚手架创建新对象,它会引导你完成命名、字段和关系。 参见 [Architecture → Scaffolding entities](/l/zh/developers/extend/apps/getting-started/scaffolding)。
<Note>
**基础字段会自动添加。** 当你定义自定义对象时,Twenty 会为你创建标准字段,例如 `id`、`name`、`createdAt`、`updatedAt`、`createdBy`、`updatedBy` 和 `deletedAt`。 你无需在 `fields` 数组中声明这些字段——只需声明你的自定义字段。 你可以通过声明一个同名字段来覆盖默认字段,但这么做通常并不是一个好主意。
</Note>
## 字段类型
从 `twenty-sdk/define` 导出的完整 `FieldType` 值集合:
| 类别 | 类型 |
| ------ | ----------------------------------------------------------------------------------------------------------- |
| 文本 | `TEXT`、`RICH_TEXT`、`ARRAY`(字符串数组)、`RAW_JSON` |
| 数值 | `NUMBER`(`universalSettings.dataType`:`'float'` / `'int'` / `'bigint'`)、`NUMERIC`(任意精度)、`RATING`、`POSITION` |
| 日期 | `DATE`, `DATE_TIME` |
| 选项 | `BOOLEAN`、`SELECT`、`MULTI_SELECT` |
| 复合 | `FULL_NAME`、`ADDRESS`、`EMAILS`、`PHONES`、`LINKS`、`CURRENCY`、`ACTOR`、`FILES` |
| 标识符和关联 | `UUID`、`RELATION`、`MORPH_RELATION`(参见 [Relations](/l/zh/developers/extend/apps/data/relations)) |
| 系统 | `TS_VECTOR`(全文搜索向量,由服务器管理) |
复合类型会存储多个子字段(例如,`FULL_NAME` = 名 + 姓;`CURRENCY` = `amountMicros` + `currencyCode`)。 `SELECT` 和 `MULTI_SELECT` 需要一个如上示例所示的 `options` 数组。
## 默认值
字面量字符串默认值必须在字符串**内部**用单引号包裹——应写成 `defaultValue: "'Draft'"`,而不是 `defaultValue: "Draft"`。 这就是上面的 `status` 字段使用 `` `'${PostCardStatus.DRAFT}'` `` 的原因。
未加引号的字符串保留用于计算得到的默认值,这些默认值会在创建记录时进行求值:
* `'uuid'` — 生成一个 UUID(用于 `UUID` 字段)
* `'now'` — 当前时间戳(用于 `DATE_TIME` 字段)
同样的约定也适用于复合默认值的字符串子字段(例如 `ACTOR` 字段上的 `{ source: "'MANUAL'" }`)以及 `SELECT`/`MULTI_SELECT` 值。 在构建应用时,未加引号的字面字符串默认值会触发警告。
## 可空性
`isNullable` 控制字段是否接受 `NULL`。 它默认是 `true` —— 对可选字段可以省略。 将 `isNullable` 设为 `false`,可在数据库层面将字段设为必填。
对 `isNullable` 的更改会在每次同步时应用,包括更新现有字段的同步——因此你可以通过编辑 manifest 并重新同步来切换字段的可空性。
<Note>
**将现有字段改为不可为空时需要提供默认值。** 当你将字段更改为 `isNullable: false` 时,还必须提供一个非空的 `defaultValue`。 在应用 `NOT NULL` 约束之前,默认值会为所有现有的 `NULL` 行进行回填;如果没有默认值,同步将失败,并出现错误:`Default value cannot be null for non-nullable fields`。 关系字段和 `TS_VECTOR` 字段始终可为空,因此 `isNullable` 对它们不起作用。
</Note>
```ts
{
universalIdentifier: 'b1a7c0de-1234-4f00-9abc-000000000000',
name: 'reference',
type: FieldType.TEXT,
label: 'Reference',
isNullable: false,
defaultValue: "'N/A'",
}
```
## 接下来
* **将此对象与其他对象关联**——关于双向关系模式,参见 [Relations](/l/zh/developers/extend/apps/data/relations)。
* **为其他应用的对象添加字段**——关于 `defineField()`,参见 [Extending Objects](/l/zh/developers/extend/apps/data/extending-objects)。
* **在界面中显示此对象** — 请参阅 [导航菜单项](/l/zh/developers/extend/apps/layout/navigation-menu-items) 以添加侧边栏条目;请参阅 [视图](/l/zh/developers/extend/apps/layout/views) 以添加自定义列表配置。

View File

@@ -0,0 +1,101 @@
---
title: 概览
description: 定义你的应用在工作区中添加的数据结构——对象、字段和关系。
icon: database
---
Twenty 应用的 **数据层(data layer)** 是你的应用*添加*到工作区中的数据——它声明的新记录类型、它为现有对象添加的列,以及这些记录之间如何相互关联。
```text
┌──────────────────────────────────────────────────┐
│ Object — a record type, e.g. PostCard │
│ ├─ Field (name, type, label) │
│ ├─ Field │
│ └─ Relation (link to another object) │
└──────────────────────────────────────────────────┘
│
├── lives in your app, OR
│
▼
┌──────────────────────────────────────────────────┐
│ Standard / other apps' objects │
│ └─ Field added by your app via defineField │
└──────────────────────────────────────────────────┘
```
## 本节内容
<CardGroup cols={2}>
<Card title="对象" icon="table" href="/l/zh/developers/extend/apps/data/objects">
`defineObject` —— 声明具有自有字段的新记录类型。
</Card>
<Card title="扩展对象" icon="wand-magic-sparkles" href="/l/zh/developers/extend/apps/data/extending-objects">
`defineField` —— 向标准对象或其他应用的对象添加字段。
</Card>
<Card title="关系" icon="diagram-project" href="/l/zh/developers/extend/apps/data/relations">
对象之间的双向 `MANY_TO_ONE` / `ONE_TO_MANY` 连接。
</Card>
<Card title="时间线活动类型" icon="clock-rotate-left" href="/l/zh/developers/extend/apps/data/timeline-activity-types">
为记录时间线定义自动审计事件和显式应用事件。
</Card>
</CardGroup>
## 实体一览
| 实体 | 目的 | 定义方式 |
| ----------- | ----------------------------------------------------- | ------------------------------------------- |
| **对象** | 具有自有字段的新自定义记录类型(例如 PostCard、Invoice) | `defineObject()` |
| **字段** | 对象上的一列。 独立字段可以扩展你未创建的对象(例如向 Company 添加 `loyaltyTier`) | `defineField()` |
| **关系** | 两个对象之间的双向链接——双方都声明为字段 | 使用 `defineField()` 并指定 `FieldType.RELATION` |
| **索引** | 用于加速在某个对象上经常执行的查询的数据库索引 | `defineIndex()` |
| **时间线活动类型** | 显示在记录时间线中的自动审计事件或显式域事件 | `defineTimelineActivityType()` |
SDK 会在构建时通过 AST 分析检测这些内容,因此文件组织方式由你决定——约定是使用 `src/objects/`、`src/fields/`、`src/indexes/` 和 `src/timeline-activity-types/`。 稳定的 `universalIdentifier` UUID 在不同部署之间将一切关联在一起。
## 索引(可选)
应用可以随对象一同提供索引,以确保经常执行的查询保持快速。 最常见的情况是某个你经常读取的状态列或外键列。
```ts src/indexes/post-card-status.index.ts
import { defineIndex } from 'twenty-sdk/define';
import {
POST_CARD_UNIVERSAL_IDENTIFIER,
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
} from '../objects/post-card.object';
export default defineIndex({
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff0',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff1',
fieldUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
},
],
});
```
### 唯一索引
`defineIndex` 接受 `isUnique: true`,可同时用于单列和多列表达唯一性约束。 这是推荐的基础方式——`defineField({ isUnique: true })` 已被弃用,并将在未来的版本中移除。
```ts
defineIndex({
universalIdentifier: '…',
objectUniversalIdentifier: PERSON_UNIVERSAL_IDENTIFIER,
isUnique: true,
fields: [{ universalIdentifier: '…', fieldUniversalIdentifier: EMAIL_FIELD_UNIVERSAL_IDENTIFIER }],
});
```
### 其他约束
* 部分 `WHERE` 子句由管理员控制——应用无法声明它们。
* 每个对象最多只能有 10 个自定义索引(框架自身的索引不计入其中)。
按照 Postgres 使用索引的方式来排列 `fields` 数组——最左边的列放在最前面,就像电话簿一样。 索引不是免费的:对表的每一次写入都会更新索引。 只有当你确实有查询需要某个索引时才添加它。
<Note>
在找 **Application Config** 或 **Roles & Permissions** 吗? 这些描述的是应用本身而不是它添加的数据——相关内容位于 [Config](/l/zh/developers/extend/apps/config/overview) 下。 在找 **Connections**(Linear、GitHub、Slack OAuth)吗? 这些用于*从*逻辑函数中调用,并位于 [Logic](/l/zh/developers/extend/apps/logic/connections) 下。
</Note>

View File

@@ -0,0 +1,184 @@
---
title: 关系
description: 使用双向 MANY_TO_ONE / ONE_TO_MANY 关系将对象连接在一起。
icon: diagram-project
---
关系用于将两个对象连接在一起。 在 Twenty 中,关系始终是 **双向的**——每个关系都有两侧,每一侧都作为一个字段声明,并引用另一侧。
| 关系类型 | 描述 | 是否有外键? |
| ------------- | ------------------- | ------------------- |
| `MANY_TO_ONE` | 该对象的多条记录指向目标对象的一条记录 | 是(`joinColumnName`) |
| `ONE_TO_MANY` | 该对象的一条记录拥有目标对象的多条记录 | 否(反向侧) |
## 关系如何工作
每个关系都需要两个相互引用的字段:
1. **MANY_TO_ONE** 侧——位于持有外键的对象上。
2. **ONE_TO_MANY** 侧——位于拥有集合的对象上。
两个字段都使用 `FieldType.RELATION`,并通过 `relationTargetFieldMetadataUniversalIdentifier` 相互交叉引用。
## 示例:Post Card 拥有多个收件人
一个 `PostCard` 可以发送给多个 `PostCardRecipient` 记录。 每个收件人只隶属于一张 PostCard。
**步骤 1:在 PostCard 上定义 ONE_TO_MANY 侧**(“一”侧):
```ts src/fields/post-card-recipients-on-post-card.field.ts
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/post-card-recipient.object';
// Export so the other side can reference it
export const POST_CARD_RECIPIENTS_FIELD_ID = 'a1111111-1111-1111-1111-111111111111';
// Import from the other side
import { POST_CARD_FIELD_ID } from './post-card-on-post-card-recipient.field';
export default defineField({
universalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'postCardRecipients',
label: 'Post Card Recipients',
icon: 'IconUsers',
relationTargetObjectMetadataUniversalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
```
**步骤 2:在 PostCardRecipient 上定义 MANY_TO_ONE 侧**(“多”侧——持有外键):
```ts src/fields/post-card-on-post-card-recipient.field.ts
import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk/define';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/post-card-recipient.object';
// Export so the other side can reference it
export const POST_CARD_FIELD_ID = 'b2222222-2222-2222-2222-222222222222';
// Import from the other side
import { POST_CARD_RECIPIENTS_FIELD_ID } from './post-card-recipients-on-post-card.field';
export default defineField({
universalIdentifier: POST_CARD_FIELD_ID,
objectUniversalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'postCard',
label: 'Post Card',
icon: 'IconMail',
relationTargetObjectMetadataUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.CASCADE,
joinColumnName: 'postCardId',
},
});
```
<Note>
\*\*循环导入:\*\*两个关系字段相互引用对方的 `universalIdentifier`。 为避免循环导入问题,请在各自文件中将字段 ID 作为具名常量导出,并在另一个文件中导入它们。 构建系统会在编译时解析这些引用。
</Note>
## 联结关系
联结对象将一条源记录连接到一条或多条目标记录。 例如,`PostCardRecipient` 可以同时包含 `postCard` 关系和 `recipient` 关系。 在源对象的 `ONE_TO_MANY` 字段上,将 `junctionTargetFieldUniversalIdentifier` 设置为联结对象上指向目标的关系字段:
```ts
export default defineField({
universalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'postCardRecipients',
label: 'Post Card Recipients',
relationTargetObjectMetadataUniversalIdentifier:
POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
junctionTargetFieldUniversalIdentifier: RECIPIENT_FIELD_ID,
},
});
```
`RECIPIENT_FIELD_ID` 必须标识联结对象上的 `MANY_TO_ONE` 或 morph 关系。 当[时间线活动扇出](/l/zh/developers/extend/apps/data/timeline-activity-types#automatic-and-explicit-events)等功能遍历联结时,它会告知 Twenty 哪条相关记录是目标。
## 与标准对象建立关系
要与内置的 Twenty 对象(Person、Company 等)建立关系,请使用 `STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS`:
```ts src/fields/person-on-self-hosting-user.field.ts
import {
defineField,
FieldType,
RelationType,
OnDeleteAction,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { SELF_HOSTING_USER_UNIVERSAL_IDENTIFIER } from '../objects/self-hosting-user.object';
export const PERSON_FIELD_ID = 'c3333333-3333-3333-3333-333333333333';
export const SELF_HOSTING_USER_REVERSE_FIELD_ID = 'd4444444-4444-4444-4444-444444444444';
export default defineField({
universalIdentifier: PERSON_FIELD_ID,
objectUniversalIdentifier: SELF_HOSTING_USER_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'person',
label: 'Person',
description: 'Person matching with the self hosting user',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: SELF_HOSTING_USER_REVERSE_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'personId',
},
});
```
## 关系字段属性
| 属性 | 必填 | 描述 |
| ---------------------------------------------------------- | ------------------ | -------------------------------------------------------------- |
| `type` | 是 | 必须为 `FieldType.RELATION` |
| `relationTargetObjectMetadataUniversalIdentifier` | 是 | 目标对象的 `universalIdentifier` |
| `relationTargetFieldMetadataUniversalIdentifier` | 是 | 目标对象上匹配字段的 `universalIdentifier` |
| `universalSettings.relationType` | 是 | `RelationType.MANY_TO_ONE` 或 `RelationType.ONE_TO_MANY` |
| `universalSettings.onDelete` | 仅适用于 MANY_TO_ONE | 当被引用的记录被删除时的处理方式:`CASCADE`、`SET_NULL`、`RESTRICT` 或 `NO_ACTION` |
| `universalSettings.joinColumnName` | 仅适用于 MANY_TO_ONE | 外键的数据库列名(例如,`postCardId`) |
| `universalSettings.junctionTargetFieldUniversalIdentifier` | 仅限联结 `ONE_TO_MANY` | 联结对象上指向扇出目标的关系字段 |
## 内联关系字段
你也可以直接在 [`defineObject`](/l/zh/developers/extend/apps/data/objects) 中声明关系。 以内联方式声明时,省略 `objectUniversalIdentifier`——它会从父对象继承:
```ts
export default defineObject({
universalIdentifier: '...',
nameSingular: 'postCardRecipient',
// ...
fields: [
{
universalIdentifier: POST_CARD_FIELD_ID,
type: FieldType.RELATION,
name: 'postCard',
label: 'Post Card',
relationTargetObjectMetadataUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.CASCADE,
joinColumnName: 'postCardId',
},
},
// … other fields
],
});
```

View File

@@ -0,0 +1,205 @@
---
title: 定位系统元数据
description: 解析 Twenty 在每个对象上自动预配置的元数据的确定性通用标识符,这样你的应用无需硬编码就能引用它。
icon: gears
---
Twenty 中的每个对象都带有你无需自行声明的**系统元数据**,例如一组字段以及带有列的主列表视图。 当对象被预配置时,服务器会创建所有这些内容,并且随着 Twenty 的发展,这个集合也会随之增长。
由于你不会声明它,因此也就没有可供你导入的 `universalIdentifier` 常量。 相反,服务器会以确定性方式**派生**每个标识符,而 `twenty-sdk` 暴露了相同的派生逻辑,这样你的 manifest 就能解析出服务器实际使用的精确值。
## 系统字段
存在于每个对象上的标量字段,你不会使用 [`defineField()`](/l/zh/developers/extend/apps/data/extending-objects) 来声明其中任何一个:
`id`, `createdAt`, `updatedAt`, `deletedAt`, `createdBy`, `updatedBy`, `position`, `searchVector`
那么,如何在[视图](/l/zh/developers/extend/apps/layout/views)中将 `createdAt` 作为一列来引用呢?
### 问题
从 Twenty 2.19 起,系统字段的通用标识符由服务器根据三个输入**确定性派生**:应用通用标识符、对象通用标识符以及字段名称。 自行构造一个 id 并将其硬编码是行不通的:它在服务器上不会匹配任何内容,同步时会拒绝这个悬空引用:
```
Dev sync failed: viewField: INVALID_VIEW_DATA: Field metadata not found
```
### 解决方案
<Note>
`getFieldUniversalIdentifier` 从 `twenty-sdk` 2.21 版本开始可用。
</Note>
使用 `getFieldUniversalIdentifier` 来解析与服务器使用的值完全相同的字段标识符。 它接收这三个输入,并返回字段的通用标识符:
```ts
import { getFieldUniversalIdentifier } from 'twenty-sdk/define';
const createdAtFieldId = getFieldUniversalIdentifier({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: MY_OBJECT_UNIVERSAL_IDENTIFIER,
name: 'createdAt',
});
```
* `applicationUniversalIdentifier` 是你的应用标识符,也就是你传递给 [`defineApplication()`](/l/zh/developers/extend/apps/config/application) 的那个。
* `objectUniversalIdentifier` 是该字段所属对象的标识符。
* `name` 是系统字段名称,为上面列出的值之一。
### 示例:视图中的 createdAt 列
典型场景是:为你某个自定义对象的视图添加一列 `createdAt`。 解析字段 id,并像引用其他 `fieldMetadataUniversalIdentifier` 一样引用它:
```ts src/views/example-view.ts
import {
defineView,
getFieldUniversalIdentifier,
} from 'twenty-sdk/define';
const APPLICATION_UNIVERSAL_IDENTIFIER =
'0b04e15c-27b2-4741-9046-b32e07469072';
const MY_OBJECT_UNIVERSAL_IDENTIFIER =
'c782b61c-70fd-4c88-9cd6-4e61ab8d7591';
export default defineView({
universalIdentifier: '70f10d44-144a-4da8-8c6f-3ec2422138c0',
name: 'All records',
objectUniversalIdentifier: MY_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconList',
position: 0,
fields: [
{
universalIdentifier: '75a90bc4-d901-4df4-85e0-af29db5e0104',
fieldMetadataUniversalIdentifier: getFieldUniversalIdentifier({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: MY_OBJECT_UNIVERSAL_IDENTIFIER,
name: 'createdAt',
}),
position: 0,
isVisible: true,
size: 200,
},
],
});
```
同一个解析得到的 id 可在任何需要 `fieldMetadataUniversalIdentifier` 的地方使用:视图字段、筛选器、排序、分组以及页面布局挂件。
<Note>
解析 id,而不是将其硬编码。 由于服务器是根据应用 id、对象 id 和字段名来派生该值的,调用
`getFieldUniversalIdentifier` 能在这些输入发生变化时仍保持引用正确,并在派生逻辑演进时避免偏差。
</Note>
### 系统关系字段
<Note>
`getSystemRelationFieldUniversalIdentifier` 可在 `twenty-sdk`
2.23 及更高版本中使用,并且需要 Twenty 服务器版本为 2.23 或更高。
</Note>
除了上面的标量系统字段之外,服务器还会在每个对象上预置四个**系统关系字段**:`timelineActivities`、`attachments`、`noteTargets` 和 `taskTargets`,它们各自指向相应的标准关系对象。
这些字段不会通过 `getFieldUniversalIdentifier` 解析:其标识符不依赖名称,而是由承载该字段的对象和该字段指向的对象推导而来。 通过这种方式,重命名对象永远不会改变其关系字段的标识符。
使用 `getSystemRelationFieldUniversalIdentifier` 来解析它们:
```ts
import {
getSystemRelationFieldUniversalIdentifier,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
// rocket.attachments — the relation field hosted on your custom object
const rocketAttachmentsFieldId = getSystemRelationFieldUniversalIdentifier({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: ROCKET_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetObjectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.attachment.universalIdentifier,
});
```
* `objectUniversalIdentifier` 是**承载**该字段的对象。
* `relationTargetObjectUniversalIdentifier` 是该字段**指向**的对象。
方向由参数的顺序编码。 要解析反向端(例如 `attachment.targetRocket`,即服务器在标准关系对象上创建的 morph 字段),交换这两个参数:
```ts
// attachment.targetRocket — the reverse morph field on Attachment
const attachmentTargetRocketFieldId =
getSystemRelationFieldUniversalIdentifier({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.attachment.universalIdentifier,
relationTargetObjectUniversalIdentifier: ROCKET_OBJECT_UNIVERSAL_IDENTIFIER,
});
```
与标量系统字段一样,解析得到的 id 可在任何需要 `fieldMetadataUniversalIdentifier` 的地方使用。
## 系统视图
<Note>
`getSystemViewUniversalIdentifier` 和 `getSystemViewFieldUniversalIdentifier` 可在 `twenty-sdk` 2.26 及更高版本中使用,并且需要 Twenty 服务器版本为 2.26 或更高。
</Note>
服务器还会在每个对象上预配置一个**系统视图**:主列表视图(`All {objectLabelPlural}`,以 `ViewKey.INDEX`(由 `SYSTEM_VIEW_KEYS.INDEX` 派生)作为键),其中每个可显示字段对应一列。 与系统关联字段类似,它们的标识符是以**与名称无关**的方式派生的,因此重命名对象或字段都不会改变这些标识符。
使用 `getSystemViewUniversalIdentifier` 来解析该视图:
```ts
import {
getSystemViewUniversalIdentifier,
SYSTEM_VIEW_KEYS,
} from 'twenty-sdk/define';
const rocketIndexViewId = getSystemViewUniversalIdentifier({
objectMetadataApplicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: ROCKET_OBJECT_UNIVERSAL_IDENTIFIER,
viewKey: SYSTEM_VIEW_KEYS.INDEX,
});
```
* `objectMetadataApplicationUniversalIdentifier` 是拥有该**对象**的应用程序,也是视图命名空间所基于的应用。
* `objectUniversalIdentifier` 是该视图所列出的对象。
* `viewKey` 是系统视图的区分符:主列表视图使用 `SYSTEM_VIEW_KEYS.INDEX`,记录页面字段小部件视图使用 `SYSTEM_VIEW_KEYS.FIELDS_WIDGET`。 它作为派生的键;只有 `INDEX` 同时会存储在视图行中。
解析得到的 id 可用于任何需要 `viewUniversalIdentifier` 的地方,例如侧边栏中的 [`NavigationMenuItemType.VIEW`](/l/zh/developers/extend/apps/layout/navigation-menu-items) 条目。 如果只是要打开某个对象的主列表,优先使用带有 `targetObjectUniversalIdentifier` 的 `NavigationMenuItemType.OBJECT`:它不需要派生。
`getSystemViewFieldUniversalIdentifier` 会从视图以及其显示的字段中解析系统视图上的单个**列**:
```ts
import { getSystemViewFieldUniversalIdentifier } from 'twenty-sdk/define';
const rocketNameColumnId = getSystemViewFieldUniversalIdentifier({
fieldMetadataApplicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
viewUniversalIdentifier: rocketIndexViewId,
fieldMetadataUniversalIdentifier: ROCKET_NAME_FIELD_UNIVERSAL_IDENTIFIER,
});
```
注意第一个参数:列是由**其显示的字段**所属的应用程序进行命名空间划分的,而不是由拥有该视图的应用程序进行划分。 你的应用向标准对象添加的字段,会在 Twenty 拥有的视图上,以你的应用为命名空间来派生其列。
<Warning>
系统视图及其列是由**服务器所有**的:解析它们的标识符是为了引用它们,而不是为了声明它们。 [`defineView()`](/l/zh/developers/extend/apps/layout/views) 上的 `key` 已被弃用且会被忽略,因此 manifest 视图永远无法占用 `INDEX` 键,并且服务器已经为你添加的每个字段预配置了一列,所以在系统视图上为同一个字段声明你自己的 `defineViewField()` 会与其发生冲突。
</Warning>
## 标准 Twenty 对象
对于**标准** Twenty 对象(Person、Company、Opportunity 等),你不需要进行任何派生:字段和视图的标识符都是预先计算好的常量,你可以直接导入。
```ts
import { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
// STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.createdAt.universalIdentifier
// STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.fields.updatedAt.universalIdentifier
// STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.views.allPeople.universalIdentifier
```
当对象是由**你的应用**使用 [`defineObject()`](/l/zh/developers/extend/apps/data/objects) 定义、且不存在此类常量时,再使用上面的辅助方法。
<Note>
`name` 是一个**默认**字段,而不是系统字段。 它保留自己的硬编码
通用标识符,而不是通过
`getFieldUniversalIdentifier` 解析。 在你定义的对象上,请使用你在 `defineObject()` 中赋予它的标识符来引用
`name` 字段。
</Note>

View File

@@ -0,0 +1,184 @@
---
title: "时间线活动类型们。 "
description: 定义在记录时间线上呈现的自动审计事件和显式应用事件。
icon: clock-rotate-left
---
时间线活动类型定义了在记录时间线上显示的事件所使用的稳定词汇。 标准对象和应用对象使用相同的约定:类型具有标签和图标,可选择声明其发出时间,还可选择通过你的应用的[前端组件](/l/zh/developers/extend/apps/layout/front-components)之一进行呈现。
<Note>
时间线活动类型目前处于 Beta 阶段,将很快在 Twenty 2.34 中推出。 该
API 可能会随着我们了解应用开发者的使用场景而演进。
</Note>
使用脚手架工具创建一个:
```bash filename="Terminal"
yarn twenty dev:add timelineActivityType
```
或直接定义:
```ts filename="src/timeline-activity-types/post-card-created.ts"
import { defineTimelineActivityType } from 'twenty-sdk/define';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
export default defineTimelineActivityType({
universalIdentifier: 'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7500',
name: 'postCardCreated',
label: 'created a post card',
icon: 'IconMail',
emit: {
on: 'created',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
},
});
```
## 自动事件和显式事件
当 Twenty 应自动创建此类型时,添加 `emit`。 `emit.on` 支持 `created`、`updated`、`deleted`、`restored`、`linked` 和 `unlinked`,而 `emit.objectUniversalIdentifier` 用于标识源对象。 在一个工作区中,只有一个有效类型可以处理相同的 emit 键——`on`、对象和可选的通过关系。
如果没有 `emit.through`,事件将写入源记录自身的时间线。 要让它发到相关的记录,请设置 `emit.access'. elationFieldUniversalIdentifier` 指向一个直接的多对一个关系或源对象上的[一个对多的交接关系](/l/zh/developers/extend/apps/data/relations#junction-relations)。 变形关系向其摩擦组的每个成员粉碎,因此单一声明可以针对几个对象类型。
就直接关系而言,创建或恢复源记录产生`链接`,删除它产生`unlined`, 并重新指出关系会在前一个目标上产生`unlinked`加上新目标上的`linked`。 其他源更新不会产生链接事件。 这是附加物所使用的合同,其目标是一种直接的停尸关系。
对于联接关系,`universalSettings.crosstionTargetFieldUniversalIdentifier` 可以识别联接对象与目标之间的关系。 创建或删除一个命令行会产生`linked`或`unlinked`。 指明一个联接行的任何一方也会产生一个联接事件;不相关联的联接字段的更新则不会产生联接事件。
`linked`和`unlinked`事件需要`emit.that`, 因为它们的触发是对配置的直接关系或交叉关系的改变。
例如,这是备注、任务、消息和日历事件所使用的同一通用约定:
```ts filename="src/timeline-activity-types/post-card-linked.ts"
import { defineTimelineActivityType } from 'twenty-sdk/define';
import { POST_CARD_RECIPIENTS_FIELD_UNIVERSAL_IDENTIFIER } from '../fields/post-card-recipients-on-post-card.field';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
export default defineTimelineActivityType({
universalIdentifier: 'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7502',
name: 'postCardLinked',
label: 'received a post card',
icon: 'IconMail',
emit: {
on: 'linked',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
through: {
relationFieldUniversalIdentifier:
POST_CARD_RECIPIENTS_FIELD_UNIVERSAL_IDENTIFIER,
},
},
});
```
默认情况下,每次源更新都会发出一个 `updated` 通过事件。 当只有选定源字段的更改应显示在目标时间线上时,设置 `emit.through.triggerFieldUniversalIdentifiers`。
对于由你的逻辑函数自行创建的显式领域事件,请省略 `emit`。 这样可避免针对同一操作同时生成自动审计行和显式行。
```ts filename="src/timeline-activity-types/post-card-sent.ts"
import { defineTimelineActivityType } from 'twenty-sdk/define';
export default defineTimelineActivityType({
universalIdentifier: 'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7501',
name: 'postCardSent',
label: 'sent a post card',
icon: 'IconSend',
});
```
使用 [`createTimelineActivity()`](/l/zh/developers/extend/apps/logic/logic-functions#create-a-timeline-activity) 创建显式事件。 应用代码使用稳定的通用标识符;Twenty 会解析特定于安装的元数据 ID。
## 自定义呈现
如果没有前端组件,Twenty 会根据类型标签、图标和关联对象元数据呈现原生通用行。 这适用于没有对象专用渲染器的标准对象和自定义对象。
如需自定义详细信息,请将 `frontComponentUniversalIdentifier` 设置为由同一应用拥有的前端组件:
```ts
export default defineTimelineActivityType({
universalIdentifier: 'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7501',
name: 'postCardSent',
label: 'sent a post card',
icon: 'IconSend',
frontComponentUniversalIdentifier: '88c15ae2-5f87-4a6b-b48f-1974bbe62eb7',
});
```
原生行仍保持为折叠状态下的呈现形式。 Twenty 仅在用户展开该行后才挂载前端组件,从而避免为每个可见事件创建沙盒和工作线程。 在组件内部,从 `twenty-sdk/front-component` 调用 `useTimelineActivityId()` 以读取行 ID,并获取呈现所需的任何数据。 当组件在时间线行外部呈现时,它会返回 `null`。
## 覆盖其他应用的呈现方式
应用拥有其自身对象的自动时间线约定。 要自定义由其他应用拥有的对象上的事件,请使用 `replacesTimelineActivityTypeUniversalIdentifier` 显式声明现有类型:
```ts
export default defineTimelineActivityType({
universalIdentifier: 'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7503',
name: 'companyCreatedWithDeploymentContext',
label: 'was created from a deployment',
emit: {
on: 'created',
objectUniversalIdentifier: COMPANY_UNIVERSAL_IDENTIFIER,
},
replacesTimelineActivityTypeUniversalIdentifier:
RECORD_CREATED_TIMELINE_ACTIVITY_TYPE_UNIVERSAL_IDENTIFIER,
frontComponentUniversalIdentifier: '88c15ae2-5f87-4a6b-b48f-1974bbe62eb7',
});
```
覆盖必须包含 `emit`,因为它替换的是现有的自动发出槽位,而不是仅显式类型。 被引用的类型必须属于目标对象的应用,并描述相同的操作和路由。 移除覆盖应用会恢复基础类型;移除基础类型会禁用覆盖。
## 工作区覆盖和静默
工作区管理员无需分叉应用即可更改类型的呈现方式,或将其自动和显式事件设为静默。 使用元数据 API 的 `updateTimelineActivityType` 变更,并传入特定于安装的类型 ID:
```graphql
mutation CustomizeTimelineActivityType(
$id: UUID!
$label: String
$icon: String
$isActive: Boolean
) {
updateTimelineActivityType(
input: { id: $id, label: $label, icon: $icon, isActive: $isActive }
) {
id
label
icon
isActive
}
}
```
`label` 和 `icon` 会存储为工作区覆盖,因此之后的应用更新不会覆盖管理员的选择。 将 `isActive` 设为 `false` 可停止自动发出,并拒绝该类型的新显式事件。 现有行仍然可见。
使用以下方式恢复应用默认值,包括活动状态:
```graphql
mutation ResetTimelineActivityType($id: UUID!) {
resetTimelineActivityType(id: $id) {
id
label
icon
isActive
}
}
```
## 配置字段
| 字段 | 必填 | 描述 |
| ------------------------------------------------- | ------- | ---------------------- |
| `universalIdentifier` | 是 | 跨安装和升级保持稳定的类型 UUID |
| `name` | 是 | 应用内本地编程名称;在应用内必须唯一 |
| `label` | 是 | 由原生渲染器使用的面向用户的操作文本 |
| `icon` | 否 | 显示在事件旁的 Twenty 图标名称 |
| `emit` | 否 | 自动发出声明;对于仅显式类型则省略 |
| `emit.on` | 使用 emit | 导致写入此类型的审计操作 |
| `emit.objectUniversalIdentifier` | 使用 emit | 其记录会发出此类型的对象 |
| `emit.through.relationFieldUniversalIdentifier` | 否 | 用于切换到相关时限的直接或交接关系 |
| `emit.through.triggerFieldUniversalIdentifiers` | 否 | 可触发 `updated` 通过事件的源字段 |
| `frontComponentUniversalIdentifier` | 否 | 当行扩展时挂载应用前台组件 |
| `replacesTimelineActivityTypeUniversalIdentifier` | 否 | 现有的发射类型已替换;需要 `emit` |
当创建事件时,二十张快照会出现语义特征和持久演示效果的退缩。 历史行保留其原始动作和对象含义。 此类型已安装,其当前翻译的标签、图标和前面组件正在运行;卸载后,快照回退将保持行可读。 分辨率使用通用标识符,因此在重新安装应用时也可以存活。

View File

@@ -0,0 +1,102 @@
---
title: 概念
description: Twenty 应用的工作方式 — 实体模型、沙盒机制和安装生命周期。
icon: sitemap
---
Twenty 应用是 TypeScript 包,可通过自定义对象、逻辑、UI 组件和 AI 能力扩展你的工作区。 它们在 Twenty 平台上运行,具备完备的沙盒与权限控制。
## 应用如何运作
应用是由 `twenty-sdk` 包中的 `defineEntity()` 函数声明的**实体**集合。 SDK 在构建时通过 AST 分析检测到这些声明,并生成一份**清单**——完整描述你的应用为工作区新增的内容。 这些函数会在构建时校验你的配置,并提供 IDE 自动补全和类型安全。
```
your-app/
├── src/
│ ├── application-config.ts ← defineApplication (required, one per app)
│ ├── roles/ ← defineRole
│ ├── objects/ ← defineObject
│ ├── fields/ ← defineField
│ ├── logic-functions/ ← defineLogicFunction
│ ├── front-components/ ← defineFrontComponent
│ ├── skills/ ← defineSkill
│ ├── agents/ ← defineAgent
│ ├── views/ ← defineView
│ ├── navigation-menu-items/ ← defineNavigationMenuItem
│ └── page-layouts/ ← definePageLayout
├── public/ ← Static assets (images, icons)
└── package.json
```
<Note>
**文件组织由你决定。** 实体检测基于 AST——无论文件位于何处,SDK 都能找到 `export default defineEntity(...)` 的调用。 上述文件夹结构是一种约定,而非强制要求。
</Note>
## 实体类型
| 实体 | 目的 | 文档 |
| --------- | --------------------------------- | ----------------------------------------------------------------------------------------------------- |
| **应用程序** | 应用标识、默认角色、变量 | [应用配置](/l/zh/developers/extend/apps/config/application) |
| **角色** | 对象和字段的权限集 | [角色与权限](/l/zh/developers/extend/apps/config/roles) |
| **对象** | 带字段的自定义记录类型 | [对象](/l/zh/developers/extend/apps/data/objects) |
| **字段** | 从其他应用向对象添加字段 | [扩展对象](/l/zh/developers/extend/apps/data/extending-objects) |
| **关系** | 对象之间的双向链接 | [关系](/l/zh/developers/extend/apps/data/relations) |
| **逻辑函数** | 带触发器的服务端 TypeScript | [逻辑函数](/l/zh/developers/extend/apps/logic/logic-functions) |
| **技能** | 可复用的 AI 代理指令 | [技能与代理](/l/zh/developers/extend/apps/logic/skills-and-agents) |
| **代理** | 具有自定义提示词的 AI 助手 | [技能与代理](/l/zh/developers/extend/apps/logic/skills-and-agents) |
| **连接提供方** | 用于第三方 API 的 OAuth 凭证 | [连接](/l/zh/developers/extend/apps/logic/connections) |
| **视图** | 预配置的记录列表视图 | [视图](/l/zh/developers/extend/apps/layout/views) |
| **导航菜单项** | 自定义侧边栏条目 | [导航菜单项](/l/zh/developers/extend/apps/layout/navigation-menu-items) |
| **页面布局** | 记录详情页上的选项卡和小部件 | [页面布局](/l/zh/developers/extend/apps/layout/page-layouts) |
| **前端组件** | Twenty 内的沙盒化 React UI | [前端组件](/l/zh/developers/extend/apps/layout/front-components) |
| **共享依赖项** | 由应用的前端组件共享的依赖项,在 package.json 中声明 | [共享依赖项](/l/zh/developers/extend/apps/layout/front-components#sharing-dependencies-across-front-components) |
| **命令菜单项** | 快速操作和 Cmd+K 条目 | [命令菜单项](/l/zh/developers/extend/apps/layout/command-menu-items) |
## 沙盒化
* **逻辑函数** 在服务器上的独立 Node.js 进程中运行。 它们仅通过类型化 API 客户端访问数据,该客户端会以触发运行的人员或应用本身的身份执行操作。 请参阅[调用使用谁的访问权限](/l/zh/developers/extend/apps/logic/logic-functions#whose-access-a-call-uses)。
* **前端组件** 在使用 Remote DOM 的 Web Worker 中运行——与主页面沙盒隔离,但渲染原生 DOM 元素(非 iframe)。 它们通过消息传递的宿主 API 与 Twenty 通信。
* **权限** 在 API 层面强制执行。 系统会注入两个运行时令牌,二者均派生自通过 `defineApplicationRole()` 声明的角色;只要运行由人员触发,默认令牌的权限范围就会进一步缩小为该触发人员可执行的操作。
## 应用生命周期
```
┌─────────────────────────────────────────────────────────┐
│ Development │
│ npx create-twenty-app → yarn twenty dev (live sync) │
├─────────────────────────────────────────────────────────┤
│ Build & Deploy │
│ yarn twenty dev:build → yarn twenty app:publish │
├─────────────────────────────────────────────────────────┤
│ Install flow │
│ upload → [pre-install] → metadata migration → │
│ generate SDK → [post-install] → … → [uninstall] │
├─────────────────────────────────────────────────────────┤
│ Publish │
│ npm publish → appears in Twenty marketplace │
└─────────────────────────────────────────────────────────┘
```
* **`yarn twenty dev`** — 监视你的源文件,并将更改实时同步到已连接的 Twenty 服务器。 当模式发生变化时,会自动重新生成类型化的 API 客户端。
* **`yarn twenty dev:build`** — 编译 TypeScript,使用 esbuild 打包逻辑函数和前端组件,并生成清单。
* **预/后安装和卸载钩子** — 在安装过程中或在卸载前立即运行的可选函数。 详见 [安装钩子](/l/zh/developers/extend/apps/config/install-hooks)。
## 后续步骤
<CardGroup cols={2}>
<Card title="配置" icon="screwdriver-wrench" href="/l/zh/developers/extend/apps/config/overview">
应用标识、默认角色,以及安装和卸载钩子。
</Card>
<Card title="数据" icon="database" href="/l/zh/developers/extend/apps/data/overview">
对象、字段和双向关系。
</Card>
<Card title="逻辑" icon="bolt" href="/l/zh/developers/extend/apps/logic/overview">
逻辑函数、技能、代理和 OAuth 连接。
</Card>
<Card title="布局" icon="table-columns" href="/l/zh/developers/extend/apps/layout/overview">
视图、导航、页面布局、前端组件。
</Card>
<Card title="操作" icon="rocket" href="/l/zh/developers/extend/apps/operations/overview">
CLI、测试、远程、CI,以及发布你的应用。
</Card>
</CardGroup>

View File

@@ -0,0 +1,87 @@
---
title: 本地服务器
description: 管理本地 Twenty Docker 服务器 — 启动、停止、升级、并行测试实例,以及手动设置 SDK。
icon: server
---
## 管理本地服务器
使用 `yarn twenty docker:*` 控制本地的 Twenty 容器:
| 命令 | 作用 |
| -------------------------------------- | ------------------------- |
| `yarn twenty docker:start` | 启动服务器(按需拉取镜像) |
| `yarn twenty docker:start 2.2.0` | 启动指定的服务器版本 |
| `yarn twenty docker:start --port 3030` | 在自定义端口启动 |
| `yarn twenty docker:stop` | 停止服务器(保留数据) |
| `yarn twenty docker:status` | 显示 URL、版本和登录凭据 |
| `yarn twenty docker:logs` | 流式输出服务器日志 |
| `yarn twenty docker:reset` | 清空数据并全新开始 |
| `yarn twenty docker:upgrade` | 拉取最新的 `twenty-app-dev` 镜像 |
| `yarn twenty docker:upgrade 2.2.0` | 升级到指定版本 |
数据在重启后会保留,存储于两个 Docker 卷中(`twenty-app-dev-data` 用于 PostgreSQL,`twenty-app-dev-storage` 用于文件)。 使用 `reset` 清空所有内容。
## 固定服务器版本
当未传入版本时,`docker:start` 会根据你的应用在 `package.json` 中的 `engines.twenty` 范围解析版本 —— 这与在安装你的应用时服务器用于校验的范围相同。 它会启动满足该范围的最新已发布 `twenty-app-dev` 镜像,当该字段缺失或没有已发布版本匹配时,则回退到 `latest`:
```json filename="package.json"
{
"engines": {
"twenty": ">=2.2.0"
}
}
```
显式传入一个版本以在单次运行中覆盖该范围:`yarn twenty docker:start 2.3.0`。 如果已有容器使用的是不同版本,`docker:start` 会就地升级它(在保留你的数据卷的同时重新创建容器)。
## 升级服务器镜像
`yarn twenty docker:upgrade` 将拉取最新镜像、比较摘要,并且仅在确有变更时才重新创建容器。 数据卷将被保留——只会替换容器。 如果已拉取新镜像且容器正在运行,升级会自动启动一个新容器;之后运行 `yarn twenty docker:start` 以等待其变为健康状态。
```bash filename="Terminal"
yarn twenty docker:upgrade # Latest
yarn twenty docker:upgrade 2.2.0 # Specific version
```
使用 `yarn twenty docker:status` 验证正在运行的版本(它会显示写入容器的 `APP_VERSION`)。
## 运行并行测试实例
向任意 `docker:*` 命令传递 `--test` 以管理第二个、完全隔离的实例——这有助于在不影响主开发数据的情况下进行集成测试或试验:
| 命令 | 作用 |
| ----------------------------------- | ------------------- |
| `yarn twenty docker:start --test` | 启动测试实例 (默认端口为 2021) |
| `yarn twenty docker:stop --test` | 停止它 |
| `yarn twenty docker:status --test` | 显示其状态 |
| `yarn twenty docker:logs --test` | 流式输出其日志 |
| `yarn twenty docker:reset --test` | 清空其数据 |
| `yarn twenty docker:upgrade --test` | 升级其镜像 |
测试实例有其自己的容器(`twenty-app-dev-test`)、卷(`twenty-app-dev-test-data`、`twenty-app-dev-test-storage`)和配置——它可与你的主实例并行运行且不会发生冲突。 将 `--test` 与 `--port` 组合使用以覆盖 2021 端口。
## 手动设置(不使用脚手架)
如果你要将 SDK 添加到现有项目中,可跳过脚手架:
```bash filename="Terminal"
yarn add twenty-sdk twenty-client-sdk
```
在 `package.json` 中添加该脚本:
```json filename="package.json"
{
"scripts": {
"twenty": "twenty"
}
}
```
现在你可以运行 `yarn twenty dev`、`yarn twenty docker:start`,以及其他命令。
<Note>
不要全局安装 `twenty-sdk` —— 在每个项目中固定其版本,使每个应用都使用各自的版本。
</Note>

View File

@@ -0,0 +1,79 @@
---
title: 项目结构
description: 脚手架生成的 Twenty 应用中包含哪些内容——文件、文件夹以及它们各自的作用。
icon: folder-tree
---
由 `npx create-twenty-app` 生成的新应用看起来是这样的:
```text filename="my-twenty-app/"
my-twenty-app/
package.json
src/
application-config.ts # Required — your app's entry point
default-role.ts # Permissions for logic functions
constants/
universal-identifiers.ts # Auto-generated UUIDs and metadata
front-components/
main-page.tsx # Welcome page component
navigation-menu-items/
main-page.navigation-menu-item.ts # Sidebar entry for the welcome page
page-layouts/
main-page.page-layout.ts # Standalone page hosting the component
__tests__/
application-config.test.ts # Unit test
global-setup.ts # Integration test setup (sync + uninstall)
schema.integration-test.ts # Integration test against a live server
.github/workflows/
ci.yml # Lint, typecheck, unit + integration tests
cd.yml # Deploy + install on push to main
publish.yml # Publish to npm on version tags (with provenance)
public/
logo.svg # Static assets
vitest.config.ts # Integration test runner config
vitest.unit.config.ts # Unit test runner config
tsconfig.json, tsconfig.spec.json
.nvmrc, .yarnrc.yml, .oxlintrc.json
README.md, AGENTS.md, CLAUDE.md, CHANGELOG.md, SETUP.md
```
## 关键文件
| 文件 / 文件夹 | 目的 |
| -------------------------------------------------------------------------- | --------------------------------------- |
| `src/application-config.ts` | **必需。** 应用的主配置文件。 |
| `src/default-role.ts` | 默认角色,用于控制你的逻辑函数可访问的内容。 |
| `src/constants/universal-identifiers.ts` | 自动生成的 UUID 和元数据(显示名称、描述)。 |
| `src/front-components/`, `src/navigation-menu-items/`, `src/page-layouts/` | 一个入门欢迎页面:由独立页面布局渲染的前端组件,可从侧边栏访问。 |
| `src/__tests__/` | 一个单元测试加一个集成测试(带有其全局设置),用于将应用与真实服务器进行同步。 |
| `public/` | 随应用一起提供的静态资源(图像、字体)。 |
| `AGENTS.md` / `CLAUDE.md` | 为在该应用上工作的 AI 编码代理提供指导。 |
| `CHANGELOG.md` / `SETUP.md` | 显著更改的更新日志以及用于本地开发的设置说明。 |
<Note>
**文件组织由你决定。** 上述文件夹只是约定——SDK 通过对 `export default defineEntity(...)` 调用进行 AST 分析来检测实体,而不受文件所在位置影响。
</Note>
## 依赖项
这两个 Twenty SDK 软件包都应放在 `devDependencies` 下,而不是 `dependencies`:
```json filename="package.json"
{
"dependencies": {},
"devDependencies": {
"twenty-client-sdk": "2.20.0",
"twenty-sdk": "2.20.0",
"twenty-ui": "1.0.0-alpha.1"
}
}
```
脚手架工具将 `twenty-sdk` 和 `twenty-client-sdk` 固定为与自身相同的版本——升级时请保持这两者同步。
* **`twenty-sdk`** 提供 `twenty` CLI 以及构建/脚手架工具。 它只在开发和构建阶段运行,且永远不会在已发布应用的运行时环境中被导入。
* **`twenty-client-sdk`** 会被你的应用代码(`CoreApiClient`、`MetadataApiClient`、`RestApiClient`)导入,但 Twenty 会在运行时提供它——逻辑函数从生成的 SDK 层获取它,前端组件则从服务器提供的模块中解析它。 你安装的副本仅用于类型检查和部署时的构建,因此不需要被打包进已部署的 bundle 中。
把任意一个软件包放在 `dependencies` 下,都会把它拉入已安装应用的运行时 bundle 中,在那里只是累赘。 当任一软件包仍然列在 `dependencies` 下时,`twenty dev:build` 会发出警告。
像往常一样,把你的应用自身的运行时依赖项(逻辑函数在运行时实际导入的库)添加到 `dependencies` 下。 保持依赖树精简:`dependencies` 会被安装到一个解压后上限为 250MB 的运行时层中,如果超过该依赖大小限制,同步或安装就会失败并报依赖大小错误——UI 库和开发工具应放在 `devDependencies` 中。

View File

@@ -0,0 +1,166 @@
---
title: 快速开始
icon: rocket
description: 几分钟内创建你的第一个 Twenty 应用。
---
## 先决条件
* **Node.js 24.5+** — [在此下载](https://nodejs.org/)
* **Yarn 4** — 通过 Corepack 随 Node.js 提供。 启用它:`corepack enable`
* **Docker** — [在此下载](https://www.docker.com/products/docker-desktop/)。 运行本地 Twenty 服务器所需。 如果你已经在其他地方运行了 Twenty,请跳过。
构建一个 Twenty 应用包含三个阶段。 脚手架工具将它们合并为一个理想路径的命令,但每个阶段都是独立的概念——当出现问题时,知道自己处于哪个阶段可以指明需要修复什么。
| 阶段 | 你要做什么 | 工具 | 结果 |
| ------------ | -------------------- | ----------------------------------- | -------------------- |
| **1. 脚手架** | 生成应用的源代码 | `npx create-twenty-app` | 磁盘上的一个 TypeScript 项目 |
| **2. 运行服务器** | 启动一个 Twenty 服务器以进行同步 | Docker + `yarn twenty docker:start` | 一个正在运行的 Twenty 实例 |
| **3. 同步** | 将你的代码实时同步到服务器 | `yarn twenty dev` | 你的更改会出现在 UI 中 |
---
## 阶段 1 — 搭建项目脚手架
从模板创建一个新应用:
```bash filename="Terminal"
npx create-twenty-app@latest my-twenty-app
```
脚手架是非交互式的:目录名称会成为应用名称。 传递 `--display-name` 和 `--description` 来自定义生成的元数据(你也可以稍后在 `src/constants/universal-identifiers.ts` 中进行编辑)。 这将在 `my-twenty-app/` 中生成一个 TypeScript 项目,包含一个入门版的 `application-config.ts`、一个默认角色、CI/CD 工作流,以及一个集成测试。
**完成此阶段后:** 你的机器上已有该应用的源代码。 它还未运行——那是第 2 阶段的内容。
---
## 阶段 2 — 运行本地 Twenty 服务器
你的应用需要一个 Twenty 服务器来进行同步。 该服务器是一个完整的 Twenty 实例——包含 UI、GraphQL API、PostgreSQL——在本地的 Docker 中运行。 你的本地代码会将其定义上传到该服务器,从而使其显示在 UI 中。
脚手架会为你启动一个:在 Docker 正在运行的情况下,它会拉取 `twentycrm/twenty-app-dev` 镜像,在端口 `2020` 上启动,并将 CLI 认证到预置的演示工作区(`tim@apple.dev`)——无需登录。
若要改为连接到现有的 Twenty 服务器,请传入 `--url \<your-server-url>`。 远程服务器通过 OAuth 进行身份验证:会打开一个浏览器窗口,你可以登录并点击 **Authorize**,从而授予 CLI 访问你工作区的权限。 (你也可以在本地选择使用 OAuth,方式是添加 `--authentication-method oauth` —— 使用 `tim@apple.dev` / `tim@apple.dev` 登录。)
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/login.png" alt="Twenty 登录界面" />
</div>
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/authorize.png" alt="Twenty CLI 授权界面" />
</div>
你的终端会确认一切已就绪。
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/scaffolded.png" alt="应用脚手架创建成功" />
</div>
**完成此阶段后:** 你在 [http://localhost:2020](http://localhost:2020) 上拥有一个正在运行的 Twenty 服务器,且你的 CLI 已获授权可与其同步。
<Note>
如果未安装或未运行 Docker,脚手架工具会告诉你在所用操作系统上正确的启动命令。 Docker 启动后,你可以通过 `yarn twenty docker:start` 继续——无需重新生成脚手架。
</Note>
---
## 阶段 3 — 同步你的更改
这是你大部分时间所处的内循环。
```bash filename="Terminal"
cd my-twenty-app
yarn twenty dev
```
它会监视 `src/`,在每次更改时重新构建,并将结果同步到服务器。 编辑文件、保存,服务器会在几秒内反映出更改。 你会在终端中看到一个实时状态面板。
如需更详细的输出(构建日志、同步请求、错误跟踪),请添加 `--verbose`。
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/dev.png" alt="开发模式终端输出" />
</div>
打开 [http://localhost:2020/settings/applications#developer](http://localhost:2020/settings/applications#developer)。 你应当在 **你的应用** 下看到你的应用。
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/app-in-ui-1.png" alt="“你的应用”列表显示 My twenty app" />
</div>
点击 **My twenty app** 查看其**应用注册**——一条用于描述你的应用(名称、标识符、OAuth 凭据、来源)的服务器级记录。 同一服务器上的多个工作区可以安装同一个注册项。
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/app-in-ui-2.png" alt="应用注册详情" />
</div>
点击 **查看已安装的应用** 以查看工作区安装项。 **关于** 选项卡显示版本和管理选项。
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/app-in-ui-3.png" alt="已安装的应用" />
</div>
**完成此阶段后:** 你拥有一个实时的开发循环。 编辑 `src/` 中的任意文件,更改会显示在 UI 中。
### 用于 CI 和脚本的一次性同步
使用 `plan` 和 `apply` 在无监视器的情况下各运行一次相同的流水线:
```bash filename="Terminal"
yarn twenty plan # preview the metadata changes without applying them
yarn twenty apply # show the plan, then apply it
```
| 命令 | 行为 | 适用场景 |
| ------------------- | ------------------------------------------------------------------ | ------------------------------- |
| `yarn twenty dev` | 监视并在每次更改时重新同步。 持续运行,直到你将其停止。 | 交互式本地开发。 |
| `yarn twenty apply` | 单次构建与同步,成功时以 `0` 退出,失败时以 `1` 退出。 在执行破坏性变更前会要求确认(传入 `--force` 可跳过)。 | CI、pre-commit 钩子、AI 智能体、脚本化工作流。 |
| `yarn twenty plan` | 构建并打印元数据更改,**但不会应用这些更改**。 | 在提交同步之前检查它会更改哪些内容。 |
所有模式都需要经过身份验证的远程服务器。 有关 `plan` 的更多信息,请参见 [同步与恢复](/l/zh/developers/extend/apps/operations/sync-and-recovery#previewing-changes-plan)。
<Note>
`yarn twenty dev --once` 和 `yarn twenty dev --once --dry-run` 是 `yarn twenty apply` 和 `yarn twenty plan` 的已弃用别名。
</Note>
### 开发模式选项
| 标志 | 描述 |
| ------------------------------------- | ------------------------------ |
| `--force` | 在不经确认的情况下应用破坏性变更(删除)。 |
| `--debounceMs \<ms>` | 以毫秒为单位设置文件更改的防抖延迟(默认值:`1000`)。 |
| `--verbose` / `--debug` | 显示详细的构建日志、同步请求和错误跟踪。 |
## 你可以构建的内容
应用由**实体**组成——每个实体定义为一个包含单一 `export default` 的 TypeScript 文件:
| 实体 | 作用 |
| ---------- | ------------------------------------------ |
| **对象与字段** | 自定义数据模型(明信片、发票等) 带有类型化字段 |
| **逻辑函数** | 由 HTTP 路由、cron 调度或数据库事件触发的服务端 TypeScript |
| **前端组件** | 在 Twenty 的 UI 内渲染的 React 组件(侧边面板、小部件、命令菜单) |
| **技能与智能体** | AI 能力——可复用的指令和自主助手 |
| **视图与导航** | 预配置的列表视图和侧边栏菜单项 |
| **页面布局** | 带有选项卡和小部件的自定义记录详情页 |
完整参考:[概念](/l/zh/developers/extend/apps/getting-started/concepts)。
## 后续步骤
<CardGroup cols={2}>
<Card title="配置" icon="screwdriver-wrench" href="/l/zh/developers/extend/apps/config/overview">
应用标识、默认角色、安装和卸载钩子、公共资源。
</Card>
<Card title="数据" icon="database" href="/l/zh/developers/extend/apps/data/overview">
对象、字段和双向关系。
</Card>
<Card title="逻辑" icon="bolt" href="/l/zh/developers/extend/apps/logic/overview">
逻辑函数、技能、智能体和 OAuth 连接。
</Card>
<Card title="布局" icon="table-columns" href="/l/zh/developers/extend/apps/layout/overview">
视图、导航、页面布局、前端组件。
</Card>
<Card title="操作" icon="rocket" href="/l/zh/developers/extend/apps/operations/overview">
CLI、测试、远程、CI,以及发布你的应用。
</Card>
</CardGroup>

View File

@@ -0,0 +1,63 @@
---
title: 脚手架
description: 通过 yarn twenty dev:add 以交互方式生成实体文件——对象、字段、视图、逻辑函数等。
icon: wand-magic-sparkles
---
无需手动创建实体文件,使用交互式脚手架:
```bash filename="Terminal"
yarn twenty dev:add
```
它会提示你选择一个实体类型,引导你填写所需字段,然后生成一个可直接使用的文件,其中包含稳定的 `universalIdentifier` 和正确的 `defineEntity()` 调用。
你也可以直接传入实体类型以跳过第一个提示:
```bash filename="Terminal"
yarn twenty dev:add object
yarn twenty dev:add logicFunction
yarn twenty dev:add frontComponent
```
## 可用的实体类型
| 实体类型 | 命令 | 生成的文件 |
| ------- | ------------------------------------------ | --------------------------------------------------------- |
| 对象 | `yarn twenty dev:add object` | `src/objects/\<name>.ts` |
| 字段 | `yarn twenty dev:add field` | `src/fields/\<name>.ts` |
| 逻辑函数 | `yarn twenty dev:add logicFunction` | `src/logic-functions/\<name>.ts` |
| 前端组件 | `yarn twenty dev:add frontComponent` | `src/front-components/\<name>.tsx` |
| 角色 | `yarn twenty dev:add role` | `src/roles/\<name>.ts` |
| 技能 | `yarn twenty dev:add skill` | `src/skills/\<name>.ts` |
| 代理 | `yarn twenty dev:add agent` | `src/agents/\<name>.ts` |
| 视图 | `yarn twenty dev:add view` | `src/views/\<name>.ts` |
| 导航菜单项 | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/\<name>.ts` |
| 页面布局 | `yarn twenty dev:add pageLayout` | `src/page-layouts/\<name>.ts` |
| 页面布局选项卡 | `yarn twenty dev:add pageLayoutTab` | `src/page-layout-tabs/\<name>.ts` |
| 命令菜单项 | `yarn twenty dev:add commandMenuItem` | `src/command-menu-items/\<name>.ts` |
| 视图字段 | `yarn twenty dev:add viewField` | `src/view-fields/\<name>.ts` |
| 连接提供方 | `yarn twenty dev:add connectionProvider` | `src/connection-providers/\<name>.ts` |
| 时间线活动类型 | `yarn twenty dev:add timelineActivityType` | `src/timeline-activity-types/\<name>.ts` |
## 脚手架生成的内容
每种实体类型都有其自己的模板。 例如,`yarn twenty dev:add object` 会询问:
1. **名称(单数)**——例如,`invoice`
2. **名称(复数)**——例如,`invoices`
3. **标签(单数)**——根据名称自动填充(例如,`Invoice`)
4. **标签(复数)**——自动填充(例如,`Invoices`)
5. **创建视图和导航项?**——如果你选择是,脚手架还会为新对象生成相应的视图和侧边栏链接。
其他实体类型的提示更简单——大多只会询问名称。
`field` 实体类型更为详细:它会询问字段名称、标签、类型(从所有可用字段类型列表中选择,如 `TEXT`、`NUMBER`、`SELECT`、`RELATION` 等),以及目标对象的 `universalIdentifier`。
## 自定义输出路径
使用 `--path` 标志将生成的文件放置在自定义位置:
```bash filename="Terminal"
yarn twenty dev:add logicFunction --path src/custom-folder
```

View File

@@ -0,0 +1,14 @@
---
title: 故障排除
description: 常见的首次运行问题 — Docker、Node 版本、Yarn、依赖项。
icon: wrench
---
* **Docker 错误** — 在运行 `yarn twenty docker:start` 之前,请确保 Docker Desktop(或守护进程)已在运行。 错误消息会显示适用于你的操作系统的正确启动命令。
* **错误的 Node 版本** — 需要 24.5+(`engines.node: ^24.5.0`)。 使用 `node -v` 检查。
* **缺少 Yarn 4** — 运行 `corepack enable`。
* **依赖损坏** — `rm -rf node_modules && yarn install`。
* **`twenty-sdk` 升级到 v2.8.0 后出现错误** — 在 v2.8.0 中,它从 `dependencies` 移动到了 `devDependencies`。 请参阅[项目结构 → 依赖项](/l/zh/developers/extend/apps/getting-started/project-structure#dependencies)。
* **`twenty dev:build` 会对 `dependencies` 下的 `twenty-client-sdk` 发出警告** — 它由 Twenty 在运行时提供,因此应与 `twenty-sdk` 一起移动到 `devDependencies` 中。 请参阅[项目结构 → 依赖项](/l/zh/developers/extend/apps/getting-started/project-structure#dependencies)。
卡住了吗? 在 [Twenty 的 Discord](https://discord.com/channels/1130383047699738754/1130386664812982322) 上提问。

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)配对。 这是规范做法:每个自定义对象在创建时都会自动生成一个默认的列表视图,因此你只需提供用于打开它的侧边栏条目。

View File

@@ -0,0 +1,195 @@
---
title: 后台作业
description: 通过将另一次逻辑函数运行入队,而不是在当前执行中完成所有工作,把耗时较长或受速率限制的工作交给 Twenty 工作进程处理。
icon: layer-group
---
一次逻辑函数运行会受到其 `timeoutSeconds`(最大 900 秒)的限制。 任何无法在该时间窗口内完成的工作——例如完整的重新同步、逐记录的扇出、对你进行速率限制的第三方 API——都必须拆分为更小的运行。
`enqueueJob` 正是用于此:它会请求 Twenty 工作进程稍后在其自己的进程中运行你应用中的某个逻辑函数,并使用其独立的超时预算。 调用方会立即返回。
```text
┌─────────────────┐ enqueueJob(...) ┌──────────────┐ ┌────────────────────┐
│ Logic function │ ─────────────────▶ │ Job queue │──▶│ Logic function │
│ (returns now) │ │ (workers) │ │ (fresh run/timeout)│
└─────────────────┘ └──────────────┘ └────────────────────┘
```
## 将一次运行加入队列
从 `twenty-sdk/logic-function` 导入 `enqueueJob`,并将其指向你想要运行的逻辑函数的 `universalIdentifier`。
```ts src/logic-functions/sync-all-contacts.ts
import { enqueueJob } from 'twenty-sdk/logic-function';
await enqueueJob({
logicFunctionUniversalIdentifier: '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33',
payload: { page: 1 },
});
```
目标函数会像其他任何触发器一样,将 `payload` 作为其处理程序参数接收。 它必须属于与调用方**相同的应用程序**——将其他应用的函数入队会被拒绝,并返回 `Logic function not found`。
<Note>
`enqueueJob` 在作业被接受后立即返回,而不是在它运行完成后返回。 它不会返回目标的结果——如果你需要再次读取结果,让目标将其输出写入[key-value store](/l/zh/developers/extend/apps/logic/key-value-store)或工作区记录。
</Note>
## 作业选项
| 选项 | 默认 | 范围 | 作用 |
| ------------ | --- | -------------------- | ---------------------------------------------------- |
| `retryLimit` | `0` | `0`–`10` | 总体额外队列尝试次数。 应用程序请求的重试次数上限为 `3`。 仅对可以安全运行两次的处理程序提高该值。 |
| `delayMs` | `0` | `0`–`604800000`(7 天) | 在运行符合条件之前等待这么长时间。 |
```ts
await enqueueJob({
logicFunctionUniversalIdentifier: '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33',
payload: { page: 1 },
retryLimit: 3,
delayMs: 60_000,
});
```
<Note>
**优先级尚不可配置。** 入队的作业始终以最低优先级运行,因此平台工作永远不会被应用程序作业延迟。 对优先级的控制即将推出。
</Note>
排队的运行会继承将其入队的函数的当前用户,因此会以相同的权限执行。
## 重试瞬时故障
Twenty 不会重试应用程序代码引发的每个异常。 普通的抛出错误会被视为永久性故障。 对于瞬时故障,已排队的逻辑函数可以通过抛出 `RetryableLogicFunctionError` 请求最多重试三次。
```ts
import {
type LogicFunctionExecutionContext,
RetryableLogicFunctionError,
} from 'twenty-sdk/logic-function';
export const handler = async (
_payload: unknown,
{ retryCount, maxRetries }: LogicFunctionExecutionContext,
) => {
const response = await fetch('https://api.example.com/contacts');
if (response.status === 429 || response.status >= 500) {
throw new RetryableLogicFunctionError(
`The contacts API is temporarily unavailable (${response.status}); retry ${retryCount} of ${maxRetries}`,
);
}
};
```
尽可能直接抛出 `RetryableLogicFunctionError`。 如果扩展它,请勿替换其 `name`:Twenty 会在各个执行运行时中识别序列化后的名称 `RetryableLogicFunctionError`。
`retryCount` 在初始执行时为 `0`,且仅在应用程序代码请求重试时增加。 `maxRetries` 最多为 `3`;当已排队作业的总体重试限制更小时,该值可能更低。 平台故障不会增加 `retryCount`,尽管它们仍会消耗队列的总体安全预算。
队列使用指数退避和抖动来延迟重试尝试。 有意不保证确切的延迟时间,因此应用程序代码不应依赖于在精确时间发生重试。 达到 `maxRetries` 后,另一个 `RetryableLogicFunctionError` 会被记录为最终的应用程序故障,且不会再次执行。
<Warning>
重试会重新运行整个处理程序,并且可能在某些副作用已成功后发生。 在请求重试之前,请确保处理程序具有幂等性。
</Warning>
## 用法:分页处理一次长时间同步
典型模式是函数使用下一个游标将*自身*入队。 每次运行都在自己的超时时间内完成一页工作,当没有剩余内容时链条就会停止。
```ts src/logic-functions/sync-contacts-page.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { enqueueJob } from 'twenty-sdk/logic-function';
const SYNC_CONTACTS_PAGE = '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33';
const handler = async (params: { cursor?: string }) => {
const { contacts, nextCursor } = await fetchContactsPage(params.cursor);
await importContacts(contacts);
if (nextCursor) {
await enqueueJob({
logicFunctionUniversalIdentifier: SYNC_CONTACTS_PAGE,
payload: { cursor: nextCursor },
delayMs: 2_000,
});
}
return { imported: contacts.length, done: !nextCursor };
};
export default defineLogicFunction({
universalIdentifier: SYNC_CONTACTS_PAGE,
name: 'sync-contacts-page',
timeoutSeconds: 120,
handler,
});
```
## 按记录扇出(fan-out)
当工作天然按条目划分时,为每个条目入队一个作业,并让工作进程并行处理它们,而不是在内联循环中处理。
```ts
const companies = await listCompaniesToEnrich();
await Promise.all(
companies.map((company) =>
enqueueJob({
logicFunctionUniversalIdentifier: ENRICH_COMPANY,
payload: { companyId: company.id },
retryLimit: 2,
}),
),
);
```
## 长时间运行工作的最佳实践
两条规则涵盖几乎所有长作业:**用递归代替循环**,以及**每次运行处理一块有界的数据块(chunk)**。
试图一次完成所有事情的运行就是失败模式——它会触发超时,并在重试时从头开始重新执行整个过程。 相反,应调整单个数据块的大小,使其能够从容地在 `timeoutSeconds` 内完成,持久化当前位置,然后入队下一次运行。
```ts src/logic-functions/enrich-companies-batch.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { enqueueJob, kv } from 'twenty-sdk/logic-function';
const ENRICH_COMPANIES_BATCH = '3f9d1c02-8a44-4f0e-b1d7-9c2e5a7b4f10';
const CHUNK_SIZE = 50;
const handler = async (params: { offset?: number }) => {
const offset = params.offset ?? 0;
const companies = await listCompaniesToEnrich({
offset,
limit: CHUNK_SIZE,
});
for (const company of companies) {
await enrichCompany(company);
}
await kv.set('enrich:progress', { offset: offset + companies.length });
if (companies.length === CHUNK_SIZE) {
await enqueueJob({
logicFunctionUniversalIdentifier: ENRICH_COMPANIES_BATCH,
payload: { offset: offset + CHUNK_SIZE },
});
}
return { processed: companies.length, done: companies.length < CHUNK_SIZE };
};
export default defineLogicFunction({
universalIdentifier: ENRICH_COMPANIES_BATCH,
name: 'enrich-companies-batch',
timeoutSeconds: 300,
handler,
});
```
支撑这一模式的要点:
* **以最慢的条目而不是平均值来确定块大小。** `CHUNK_SIZE × worst-case item time` 必须在留有余量的情况下适配到 `timeoutSeconds` 中,否则在运行被切断时,一个数据块的尾部会丢失。
* **让终止条件显式化。** 仅在返回的是完整数据块时继续递归。 仅在“无结果”时停止的链,如果源在中途返回一个较短的分页,将会永远继续下去。
* **在入队下一次运行之前持久化进度,** 这样失败的链路会从上一个完成的数据块而不是从头开始重新启动。
* **保持每个数据块幂等。** 在重试后重新处理一个数据块时,不能产生重复写入——应基于你处理的记录或外部 id 进行关键写入。
* **在工作会访问受限频率的第三方服务时,优先使用分块链而不是一次巨大的 fan-out。** 使用 `delayMs` 的链会自我节奏控制,而一次性入队的数千个作业会立即全部变为可运行状态。

View File

@@ -0,0 +1,269 @@
---
title: 连接
description: 通过 OAuth 让你的应用代表用户在第三方服务中执行操作。
icon: plug
---
连接是用户为外部服务(Linear、GitHub、Slack 等)持有的凭据。 你的应用声明**如何**获取这些凭据——即**连接提供程序**——并在运行时使用它们向第三方 API 发起认证调用。
目前仅支持 OAuth 2.0。 将来的凭据类型(个人访问令牌、API 密钥、基本身份验证)将接入相同的接口——已经使用 `defineConnectionProvider({ type: 'oauth', ... })` 的应用将无需迁移。
<AccordionGroup>
<Accordion title="defineConnectionProvider" description="声明你的应用如何获取连接">
连接提供程序描述了你的应用所需的 OAuth 握手流程。 用户在你的应用设置中点击"添加连接",完成提供方的授权同意页面后,会在其工作区中创建一条 `ConnectedAccount` 行。
一个可用的配置需要**两个文件**——连接提供程序,以及在 `defineApplication` 上与之匹配、用于保存 OAuth 客户端凭据的 `serverVariables` 声明。
```ts src/connection-providers/linear-connection.ts
import { defineConnectionProvider } from 'twenty-sdk/define';
export default defineConnectionProvider({
universalIdentifier: '9c7d1f5e-6a0b-4d44-be0c-3f8b5a9d4e6f',
name: 'linear',
displayName: 'Linear',
icon: 'IconBrandLinear',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
scopes: ['read', 'write'],
// These must match keys in `defineApplication.serverVariables` below.
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
// Optional: defaults to 'json'. Some providers (Linear, Slack) want
// 'form-urlencoded' for the token request.
tokenRequestContentType: 'form-urlencoded',
// Optional: defaults to true. Disable only if the provider rejects PKCE.
usePkce: false,
// Optional: extra query params on the authorize URL.
// authorizationParams: { prompt: 'consent' },
// Optional: provider's RFC 7009 token revocation endpoint, called on disconnect.
// revokeEndpoint: 'https://example.com/oauth/revoke',
},
// Optional: a logic function in this app to run right after a connection is
// established. See "Run a logic function on connect".
// onConnectLogicFunction: { universalIdentifier: '3a2b1c0d-...-...' },
// Optional: a logic function in this app to run right after a connection is
// removed. See "Run a logic function on disconnect".
// onDisconnectLogicFunction: { universalIdentifier: '4d5e6f70-...-...' },
});
```
```ts src/application.config.ts
import { defineApplication } from 'twenty-sdk/define';
export default defineApplication({
universalIdentifier: '...',
displayName: 'Linear',
description: 'Connect Linear to Twenty.',
// OAuth client credentials live on the app registration (one OAuth app per
// Twenty server, configured by the admin) — not per-workspace. Declare them
// as serverVariables so the admin can fill them in once for all installs.
serverVariables: {
LINEAR_CLIENT_ID: {
description: 'OAuth client ID from your Linear OAuth application.',
isSecret: false,
isRequired: true,
},
LINEAR_CLIENT_SECRET: {
description: 'OAuth client secret from your Linear OAuth application.',
isSecret: true,
isRequired: true,
},
},
});
```
关键点:
* `name` 是在 `listConnections({ providerName })` 中使用的唯一标识符字符串(短横线命名(kebab-case),必须匹配 `^[a-z][a-z0-9-]*$`)。
* `displayName` 会显示在每个应用的设置选项卡以及 AI 工具列表中。
* `clientIdVariable` / `clientSecretVariable` 是**名称**,而不是值——它们必须与 `defineApplication.serverVariables` 中声明的键匹配。 实际的 `client_id` 和 `client_secret` 由服务器管理员通过应用注册 UI 输入,绝不会提交到你的仓库。
* 请使用 `serverVariables`(而非 `applicationVariables`)——OAuth 凭据是服务器范围的,并且每个 Twenty 服务器只配置一个 OAuth 应用。
* 在两个 `serverVariables` 都填写之前,每个应用的设置选项卡会显示"需要服务器管理员"的提示,并且"添加连接"按钮将被禁用。
* `type: 'oauth'` 是目前唯一受支持的取值。 该判别器具备前向兼容性:未来的类型(`'pat'`、`'api-key'` 等) 将会与 `oauth` 并列新增子配置块。
你的提供方需要加入白名单的 OAuth 回调 URL 为:
```
https://<your-twenty-server>/auth/apps/callback
```
</Accordion>
<Accordion title="在连接时运行逻辑函数" description="在连接建立时立即响应">
某些提供方会在连接时向你提供数据,而在连接真正可用之前,你需要先持久化这些数据——一个典型示例是 Slack,其中 OAuth 响应会标识工作区的 `team_id`,后续传入事件将会基于该值进行关联。 将 `onConnectLogicFunction` 设置为引用同一应用中的某个逻辑函数(通过其 `universalIdentifier`),该函数会在创建 `ConnectedAccount` 之后立即运行。
```ts src/connection-providers/slack-connection.ts
export default defineConnectionProvider({
universalIdentifier: '...',
name: 'slack',
displayName: 'Slack',
type: 'oauth',
oauth: {
/* ... */
},
// Runs claimSlackTeam after every successful Slack connection.
onConnectLogicFunction: {
universalIdentifier: '3a2b1c0d-1111-4222-8333-444455556666',
},
});
```
该钩子**在发起连接的工作区中以异步方式运行**(它会入队执行,而不是被等待),因此缓慢或失败的钩子永远不会阻塞或破坏 OAuth 回调——请确保该钩子是幂等的,并能自行处理重试。 处理程序会接收到:
```ts
type OnConnectPayload = {
connectionProviderId: string;
connectionProviderName: string; // e.g. 'slack'
connectedAccountId: string;
};
```
接下来,使用 `getConnection(connectedAccountId)` 读取最新的访问令牌,并调用提供商的 API(例如 Slack `auth.test`),或者使用[key-value store](/l/zh/developers/extend/apps/logic/key-value-store) 持久化映射。
</Accordion>
<Accordion title="在断开连接时运行逻辑函数" description="在移除连接时进行清理">
应用在连接时声明的任何内容,都必须在连接断开时被释放。 例如,一个在连接时声明了 `team_id` 的 Slack 集成,必须在连接结束时释放该声明,以便其他工作区可以连接同一个 Slack 团队。 将 `onDisconnectLogicFunction` 设置为引用同一应用中的某个逻辑函数,该函数会在删除 `ConnectedAccount` 之后立即运行。
```ts src/connection-providers/slack-connection.ts
export default defineConnectionProvider({
universalIdentifier: '...',
name: 'slack',
displayName: 'Slack',
type: 'oauth',
oauth: {
/* ... */
},
// Runs releaseSlackTeam after every Slack disconnection.
onDisconnectLogicFunction: {
universalIdentifier: '4470aba8-5ff5-4800-88db-2a427cd8677c',
},
});
```
与 on-connect 钩子类似,它在**正在断开连接的工作区中以异步方式运行**,并且不会阻塞断开过程。 处理程序接收的负载结构相同:
```ts
type OnDisconnectPayload = {
connectionProviderId: string;
connectionProviderName: string; // e.g. 'slack'
connectedAccountId: string;
};
```
当钩子运行时,`ConnectedAccount` 已经不存在了,因此 `getConnection(connectedAccountId)` 将不再返回结果。 清理所需的任何内容(例如 `team_id`、外部订阅 ID)必须在连接时写入到按 `connectedAccountId` 作为键的[key-value store](/l/zh/developers/extend/apps/logic/key-value-store) 中。
当连接自身被移除时,该钩子会被触发。 卸载应用会通过数据库级联来删除其连接,因此该钩子在这种情况下不会运行。 在 `defineApplication` 上声明一个 `uninstallLogicFunction` 用于该路径:它在应用的元数据被删除之前运行,因此仍然可以调用 `listConnections` 并清理剩余的内容。
</Accordion>
<Accordion title="listConnections / getConnection" description="在逻辑函数中使用连接">
在逻辑函数处理器内,`listConnections({ providerName })` 会返回此应用针对给定提供方的 `ConnectedAccount` 行,并附带已刷新的访问令牌。
```ts src/logic-functions/handlers/create-linear-issue-handler.ts
import { listConnections } from 'twenty-sdk/logic-function';
export const createLinearIssueHandler = async (input: {
teamId?: string;
title?: string;
}) => {
if (!input.teamId || !input.title) {
return { success: false, error: 'teamId and title are required' };
}
const connections = await listConnections({ providerName: 'linear' });
// Workspace-shared credentials win when present; fall back to the first
// user-visibility one. For HTTP-route triggers you typically pick the
// request user's connection via event.userWorkspaceId instead.
const connection =
connections.find((c) => c.visibility === 'workspace') ?? connections[0];
if (!connection) {
return {
success: false,
error:
'Linear is not connected. Open the app settings and click "Add connection".',
};
}
// Use connection.accessToken to call the third-party API.
const response = await fetch('https://api.linear.app/graphql', {
method: 'POST',
headers: {
Authorization: `Bearer ${connection.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: `mutation { issueCreate(input: { teamId: "${input.teamId}", title: "${input.title}" }) { success } }`,
}),
});
return { success: response.ok };
};
```
每个连接包含:
| 字段 | 描述 |
| ----------------- | -------------------------------------------------------------------------------------------- |
| `id` | 唯一的行 id;传给 `getConnection(id)` 以重新获取单个连接 |
| `visibility` | `'user'`(仅对单个工作区成员私有)或 `'workspace'`(与所有成员共享) |
| `scopes` | 上游提供方授予的 OAuth 权限(不同于 `visibility`——两者不相关) |
| `userWorkspaceId` | 所有者的 userWorkspace id——在 HTTP 路由触发器中用于选择"请求用户的连接"很有用 |
| `accessToken` | 最新的 OAuth 访问令牌(若已过期会自动刷新) |
| `name` | 连接的显示名称(在 OAuth 回调时自动生成,用户可重命名) |
| `handle` | 已连接的上游帐户电子邮件,每次重新连接时刷新; 从 OIDC `id_token` 返回的 token\` 解决方案在代币交换中返回,回退到连接20个用户的电子邮件,当提供商不返回一个 |
| `authFailedAt` | 当最近一次刷新失败时会设置;用户必须重新连接 |
关键点:
* 传入 `{ providerName }` 以按提供方筛选;省略它则可获取此应用在所有提供方上的全部连接。
* 服务器会在返回前透明地刷新访问令牌。 你的处理器始终会拿到可用的令牌(或已设置 `authFailedAt`)。
* `getConnection(id)` 是获取单行记录的对应方法。
</Accordion>
<Accordion title="按用户与工作区共享的可见性" description="用户如何在私有与共享凭据之间进行选择">
当用户点击"添加连接"时,系统会提示其选择可见性:
* **仅限我**——该凭据仅对连接的用户私有。 代表其调用的任何逻辑函数(带有 `isAuthRequired: true` 的 HTTP 路由触发器)都可以看到它;Cron 触发器和数据库事件则不可。
* **工作区共享**——任何工作区成员都可以使用该凭据。 Cron / 数据库触发器也可以使用它,因为它们没有请求用户。
为每个处理器使用合适的类型:
```ts
// HTTP-route trigger — prefer the request user's own connection.
const conn =
connections.find((c) => c.userWorkspaceId === event.userWorkspaceId) ??
connections.find((c) => c.visibility === 'workspace');
// Cron trigger — no request user; only shared credentials are sensible.
const conn = connections.find((c) => c.visibility === 'workspace');
```
每个(用户、提供方)允许有多个连接,因此同一用户可以同时拥有"个人 Linear"和"工作 Linear"。
</Accordion>
<Accordion title="一次性提供方设置" description="在第三方服务中注册你的 OAuth 应用">
对于每个连接提供方,服务器管理员需要先在第三方注册一个 OAuth 应用。
1. 前往提供方的开发者设置(例如 https://linear.app/settings/api/applications/new)。
2. 将 **Redirect URI** 设置为 `\<SERVER_URL>/auth/apps/callback`。
3. 复制生成的**Client ID**和**Client Secret**。
4. 以服务器管理员身份在 Twenty 中打开已安装的应用 → 在相应的 `serverVariables` 上设置这些值。
5. 之后,工作区成员可以在每个应用的**连接**部分添加连接。
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,110 @@
---
title: 键值存储
description: 使用内置的应用键值存储持久化中间结果、缓存数据,并在多次逻辑函数运行之间共享状态。
icon: database
---
逻辑函数在短生命周期的 Node.js 进程中以沙盒方式运行——一旦一次运行结束,内存中不会保留任何内容。 当你需要在**多次运行之间记住一些东西**时(缓存一次昂贵的 API 响应、存储增量同步的游标、对工作进行防抖处理,或在函数之间传递状态),请将其持久化到内置键值存储中。
每个应用都会获得自己隔离的命名空间:条目根据已认证的应用进行键控,因此你的键永远不会与其他应用发生冲突,也不会被其他应用读取。
```text
┌─────────────────┐ kv.set(key, value) ┌──────────────────────────┐
│ Logic function │ ─────────────────────▶ │ Application KV store │
│ (your handler) │ ◀───────────────────── │ key (unique) │ value │
└─────────────────┘ kv.get(key) └──────────────────────────┘
```
## 获取、设置、删除
从 `twenty-sdk/logic-function` 导入 `kv`。 值可以是任何可序列化为 JSON 的负载。
```ts src/logic-functions/sync-linear-issues.ts
import { kv } from 'twenty-sdk/logic-function';
// Read a value. Returns null when the key is missing.
const cursor = await kv.get<string>('sync-cursor:linear');
// Write a value. Creates the entry on first write, updates it afterwards.
await kv.set('sync-cursor:linear', newCursor);
// Delete an entry. Returns true when an entry was removed.
await kv.delete('sync-cursor:linear');
```
## 范围
每个条目都有一个作用域,在每次调用时作为选项传入。 默认值为 `WORKSPACE`。
* **`WORKSPACE`**(默认)— 条目对当前工作区中安装的应用是私有的。 每个安装该应用的工作区都会获得自己独立的一组键。 这正是缓存、游标和按工作区存储状态时所需要的。
* **`SERVER`** — 条目在服务器上应用的**每一次安装**之间共享。 服务器条目的行为类似**声明(claim)**:存储的值始终是声明该键的 workspaceId(在 `set` 时省略 `value` 即可为当前工作区声明该键),并且只有该工作区可以覆盖或删除它。 任何一次安装都可以读取该条目。
服务器声明用于跨工作区路由。 [服务器路由解析器](/l/zh/developers/extend/apps/logic/logic-functions#server-route-trigger)在应用注册所有者的工作区中运行,但入站 Webhook 通常只携带外部账户 ID——而不是 Twenty 的 workspaceId。 让每个工作区在连接时声明其外部 id,然后在路由中解析它:
```ts
// In the connected workspace, when the external account is linked:
await kv.set(`slack:team:${teamId}`, undefined, { scope: 'SERVER' });
// In the server-route resolver (owner workspace), on each webhook:
const workspaceId = await kv.get<string>(`slack:team:${teamId}`, {
scope: 'SERVER',
});
```
因为服务器键只能为调用方自己的工作区声明,且永远不会被其他工作区覆盖,所以某个工作区无法劫持属于其他工作区的映射。 当某个键已经被其他工作区声明时,`kv.set` 会抛出异常。
## 使用示例:缓存一次昂贵的调用
一个典型用例是缓存一次缓慢或受限于速率的第三方响应,这样重复的运行就可以重复使用该响应,而不必每次都付出相同的开销。
```ts src/logic-functions/getExchangeRate.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { kv } from 'twenty-sdk/logic-function';
const ONE_HOUR_MS = 60 * 60 * 1000;
type CachedRate = { rate: number; fetchedAt: number };
const handler = async (params: { from: string; to: string }) => {
const cacheKey = `cache:exchange-rate:${params.from}:${params.to}`;
const cached = await kv.get<CachedRate>(cacheKey);
if (cached && Date.now() - cached.fetchedAt < ONE_HOUR_MS) {
return { rate: cached.rate, cached: true };
}
const response = await fetch(
`https://api.example.com/rate?from=${params.from}&to=${params.to}`,
);
const { rate } = (await response.json()) as { rate: number };
await kv.set(cacheKey, { rate, fetchedAt: Date.now() });
return { rate, cached: false };
};
export default defineLogicFunction({
universalIdentifier: 'd9b2f4e6-1c83-4a07-9e52-6b1d3c8a0f47',
name: 'get-exchange-rate',
timeoutSeconds: 10,
handler,
});
```
## 模式与技巧
* **命名空间划分。** 给键添加前缀,以区分不同的用途——`sync-cursor:linear`、`cache:exchange-rate:USD:EUR`、`lock:nightly-report`。
* **过期时间(TTL)。** 该存储本身不带有过期机制。 在值中存储时间戳(如缓存示例中所示)并在读取时检查,或者通过[定时任务触发的函数](/l/zh/developers/extend/apps/logic/logic-functions)清理陈旧的键。
* **存什么。** 任何可序列化为 JSON 的值——数字、字符串、数组、对象。 保持条目足够小;此存储用于协调和缓存,而不是用于存放大型二进制对象或文件。 对于文件,请使用 `FILES` 字段和 [`uploadFile`](/l/zh/developers/extend/apps/logic/logic-functions#uploading-files)。
* **可见性。** 条目存在于实例数据库中,而不是作为工作区记录存在——它们不会出现在工作区 UI 中,不属于你应用的数据模型,也不需要角色或对象权限。
## 可选方案:可查询的存储对象
内置存储是刻意设计为不透明的:条目不是记录,因此你无法在 UI 中浏览它们、将它们与其他对象关联,或通过记录查询对它们进行筛选。 当你需要这些能力时——比如可见的同步日志或逐条记录的状态——请定义一个小型的**技术对象**,其中包含唯一的 `key` 字段和一个 `RAW_JSON` 类型的 `value` 字段,并通过[类型化 API 客户端](/l/zh/developers/extend/apps/logic/logic-functions#typed-api-clients-twenty-client-sdk)对其进行查询。 `defineObject` 的参考请参见 [Objects](/l/zh/developers/extend/apps/data/objects),关于强制键唯一性请参见 [Data → Unique indexes](/l/zh/developers/extend/apps/data/overview#unique-indexes)。
* **将作用域限定到记录。** 从存储对象到目标对象添加一个[关系](/l/zh/developers/extend/apps/data/relations),而不是把 id 编码进键中。
* **可见性与权限。** 这些行像任何其他记录一样存放在工作区数据库中,因此可以通过 API 查询,并遵循你的应用[角色](/l/zh/developers/extend/apps/config/roles)设置。 要将存储从主 UI 中隐藏,只需不要把它加入到你的[导航菜单](/l/zh/developers/extend/apps/layout/navigation-menu-items)中。
<Note>
与内置存储不同,自定义对象始终作用于单个工作区——它无法像 `SERVER` 键那样在安装之间共享条目。
</Note>

View File

@@ -0,0 +1,909 @@
---
title: 逻辑函数
description: 定义具有 HTTP、cron 和数据库事件触发器的服务端 TypeScript 函数。
icon: bolt
---
逻辑函数是在 Twenty 平台上运行的服务端 TypeScript 函数。 它们可以由 HTTP 请求、cron 调度或数据库事件触发——也可以作为工具暴露给 AI 智能体。
<AccordionGroup>
<Accordion title="defineLogicFunction" description="定义逻辑函数及其触发器">
每个函数文件都使用 `defineLogicFunction()` 导出包含处理程序和可选触发器的配置。
```ts src/logic-functions/createPostCard.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async (params: RoutePayload) => {
const client = new CoreApiClient();
const body = (params.body ?? {}) as { name?: string };
const name = body.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world';
const result = await client.mutation({
createPostCard: {
__args: { data: { name } },
id: true,
name: true,
},
});
return result;
};
export default defineLogicFunction({
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'create-new-post-card',
timeoutSeconds: 2,
handler,
httpRouteTriggerSettings: {
path: '/post-card/create',
httpMethod: 'POST',
isAuthRequired: true,
},
/*databaseEventTriggerSettings: {
eventName: 'people.created',
},*/
/*cronTriggerSettings: {
pattern: '0 0 1 1 *',
},*/
});
```
可用的触发器类型:
* **httpRoute**:通过 HTTP 路径和方法公开你的函数。 在应用代码中,使用 `RestApiClient` 时,在路由路径前加上 `/s/` 前缀;已部署的 URL 使用注入的 `TWENTY_FUNCTIONS_URL` 基础地址(如果未设置,则使用 `\<server-url>/s`)。
<Note>
要从(无头)前端组件调用由路由触发的逻辑函数,请参见[调用逻辑函数](/l/zh/developers/extend/apps/layout/front-components#calling-a-logic-function)。
</Note>
* **cron**:使用 CRON 表达式按计划运行你的函数。
* **databaseEvent**:在工作区对象生命周期事件上运行。 当事件操作为 `updated` 时,可以在 `updatedFields` 数组中指定要监听的特定字段。 如果未定义或为空,任何更新都会触发该函数。
> 例如 `person.updated`、`*.created`、`company.*`
* **serverRoute**:公开一个注册作用域的单一 HTTP 路由。 一个在所有者工作区中运行的 **resolver** 函数(使用 `serverRouteTriggerSettings` 声明)要么返回一个同步的 `Response`,要么返回目标工作区以及要入队的逻辑函数;在入队路径下,平台会发送 `202` 确认并在工作队列上运行该**目标**。 参见 [服务端路由触发器](#server-route-trigger)。
<Note>
你也可以使用 CLI 手动执行函数:
```bash filename="Terminal"
yarn twenty dev:function:exec -n create-new-post-card -p '{"key": "value"}'
```
```bash filename="Terminal"
yarn twenty dev:function:exec -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
```
你可以通过以下方式查看日志:
```bash filename="Terminal"
yarn twenty dev:function:logs
```
</Note>
#### 路由触发器负载
当路由触发器调用你的逻辑函数时,它会接收一个遵循
[AWS HTTP API v2 格式](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html)的 `RoutePayload` 对象。
从 `twenty-sdk/logic-function` 导入 `RoutePayload` 类型:
```ts
import type { RoutePayload } from 'twenty-sdk/logic-function';
const handler = async (event: RoutePayload) => {
const { headers, queryStringParameters, pathParameters, body } = event;
const { method, path } = event.requestContext.http;
return { message: 'Success' };
};
```
`RoutePayload` 类型具有以下结构:
| 属性 | 类型 | 描述 | 示例 |
| ---------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `headers` | `Record\<string, string \| undefined>` | HTTP 请求头(仅限 `forwardedRequestHeaders` 中列出的那些) | 见下文 |
| `queryStringParameters` | `Record\<string, string \| undefined>` | 查询字符串参数(多个值以逗号连接) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
| `pathParameters` | `Record\<string, string \| undefined>` | 从路由模式中提取的路径参数 | `/users/:id`,`/users/123` -> `{ id: '123' }` |
| `body` | `object \| null` | 已解析的请求体(JSON) | `{ id: 1 }` -> `{ id: 1 }` |
| `rawBody` | `string \| undefined` | 在 JSON 解析之前的原始 UTF-8 请求体。 用于验证 HMAC 风格的 Webhook 签名(例如 GitHub 的 `X-Hub-Signature-256`、Stripe)。 当运行时未保留它时为 `undefined`。 | |
| `isBase64Encoded` | `boolean` | 请求体是否为 base64 编码 | |
| `requestContext.http.method` | `string` | HTTP 方法(GET、POST、PUT、PATCH、DELETE) | |
| `requestContext.http.path` | `string` | 原始请求路径 | |
#### forwardedRequestHeaders
出于安全原因,默认**不会**将传入请求的 HTTP 请求头传递给你的逻辑函数。
如需访问特定请求头,请在 `forwardedRequestHeaders` 数组中显式列出:
```ts
export default defineLogicFunction({
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'webhook-handler',
handler,
httpRouteTriggerSettings: {
path: '/webhook',
httpMethod: 'POST',
isAuthRequired: false,
forwardedRequestHeaders: ['x-webhook-signature', 'content-type'],
},
});
```
在你的处理程序中,可以这样访问被转发的请求头:
```ts
const handler = async (event: RoutePayload) => {
const signature = event.headers['x-webhook-signature'];
const contentType = event.headers['content-type'];
// Validate webhook signature...
return { received: true };
};
```
<Note>
请求头名称会被规范化为小写。 请使用小写键访问它们(例如,`event.headers['content-type']`)。
</Note>
#### 自定义 HTTP 响应
默认情况下,从处理程序返回一个普通值会以 `200` 响应返回该值(对象为 JSON,字符串为 `text/plain`)。 要控制状态码和响应头,请从 `twenty-sdk/logic-function` 返回一个 `Response`:
```ts
import { Response } from 'twenty-sdk/logic-function';
const handler = async (event: RoutePayload) => {
return new Response('<h1>Hello</h1>', {
status: 201,
headers: { 'content-type': 'text/html' },
});
};
```
出于安全原因,响应头被限制在一个允许列表中。 任何不在该列表中的响应头(例如 `Set-Cookie`、CORS 响应头(如 `Access-Control-Allow-Origin`),或自定义的 `X-*` 响应头)都会在发送响应之前被静默丢弃。 允许的响应头包括:
* `content-type`
* `content-language`
* `content-disposition`
* `cache-control`
* `retry-after`
<Note>
状态码必须是有效的 HTTP 状态码(介于 100 和 599 之间)。 响应头名称的匹配不区分大小写。
</Note>
#### 平台错误响应
除了处理程序自身的响应之外,在某些情况下,平台会直接对路由调用做出响应:当路由或函数不存在时返回 `404`,当应用停止时返回 `403`,当触发执行速率限制时返回 `429`,以及当应用的生产环境 `dependencies` 过大而无法安装时返回 `422`——请参见[依赖项大小限制](/l/zh/developers/extend/apps/getting-started/project-structure#dependencies)。
#### 服务端路由触发器
`httpRouteTriggerSettings` 在 `/s/` 下暴露一个函数,并根据请求主机解析 workspace——这在每个 workspace 都有自己域名时有效。 然而,第三方服务商会将每个租户的事件发送到**同一个** URL。 在这种情况下,请使用 `serverRouteTriggerSettings`。
触发器由两部分组成:
1. 一个在 **所有者工作区**(拥有应用注册的工作区)中运行的 **resolver** 逻辑函数——使用 `serverRouteTriggerSettings` 声明。 它会检查传入请求并返回以下两者之一:
* `{ workspaceId, targetLogicFunctionUniversalIdentifier, payload? }` — 平台会在解析得到的工作区中将该目标入队,并返回 `202 { queued: true }` 确认,或者
* 来自 `twenty-sdk/logic-function` 的 `Response` — 平台会**同步**回显该 HTTP 响应,并且**不会**入队目标(将其用于诸如 Slack `url_verification` 之类的挑战握手)。
resolver 是唯一的授权点——URL 只携带 resolver 的标识符。 **这里是验证请求签名的首选位置**:resolver 在任何副作用之前运行,可以访问原始的 `rawBody` 和转发的请求头,并且可以在不触及目标的情况下直接拒绝请求。
2. 一个 **target** 逻辑函数——一个常规的、按工作区划分的逻辑函数——随后在解析得到的工作区中运行,并使用 resolver 返回的负载(如果 resolver 未对其进行转换,则使用原始请求负载)。 当 resolver 选择入队(enqueue)路径时,其返回值 **不会** 被 HTTP 调用方观察到。
```ts src/logic-functions/resolve-server-route.logic-function.ts
import { createHmac, timingSafeEqual } from 'crypto';
import { defineLogicFunction } from 'twenty-sdk/define';
import { Response, type RoutePayload } from 'twenty-sdk/logic-function';
// Runs in the owner workspace. Verifies the request signature, picks
// which target function should handle the event, and returns the
// workspace + target the platform should dispatch to.
const handler = async (event: RoutePayload) => {
// Fail closed if the secret isn't configured — never fall back to an
// empty key, which would let any caller forge a matching signature.
const secret = process.env.GITHUB_WEBHOOK_SECRET;
if (!secret) {
throw new Error('GITHUB_WEBHOOK_SECRET is not configured');
}
const signature = event.headers['x-hub-signature-256'] ?? '';
const expected =
'sha256=' +
createHmac('sha256', secret).update(event.rawBody ?? '').digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
throw new Error('invalid signature');
}
const body = (event.body ?? {}) as {
challenge?: string;
metadata?: { twentyWorkspaceId?: string };
type?: string;
};
// Handshakes must be answered on this same response, so reply from the
// resolver instead of returning a dispatch target.
if (body.type === 'url_verification') {
return new Response({ challenge: body.challenge });
}
const workspaceId = body.metadata?.twentyWorkspaceId;
if (!workspaceId) {
throw new Error('event is not linked to a workspace');
}
return {
workspaceId,
// Route different event types to different target functions.
targetLogicFunctionUniversalIdentifier:
body.type === 'invoice.paid'
? 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10' // handle-invoice-paid
: 'd5f3b0c2-8e5f-5d0b-a0c3-3f2e7b5d9f21', // handle-other-event
};
};
export default defineLogicFunction({
universalIdentifier: 'b3c2f0a1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
name: 'resolve-server-route',
handler,
serverRouteTriggerSettings: {
forwardedRequestHeaders: ['x-hub-signature-256'],
},
});
```
```ts src/logic-functions/handle-invoice-paid.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/logic-function';
// Runs in the resolved workspace. The resolver has already authenticated
// the request, so this handler can focus on the actual work.
const handler = async (event: RoutePayload) => {
// ...handle the verified event
return { received: true };
};
export default defineLogicFunction({
universalIdentifier: 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
name: 'handle-invoice-paid',
handler,
});
```
该端点可在以下地址访问:
```
POST https://your-twenty-server.com/webhooks/server/:resolverLogicFunctionUniversalIdentifier
```
该标识符是清单(manifest)中 resolver 的 `universalIdentifier`。 在服务商处注册该 URL。
**响应验证 GET 请求。** 某些提供商会在向端点传递内容之前对其进行验证,方法是向其稍后将 `POST` 事件到的同一 URL 发送携带质询的 `GET` 请求——Meta 的 WhatsApp Cloud API 就是其中之一。 除非另有指定,否则服务器路由仅响应 `POST`,因此请声明两种方法:
```ts
serverRouteTriggerSettings: {
forwardedRequestHeaders: ['x-hub-signature-256'],
httpMethods: ['GET', 'POST'],
}
```
质询会在 `event.queryStringParameters` 中到达,返回 `Response` 会在同一请求中将其回传给提供商。 字符串主体会以 `text/plain` 发送,这正是这些提供商所期望的:
```ts
const handler = async (event: RoutePayload) => {
if (event.requestContext.http.method === 'GET') {
return new Response(event.queryStringParameters['hub.challenge']);
}
// …handle the delivered event
};
```
`httpMethods` 会替换默认值,而不是在其基础上添加,因此仅使用 `['GET']` 会使路由拒绝 `POST`。 仅支持 `GET` 和 `POST`。 除非提供商需要第二种方法,否则请将其留空:声明 `GET` 的路由会让任何未经身份验证的调用方执行其解析器,包括会在未提示的情况下发送 `GET` 的爬虫和链接展开器。 对于平台没有对应方法的任何请求,都会返回 `405`,且解析器不会运行。
<Note>
**应用程序必须在其所属工作区中被认领并安装。** 由于 resolver 在**所属工作区**中运行(即拥有应用程序注册的工作区),服务器路由触发器只有在应用程序已被*认领*——也就是说它已有一个所属工作区——**并且**该应用程序**已安装在所属工作区**之后才会生效。 在这两个条件都满足之前,resolver 无处可运行,因此无法分发该路由。 因此,暴露 `serverRouteTriggerSettings` 逻辑函数的应用程序在被认领并安装到其所属工作区之前,不能在 marketplace 中列出。
</Note>
**Resolver 合约。** SDK 的 `LogicFunctionConfig` 类型在编译时强制执行这一点:一旦你设置了 `serverRouteTriggerSettings`,你的处理程序就被限制为返回 `Response`,或 `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }`(或它们之一的 `Promise`)。 在分发(dispatch)路径上,`workspaceId` 必须是已安装目标函数的工作区,否则请求会以 `404` 被拒绝。 如果结果不符合这两种结构中的任何一种——包括标识符不是 UUID 的情况——将会以 `502` 被拒绝。
| 字段 | 类型 | 备注 |
| ---------------------------------------- | ------------ | -------------------------------------- |
| `workspaceId` | `string` | 目标将运行所在的工作区 UUID。 |
| `targetLogicFunctionUniversalIdentifier` | `string` | 要在该工作区中调用的逻辑函数的 `universalIdentifier`。 |
| `payload` | `object`(可选) | 如果设置,将会替换发送到目标的请求正文。 |
<Warning>
**签名验证由你负责——请在 resolver 中进行验证。** 平台不会验证请求签名。 resolver 是执行验证的推荐位置:它最先运行,可以访问 `event.rawBody` 以及你在 `forwardedRequestHeaders` 中列出的请求头,并且只要抛出错误(或返回任意不匹配的 `workspaceId`),就会在调用目标之前停止分发。 如果你反而将验证下推到 target 中,那么 target 必须小心不要丢失 `rawBody` 和请求头——也就是说,resolver 不应返回 `payload`。 始终在产生任何副作用**之前**进行验证,并使用常量时间比较。
</Warning>
对于请求签名,大多数服务商使用 HMAC-SHA256 进行签名;不同之处在于请求头名称、摘要编码方式以及被签名的负载字符串。 例如:
| 提供商 | 要转发的请求头 | 签名字符串 | 摘要 |
| ------------------------- | ------------------------------------------------------ | ---------------------------- | ---------------------------------- |
| Svix(Recall、Resend、Clerk) | `webhook-id`, `webhook-timestamp`, `webhook-signature` | `{id}.{timestamp}.{rawBody}` | base64(密钥在去掉 `whsec_` 前缀后为 base64) |
| Stripe | `stripe-signature` | `{timestamp}.{rawBody}` | hex |
| GitHub | `x-hub-signature-256` | `{rawBody}` | hex(前缀为 `sha256=`) |
| Shopify | `x-shopify-hmac-sha256` | `{rawBody}` | base64 |
| Slack | `x-slack-signature`, `x-slack-request-timestamp` | `v0:{timestamp}:{rawBody}` | hex(前缀为 `v0=`) |
上面的 resolver 示例已经展示了 GitHub 的 HMAC-SHA256 流程——请根据你要集成的服务商,调整请求头名称、摘要编码方式以及被签名的负载字符串。
<Note>
当 resolver 返回一个分发对象时,该路由会响应 `202 { queued: true }`,并在工作队列上运行目标函数——调用方不会感知目标函数的延迟、结果或失败(这些都会记录在执行日志中)。 这可以防止发送方的重新投递放大处理变慢的问题,而这正是你在处理 webhook 摄取时所需要的。
当调用方必须在同一个请求中读取响应体(挑战握手、交互式确认)时,请改为从 **resolver** 返回一个 `Response`。 平台会同步回显该响应并跳过队列;其响应头会通过与 HTTP 路由响应相同的允许列表进行过滤。 保持 resolver 足够快速——某些服务商(例如 Slack)会在几秒内超时。 由于 resolver 可以作为公共端点访问,请在边缘(edge)对其进行速率限制保护。
</Note>
#### 数据库事件触发器有效负载
当数据库事件触发器调用你的逻辑函数时,每条被更改的记录都会对应一个 `DatabaseEventPayload`。 该负载将关于源工作区和对象的元数据与记录级事件组合在一起。
```ts
import type {
DatabaseEventPayload,
ObjectRecordCreateEvent,
ObjectRecordDestroyEvent,
ObjectRecordUpdateEvent,
} from 'twenty-sdk/logic-function';
type Person = {
id: string;
emails?: { primaryEmail?: string };
};
```
有效负载包括:
| 属性 | 描述 |
| ------------------------------------------------ | ------------------------------------------------------------ |
| `name` | 事件名称,例如 `person.updated`。 |
| `workspaceId` | 事件发生的工作区。 |
| `objectMetadata` | 已更改对象的元数据。 |
| `recordId` | 已更改记录的 ID。 |
| `userId`, `userWorkspaceId`, `workspaceMemberId` | 当事件由工作区用户触发时的操作者字段。 |
| `properties` | 事件的记录数据,根据操作不同,包含 `before`、`after`、`diff` 和 `updatedFields`。 |
| 事件 | 记录数据 |
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
| `person.created` | `event.properties.after` |
| `person.updated` | `event.properties.before`, `event.properties.after`, `event.properties.diff`, `event.properties.updatedFields` |
| `person.destroyed` | `event.properties.before` |
对于软删除,`.deleted` 遵循更新样式的结构,因为记录的 `deletedAt` 字段发生了变化。
对于永久删除,请使用 `.destroyed`。
<Note>
`databaseEventTriggerSettings.updatedFields` 会筛选出哪些更新事件会触发该函数。
`event.properties.updatedFields` 告诉你在当前事件中哪些字段实际发生了变化。
</Note>
创建事件示例:
```ts
type PersonCreatedEvent = DatabaseEventPayload<
ObjectRecordCreateEvent<Person>
>;
const handler = async (event: PersonCreatedEvent) => {
const person = event.properties.after;
return {
personId: event.recordId,
email: person.emails?.primaryEmail,
};
};
```
更新事件示例:
```ts
type PersonUpdatedEvent = DatabaseEventPayload<
ObjectRecordUpdateEvent<Person>
>;
const handler = async (event: PersonUpdatedEvent) => {
const { before, after, diff, updatedFields } = event.properties;
return {
personId: event.recordId,
updatedFields,
previousEmail: before.emails?.primaryEmail,
currentEmail: after.emails?.primaryEmail,
emailDiff: diff.emails,
};
};
```
仅在 email 更新时触发:
```ts
export default defineLogicFunction({
...,
databaseEventTriggerSettings: {
eventName: 'person.updated',
updatedFields: ['emails'],
},
});
```
销毁事件示例:
```ts
type PersonDestroyedEvent = DatabaseEventPayload<
ObjectRecordDestroyEvent<Person>
>;
const handler = async (event: PersonDestroyedEvent) => {
const personBeforeDestroy = event.properties.before;
return {
personId: event.recordId,
email: personBeforeDestroy.emails?.primaryEmail,
};
};
```
#### 执行上下文
每个处理程序都会收到第二个参数,用于描述运行本身,无论是什么触发了它。 第一个参数会因触发器而具有不同的结构,而这个参数不会:
```ts
import type { LogicFunctionExecutionContext } from 'twenty-sdk/logic-function';
const handler = async (
event: DatabaseEventPayload,
{ workspaceId, workspaceMemberId }: LogicFunctionExecutionContext,
) => {
if (workspaceMemberId === null) {
throw new Error('This function must be triggered by a person');
}
return { reviewedBy: workspaceMemberId, workspaceId };
};
```
| 字段 | 类型 | 描述 |
| ------------------- | ---------------- | --------------------------------------------------------------------- |
| `workspaceId` | `string` | 该运行所属的工作区。 |
| `userWorkspaceId` | `string \| null` | 触发该运行的人员,以其作为此工作区成员的身份。 |
| `workspaceMemberId` | `string \| null` | 同一人员的工作区成员记录,也就是你链接记录时使用的对象。 |
| `retryCount` | `number` | 首次尝试时为 `0`。 请参阅[后台作业](/l/zh/developers/extend/apps/logic/background-jobs)。 |
| `maxRetries` | `number` | 应用程序请求重试次数的上限。 |
当没有人触发运行时,`userWorkspaceId` 和 `workspaceMemberId` 为 `null`:cron 计划、安装钩子和未经身份验证的 Webhook 背后没有人员。 当该人员没有工作区成员记录,或者其记录已被删除时,它们也为 `null`。
<Note>
**上下文会告诉你是谁触发了运行。** 运行可以*执行*什么操作则是下面单独的声明。
</Note>
#### 调用使用谁的访问权限
每个客户端 — `CoreApiClient`、`MetadataApiClient` 和 `RestApiClient` — 都会以触发运行的人员身份执行操作:其角色与你的应用程序角色相交,因此调用所能执行的操作绝不会超过你们任一方的权限。 这是默认行为,意味着人员绝不能使用你的应用超出其自身权限。
当没有人触发运行时,没有可代为执行操作的人员,因此同一客户端会回退到使用你的应用程序自身访问权限:cron 计划、安装钩子和未经身份验证的 Webhook 无需特殊处理。
即使运行背后*有*人员,某些调用也确实需要应用程序自身的访问权限 — 例如读取应用的配置记录,或执行该人员自己无法完成的工作。 为这些情况构建第二个客户端:
```ts
import { CoreApiClient } from 'twenty-client-sdk/core';
const asPerson = new CoreApiClient();
const asApplication = new CoreApiClient({ runAs: 'application' });
const handler = async (event: DatabaseEventPayload) => {
// config records only your app may read
const { slackUserLinks } = await asApplication.query({ ... });
// created as the person, so it is attributed and permission-checked
await asPerson.mutation({ createNote: { ... } });
};
```
在模块作用域中一次性构建两个客户端,之后每个调用位置都会通过所调用的客户端表明其使用的访问权限。 `RestApiClient` 接受相同的选项:
```ts
import { RestApiClient } from 'twenty-client-sdk/rest';
const asApplication = new RestApiClient({ runAs: 'application' });
```
<Note>
**无人触发的运行会以你的应用程序身份执行。** Cron 计划、安装钩子和未经身份验证的 Webhook 背后没有人员,因此默认客户端会回退到使用你的应用程序自身访问权限,并继续正常工作。 仅当你希望在由人员*触发*的运行中使用该访问权限时,才需要 `runAs: 'application'`。
当函数的行为取决于其背后是否有人员时,请检查 `context.workspaceMemberId`,例如用于为记录归属。
</Note>
用于访问你的应用程序自身资源的 SDK 辅助工具始终使用其访问权限,并忽略 `runAs`:包括[键值存储](/l/zh/developers/extend/apps/logic/key-value-store)、[连接](/l/zh/developers/extend/apps/logic/connections)、`runAgent`、`getPublicAssetUrl` 和积分扣费。
#### 将函数公开为 AI 工具或工作流操作
逻辑函数可以在两个入口对外公开,每个入口都有各自的触发器:
* **`toolTriggerSettings`** — 使该函数可被 Twenty 的 AI 功能(chat、MCP、function calling)发现。 使用标准 JSON Schema,LLM 能够原生理解的格式。
* **`workflowActionTriggerSettings`** — 使该函数在可视化工作流构建器中显示为一个步骤。 使用 Twenty 丰富的 `InputSchema`,以便构建器可以呈现合适的字段编辑器、变量选择器和标签。
函数可以选择加入其中一个、另一个,或两者都加入。 它们与 `cronTriggerSettings`、`databaseEventTriggerSettings` 和 `httpRouteTriggerSettings` 并列 — 相同的模式、相同的结构。
<Note>
**与工作流 Code 动作的关系。** 工作流构建器中的内置 **Code** 动作本身就是一个逻辑函数 —— Twenty 会为每个 Code 步骤创建一个逻辑函数,并在行内展示其编辑器。 `workflowActionTriggerSettings` 是将一次性行内代码转换为**可复用**动作的方式:在你的应用中定义一次该函数,它就可以在任意工作流中被选择,而不需要在每个 Code 步骤中复制粘贴。 请参阅用户指南中的[Code 动作](/l/zh/user-guide/workflows/capabilities/workflow-actions#code)以了解终端用户视图。
</Note>
```ts src/logic-functions/enrich-company.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async (params: { companyName: string; domain?: string }) => {
const client = new CoreApiClient();
const result = await client.mutation({
createTask: {
__args: {
data: {
title: `Enrich data for ${params.companyName}`,
body: `Domain: ${params.domain ?? 'unknown'}`,
},
},
id: true,
},
});
return { taskId: result.createTask.id };
};
export default defineLogicFunction({
universalIdentifier: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
name: 'enrich-company',
description: 'Enrich a company record with external data',
timeoutSeconds: 10,
handler,
toolTriggerSettings: {},
});
```
关键点:
* 函数可以混用这些入口 — 同时声明 `toolTriggerSettings` 和 `workflowActionTriggerSettings`,即可在 chat 和工作流构建器中同时公开它。
* `toolTriggerSettings.inputSchema` 和 `workflowActionTriggerSettings.inputSchema` 均为可选。 如果省略,清单构建器会根据处理器源代码进行推断(AI 工具使用 JSON Schema,工作流操作使用 Twenty 的 `InputSchema`)。 当你需要更丰富的类型时,可显式提供一个 — 例如,在工作流构建器中使用对 `FieldMetadataType` 友好的字段(如 `CURRENCY` 或 `RELATION`),或提供 AI 智能体可读取的 `description` 字段:
```ts
export default defineLogicFunction({
...,
toolTriggerSettings: {
inputSchema: {
type: 'object',
properties: {
companyName: {
type: 'string',
description: 'The name of the company to enrich',
},
domain: {
type: 'string',
description: 'The company website domain (optional)',
},
},
required: ['companyName'],
},
},
});
```
要只声明**一次**参数并服务于这两种界面,请定义一个单个 JSON Schema(`InputJsonSchema`),并使用来自 `twenty-sdk/logic-function` 的 `jsonSchemaToInputSchema` 将其转换为工作流操作所用。 `toolTriggerSettings.inputSchema` 直接接受 JSON Schema,而 `workflowActionTriggerSettings.inputSchema` 需要的是 Twenty 的 `InputSchema`:
```ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema, type InputJsonSchema } from 'twenty-sdk/logic-function';
const inputSchema: InputJsonSchema = {
type: 'object',
properties: {
companyName: { type: 'string', label: 'Company name' },
domain: { type: 'string', label: 'Domain' },
},
required: ['companyName'],
};
export default defineLogicFunction({
...,
toolTriggerSettings: { inputSchema },
workflowActionTriggerSettings: {
label: 'Enrich Company',
icon: 'IconBuilding',
inputSchema: jsonSchemaToInputSchema(inputSchema),
},
});
```
##### 完整的工作流动作示例
`workflowActionTriggerSettings` 接受四个字段:
| 字段 | 目的 |
| -------------- | --------------------------------------------------------------------------- |
| `label` | 在工作流构建器的步骤选择器中为该动作显示的名称。 默认为函数的 `name`。 |
| `icon` | 在该动作旁边显示的图标(一个 `tabler-icons` 名称,例如 `IconBuilding`)。 |
| `inputSchema` | Twenty 提供的功能丰富的 `InputSchema` —— 构建器会将其渲染为可配置字段(带变量选择器)。 可选;若省略,则会从处理函数中推断。 |
| `outputSchema` | 声明处理函数返回的数据结构,这样**后续步骤就可以映射到它的输出字段**。 可选;如果不提供,输出会作为单个不透明值暴露。 |
把上述内容组合在一起 —— 将一个函数暴露为工作流动作,并声明输出,以便后续步骤可以引用 `taskId`:
```ts src/logic-functions/enrich-company.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema, type InputJsonSchema } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
const inputSchema: InputJsonSchema = {
type: 'object',
properties: {
companyName: { type: 'string', label: 'Company name' },
domain: { type: 'string', label: 'Domain' },
},
required: ['companyName'],
};
const handler = async (params: { companyName: string; domain?: string }) => {
const client = new CoreApiClient();
const result = await client.mutation({
createTask: {
__args: {
data: {
title: `Enrich data for ${params.companyName}`,
body: `Domain: ${params.domain ?? 'unknown'}`,
},
},
id: true,
},
});
// The keys returned here should match the `outputSchema` properties below.
return { taskId: result.createTask.id, enriched: true };
};
export default defineLogicFunction({
universalIdentifier: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
name: 'enrich-company',
description: 'Enrich a company record with external data',
timeoutSeconds: 10,
handler,
workflowActionTriggerSettings: {
label: 'Enrich Company',
icon: 'IconBuilding',
inputSchema: jsonSchemaToInputSchema(inputSchema),
outputSchema: [
{
type: 'object',
properties: {
taskId: { type: 'string' },
enriched: { type: 'boolean' },
},
},
],
},
});
```
应用安装完成后,**Enrich Company** 会出现在工作流构建器的动作选择器中。 构建器会将 `companyName` 和 `domain` 渲染为输入字段(每个字段都可以从前面步骤中获取值),而下游步骤可以引用该步骤的 `taskId` 和 `enriched` 输出。
<Note>
**写一个好的 `description`。** AI 智能体会依赖该函数的 `description` 字段来决定何时使用该工具。 明确说明该工具的作用以及应在何时调用。
</Note>
</Accordion>
</AccordionGroup>
<Note>
**运行时辅助工具。** `twenty-sdk/utils` 会重新导出一些小型运行时辅助工具,这样处理程序就不需要直接从 `twenty-shared` 导入。 例如,`isDefined(value)` 对 `null` 和 `undefined` 都会返回 `false` —— 使用它可以安全地收窄可选处理程序输入的类型,因为即使类型标注为 `T | undefined`,在运行时它们仍可能以 `null` 的形式传入:
```ts
import { isDefined } from 'twenty-sdk/utils';
const handler = async (params: { parentMessageId?: string }) => {
if (isDefined(params.parentMessageId)) {
// params.parentMessageId is narrowed to string here
}
};
```
</Note>
<Note>
**安装 hooks**——预安装、后安装和卸载处理程序——共享此运行时,但使用它们自己的 define 函数进行声明,并且不接受触发器设置。 有关 `definePreInstallLogicFunction`、`definePostInstallLogicFunction` 和 `defineUninstallLogicFunction`,请参阅 [Install Hooks](/l/zh/developers/extend/apps/config/install-hooks)。
</Note>
## 创建时间线活动
使用 `createTimelineActivity()` 从逻辑函数发布显式领域事件。 先将事件定义为一种[时间线活动类型](/l/zh/developers/extend/apps/data/timeline-activity-types),然后通过其稳定的通用标识符来指定该类型和对象:
```ts
import { createTimelineActivity } from 'twenty-sdk/logic-function';
await createTimelineActivity({
timelineActivityTypeUniversalIdentifier:
'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7501',
targetObjectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
targetRecordId: postCardId,
happensAt: new Date().toISOString(),
properties: {
deliveryProvider: 'postal-service',
},
});
```
Twenty 会将通用标识符解析为特定于安装的元数据 ID,验证时间线活动类型属于调用应用,并在新活动上对其展示元数据创建快照。
必需的输入为 `timelineActivityTypeUniversalIdentifier`、`targetObjectUniversalIdentifier` 和 `targetRecordId`。 您还可以提供 `happensAt`、`properties` 和 `workspaceMemberId`。 `happensAt` 控制事件在时间线中的位置和显示时间;默认值为创建时间。
要将另一条记录与事件关联,请同时提供 `linkedRecordId` 和 `linkedObjectMetadataUniversalIdentifier`。 您还可以提供 `linkedRecordCachedName` 作为历史显示回退值:
```ts
await createTimelineActivity({
timelineActivityTypeUniversalIdentifier:
'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7501',
targetObjectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
targetRecordId: postCardId,
linkedObjectMetadataUniversalIdentifier: RECIPIENT_UNIVERSAL_IDENTIFIER,
linkedRecordId: recipientId,
linkedRecordCachedName: recipientName,
});
```
逻辑函数的角色需要对标准 `timelineActivity` 对象具有写入权限。 保持显式事件类型不绑定 `action`;绑定操作的类型已接收自动审计事件,否则会产生重复行。
## 类型化 API 客户端(`twenty-client-sdk`)
`twenty-client-sdk` 包提供了两个类型化的 GraphQL 客户端,供你的逻辑函数和前端组件与 Twenty API 交互。
| 客户端 | 导入 | 端点 | 是否生成? |
| ------------------- | ---------------------------- | ------------------------ | --------- |
| `CoreApiClient` | `twenty-client-sdk/core` | `/graphql`——工作区数据(记录、对象) | 是,在开发/构建时 |
| `MetadataApiClient` | `twenty-client-sdk/metadata` | `/metadata`——工作区配置、文件上传 | 否,已预构建提供 |
<AccordionGroup>
<Accordion title="CoreApiClient" description="查询和变更工作区数据(记录、对象)">
`CoreApiClient` 是用于查询和变更工作区数据的主要客户端。 它会在执行 `yarn twenty dev` 或 `yarn twenty dev:build` 时**根据你的工作区架构生成**,因此具有完整的类型定义以匹配你的对象和字段。
```ts
import { CoreApiClient } from 'twenty-client-sdk/core';
const client = new CoreApiClient();
// Query records
const { companies } = await client.query({
companies: {
edges: {
node: {
id: true,
name: true,
domainName: {
primaryLinkLabel: true,
primaryLinkUrl: true,
},
},
},
},
});
// Create a record
const { createCompany } = await client.mutation({
createCompany: {
__args: {
data: {
name: 'Acme Corp',
},
},
id: true,
name: true,
},
});
```
该客户端使用选择集语法:传入 `true` 以包含某字段,使用 `__args` 传递参数,并通过嵌套对象表示关系。 你将基于工作区架构获得完整的自动补全和类型检查。
<Note>
**CoreApiClient 在开发/构建时生成。** 如果在未先运行 `yarn twenty dev` 或 `yarn twenty dev:build` 的情况下尝试使用它,将会抛出错误。 该生成过程是自动完成的——CLI 会自省你的工作区 GraphQL 架构,并使用 `@genql/cli` 生成类型化客户端。
</Note>
#### 使用 CoreSchema 进行类型标注
`CoreSchema` 提供与工作区对象相匹配的 TypeScript 类型,可用于为组件状态或函数参数进行类型标注:
```ts
import { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
import { useState } from 'react';
const [company, setCompany] = useState<
Pick<CoreSchema.Company, 'id' | 'name'> | undefined
>(undefined);
const client = new CoreApiClient();
const result = await client.query({
company: {
__args: { filter: { position: { eq: 1 } } },
id: true,
name: true,
},
});
setCompany(result.company);
```
</Accordion>
<Accordion title="MetadataApiClient" description="工作区配置、应用和文件上传">
`MetadataApiClient` 随 SDK 一并提供,已预构建(无需生成)。 它会查询 `/metadata` 端点以获取工作区配置、应用和文件上传。 它接受与 `CoreApiClient` 相同的 `runAs` 选项 — 请参阅[调用使用谁的访问权限](#whose-access-a-call-uses)。
```ts
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
const metadataClient = new MetadataApiClient();
// List first 10 objects in the workspace
const { objects } = await metadataClient.query({
objects: {
edges: {
node: {
id: true,
nameSingular: true,
namePlural: true,
labelSingular: true,
isCustom: true,
},
},
__args: {
filter: {},
paging: { first: 10 },
},
},
});
```
#### 上传文件
`MetadataApiClient` 包含一个 `uploadFile` 方法,用于将文件附加到文件类型字段:
```ts
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import * as fs from 'fs';
const metadataClient = new MetadataApiClient();
const fileBuffer = fs.readFileSync('./invoice.pdf');
const uploadedFile = await metadataClient.uploadFile(
fileBuffer, // file contents as a Buffer
'invoice.pdf', // filename
'application/pdf', // MIME type
'58a0a314-d7ea-4865-9850-7fb84e72f30b', // field universalIdentifier
);
console.log(uploadedFile);
// { id: '...', path: '...', size: 12345, createdAt: '...', url: 'https://...' }
```
| 参数 | 类型 | 描述 |
| ---------------------------------- | -------- | -------------------------------------------- |
| `fileBuffer` | `Buffer` | 原始文件内容 |
| `filename` | `string` | 文件名称(用于存储和显示) |
| `contentType` | `string` | MIME 类型(如果省略,默认为 `application/octet-stream`) |
| `fieldMetadataUniversalIdentifier` | `string` | 你的对象上文件类型字段的 `universalIdentifier` |
关键点:
* 使用字段的 `universalIdentifier`(而不是其工作区特定的 ID),因此你的上传代码可在安装了你的应用的任何工作区中运行。
* 返回的 `url` 是一个签名 URL,你可以用它来访问已上传的文件。
</Accordion>
</AccordionGroup>
<Note>
当你的代码在 Twenty 上运行(逻辑函数或前端组件)时,平台会以环境变量的形式注入凭据:
* `TWENTY_API_URL`——Twenty API 的基础 URL
* `TWENTY_APP_ACCESS_TOKEN` — 用于默认访问权限的短期密钥:当运行背后有人员时,使用该人员角色与你的应用程序角色的交集;当没有人员时,使用你的应用程序自身角色。 在逻辑函数中,该人员是触发运行的人;在前端组件中,则是正在查看页面的人。
* `TWENTY_APP_APPLICATION_ACCESS_TOKEN` — 仅限定于你的应用程序自身角色的短期密钥。 仅用于逻辑函数,始终会在其中注入,并由 `runAs: 'application'` 使用。
你**不**需要将这些传递给客户端 — 它们会自动从 `process.env` 读取,而[调用使用谁的访问权限](#whose-access-a-call-uses)介绍了如何在它们之间进行选择。 你的应用程序自身权限由通过 `defineApplicationRole()` 声明的角色(或在 `application-config.ts` 中通过 `defaultRoleUniversalIdentifier` 引用的角色)决定;以人员身份执行的运行绝不能超出该角色或该人员角色的权限。
</Note>

View File

@@ -0,0 +1,61 @@
---
title: 概览
description: 在 Twenty 内运行的服务端 TypeScript,可由 HTTP 路由、Cron 调度、数据库事件、AI 工具或工作流动作触发。
icon: bolt
---
Twenty 应用的 **逻辑层** 是实际*运行*的代码——用于响应 HTTP 请求、cron 调度和记录变更的服务端 TypeScript 处理程序;存在于工作区内的 AI 技能和智能体;以及让你的函数能够代表用户在第三方服务中执行操作的 OAuth 连接。
```text
┌─ HTTP route ──┐
│ Cron schedule │
│ Database event │ ┌────────────────────┐
triggers ─┤ AI tool call ├─────▶│ Logic function │
│ Workflow action │ │ (your handler) │
│ Manual exec │ └────────────────────┘
└────────────────────┘ │
▼
┌────────────────────────────┐
│ Twenty API (records) │
│ Third-party API │
│ (via Connection token) │
└────────────────────────────┘
```
## 本节内容
<CardGroup cols={2}>
<Card title="逻辑函数" icon="bolt" href="/l/zh/developers/extend/apps/logic/logic-functions">
核心构建块——触发器类型、载荷以及类型化的 API 客户端。
</Card>
<Card title="技能与智能体" icon="robot" href="/l/zh/developers/extend/apps/logic/skills-and-agents">
可复用的 AI 智能体指令和助手,支持自定义系统提示词。
</Card>
<Card title="连接" icon="plug" href="/l/zh/developers/extend/apps/logic/connections">
你的应用为第三方服务(如 Linear、GitHub、Slack 等)持有的 OAuth 凭证。
</Card>
<Card title="键值存储" icon="database" href="/l/zh/developers/extend/apps/logic/key-value-store">
在逻辑函数的多次运行之间持久化状态——缓存、游标和跨工作区声明。
</Card>
<Card title="后台作业" icon="layer-group" href="/l/zh/developers/extend/apps/logic/background-jobs">
将逻辑函数排入队列,由工作进程运行,以避开单次运行超时限制。
</Card>
</CardGroup>
## 触发器类型一览
逻辑函数选择一个或多个触发器——下面的每一项都是 `defineLogicFunction()` 上的一个独立字段:
| 触发器 | 触发时机 | 设置 |
| ----------- | --------------------------------------- | ------------------------------- |
| **HTTP 路由** | 请求命中你的 `/s/\<path>` 端点 | `httpRouteTriggerSettings` |
| **Cron** | 匹配到一个 CRON 表达式时 | `cronTriggerSettings` |
| **数据库事件** | 当工作区记录被创建、更新或删除时 | `databaseEventTriggerSettings` |
| **AI 工具** | 某个 Twenty AI 功能决定调用你的函数时 | `toolTriggerSettings` |
| **工作流动作** | 当工作流步骤调用你的函数时 | `workflowActionTriggerSettings` |
函数在隔离的 Node.js 进程沙箱中运行,并通过限定在 [`defineApplication()`](/l/zh/developers/extend/apps/config/application) 上声明角色范围内的类型化 API 客户端访问工作区。
<Note>
**安装时钩子(Install-time hooks)**——在安装前或安装后运行的代码——与上述共享同一运行时,但使用它们自己的 define 函数,并位于 [Config → Install Hooks](/l/zh/developers/extend/apps/config/install-hooks) 下。
</Note>

View File

@@ -0,0 +1,179 @@
---
title: 技能与智能体
description: 为你的应用定义 AI 技能和智能体。
icon: robot
---
<Warning>
技能和智能体目前处于 Alpha 阶段。 该功能可用,但仍在演进中。
</Warning>
应用可以定义存在于工作区内的 AI 能力——可复用的技能指令以及具有自定义系统提示词的智能体。
<AccordionGroup>
<Accordion title="defineSkill" description="定义 AI 智能体技能">
技能定义了可复用的指令和能力,AI 智能体可在你的工作区中使用。 使用 `defineSkill()` 定义带内置校验的技能:
```ts src/skills/example-skill.ts
import { defineSkill } from 'twenty-sdk/define';
export default defineSkill({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
name: 'sales-outreach',
label: 'Sales Outreach',
description: 'Guides the AI agent through a structured sales outreach process',
icon: 'IconBrain',
content: `You are a sales outreach assistant. When reaching out to a prospect:
1. Research the company and recent news
2. Identify the prospect's role and likely pain points
3. Draft a personalized message referencing specific details
4. Keep the tone professional but conversational`,
});
```
关键点:
* `name` 是该技能的唯一标识字符串(推荐使用 kebab-case)。
* `label` 是在 UI 中显示的人类可读名称。
* `content` 包含技能指令——这是 AI 智能体使用的文本。
* `icon`(可选)设置在 UI 中显示的图标。
* `description`(可选)提供有关技能用途的更多上下文。
</Accordion>
<Accordion title="defineAgent" description="使用自定义提示词定义 AI 智能体">
智能体是在你的工作区内驻留的 AI 助手。 使用 `defineAgent()` 来创建带有自定义系统提示词的智能体:
```ts src/agents/example-agent.ts
import { defineAgent } from 'twenty-sdk/define';
export default defineAgent({
universalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
name: 'sales-assistant',
label: 'Sales Assistant',
description: 'Helps the sales team draft outreach emails and research prospects',
icon: 'IconRobot',
prompt: 'You are a helpful sales assistant. Help users with their questions and tasks.',
});
```
关键点:
* `name` 是该智能体的唯一标识字符串(推荐使用 kebab-case)。
* `label` 是在 UI 中显示的名称。
* `prompt` 是定义智能体行为的系统提示词。
* `description`(可选)提供有关智能体功能的上下文。
* `icon`(可选)设置在 UI 中显示的图标。
* `modelId`(可选)会覆盖该智能体使用的默认 AI 模型。
* `responseFormat`(可选)控制代理输出的结构形式。 对于自由格式文本,默认值为 `{ type: 'text' }`。 使用 `{ type: 'json', schema }` 来强制生成结构化 JSON 输出。
默认情况下,代理返回自由格式文本。 要获取结构化输出,将 `responseFormat` 设置为 `{ type: 'json' }`,并提供一个 `schema`:
```ts src/agents/structured-agent.ts
import { defineAgent } from 'twenty-sdk/define';
export default defineAgent({
universalIdentifier: 'c4d5e6f7-a8b9-0123-cdef-456789012345',
name: 'lead-scorer',
label: 'Lead Scorer',
prompt: 'Score the lead and explain your reasoning.',
responseFormat: {
type: 'json',
schema: {
type: 'object',
properties: {
score: { type: 'number', description: 'Lead score from 0 to 100' },
summary: { type: 'string', description: 'Short reasoning for the score' },
},
required: ['score', 'summary'],
additionalProperties: false,
},
},
});
```
架构说明:
* 该架构是一个扁平对象:每个属性的 `type` 必须是原始类型(`string`、`number` 或 `boolean`)。 不支持嵌套对象和数组。
* 每个属性上的 `description`(可选)用于引导模型应在此处填入什么内容。
* `required`(可选)列出模型必须始终返回的属性。
* `additionalProperties: false`(可选)禁止任何未在 `properties` 中声明的属性。
</Accordion>
<Accordion title="runAgent" description="从逻辑函数中运行代理">
`runAgent()` 允许逻辑函数运行你的应用的某个代理(及其技能和工具)。 通过你传递给 `defineAgent()` 的 `universalIdentifier` 来标识该代理。 只传入 `prompt` 字符串或 `messages` 会话历史其中之一,不要同时传入两者:
```ts src/logic-functions/run-enricher.ts
import { runAgent } from 'twenty-sdk/logic-function';
const { result, error, success } = await runAgent({
agentUniversalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
prompt: 'Enrich House Ad <recordId>: fill empty fields from its listing URL.',
});
```
对于多轮对话机器人(Slack、Discord、Teams 等),将线程历史作为 `messages` 传入,而不是单个 `prompt`:
```ts src/logic-functions/reply-in-thread.ts
import { runAgent } from 'twenty-sdk/logic-function';
const { result, error, success } = await runAgent({
agentUniversalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
messages: [
{ role: 'user', content: 'Who owns Acme?' },
{ role: 'assistant', content: 'Sarah owns the Acme account.' },
{ role: 'user', content: 'What was the last touchpoint?' },
],
});
```
关键点:
* 提供 `prompt`(字符串)或 `messages`(1 到 100 个 `{ role: 'user' | 'assistant', content: string }` 条目)中的 **恰好一个**。
* 代理以**同步**方式运行,并且可以通过其自身的工具读取/更新记录 —— `runAgent()` 会在运行完成后才 resolve。
* 一个应用只能运行它自己的代理。
* 应用的[默认角色](/l/zh/developers/extend/apps/config/roles)必须授予 `AI` 权限标记 —— 在其 `permissionFlagUniversalIdentifiers` 中添加 `SystemPermissionFlag.AI`(或设置 `canAccessAllTools: true`)。
否则,`runAgent()` 会因权限错误而失败。
* 在逻辑函数上设置较大的 `timeoutSeconds` 值 —— 代理运行可能需要数秒时间。
* 当运行完成时,`success` 为 `true` 且 `result` 为非空;失败时,`success` 为 `false`,`result` 为 `null`,并且 `error` 保存失败原因(例如,当工作区在运行过程中耗尽 AI 额度时)。
```ts src/roles/default-role.ts
import { defineApplicationRole, SystemPermissionFlag } from 'twenty-sdk/define';
export default defineApplicationRole({
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
label: 'Default function role',
// runAgent() requires the AI permission flag on the app's default role.
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.AI],
});
```
<Warning>
\*\*避免循环:\*\*如果你从 `*.updated` 数据库事件触发器中调用 `runAgent()`,而代理又会更新同一条记录,请将触发器的 `updatedFields` 限定为代理永远不会写入的字段(例如来源 URL),或者在调用 `runAgent()` 之前检查任何目标字段是否仍为空。
</Warning>
### 以工作区成员身份运行
当运行是由某个用户触发时(例如,一个聊天机器人在回复消息),传入 `runAsWorkspaceMemberId`,这样智能体就会以该成员的身份而不是以应用自身的身份执行:
```ts src/logic-functions/answer-question.ts
const { result } = await runAgent({
agentUniversalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
prompt: 'How many open opportunities do we have?',
runAsWorkspaceMemberId: '20202020-0687-4c41-b707-ed1bfca972a7',
});
```
然后,此运行将以**成员自身的角色**执行:它可以执行该成员能够执行的所有操作且仅限于此,它创建的记录将归属于该成员,并且会应用该成员的行级权限。 智能体的角色不会参与——它是针对没有任何成员在背后参与的运行所使用的应用默认角色。 对于自主运行(计划任务、数据库事件触发器),省略该字段:这类运行会保持智能体自身的角色。 为保持来源追溯,执行操作的应用会附着在该运行的上下文中,但不会收窄该成员的权限。
你的应用负责将触发运行的用户映射到一个工作区成员。 要为某个成员命名,需要一个应用访问令牌,而令牌可以为谁命名,取决于它是否携带用户:
* **未绑定任何用户** 的令牌可以为任意成员命名。 逻辑函数使用这类令牌运行,通过 `client_credentials` 或由 API 密钥签发的令牌也属于此类。
* **代表某个用户签发的令牌**(例如前端组件接收到的令牌),只能为该用户自己的成员命名。
除此之外的任何情况——普通用户会话、没有应用访问令牌的 API 密钥——都不能为任何成员命名。
<Warning>
当工作区成员无法解析时——例如未知或已移除的成员,或者没有角色的成员——`runAgent()` 会抛出异常。 它绝不会回退到代理自身的角色,因为那样会授予比调用方请求更多的访问权限。
</Warning>
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,131 @@
---
title: CLI
description: yarn twenty 命令可用于执行函数、流式传输日志、管理应用安装以及切换远程。
icon: terminal
---
`yarn twenty` CLI 是与你的所有应用相关内容进行交互的接口。 完整命令列表:
| 命令 | 作用 | 记录于 |
| ----------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------- |
| `dev` | 监视源文件并实时同步更改 | [快速开始](/l/zh/developers/extend/apps/getting-started/quick-start) |
| `计划` | 在不应用变更的情况下预览元数据更改 | [同步与恢复](/l/zh/developers/extend/apps/operations/sync-and-recovery#previewing-changes-plan) |
| `应用` | 在显示计划后应用元数据更改 | [同步与恢复](/l/zh/developers/extend/apps/operations/sync-and-recovery) |
| `dev:build` | 编译应用并生成 API 客户端(使用 `--tarball` 打包为 `.tgz`) | [发布](/l/zh/developers/extend/apps/operations/publishing) |
| `dev:typecheck` | 运行 TypeScript 类型检查 | [测试](/l/zh/developers/extend/apps/operations/testing) |
| `dev:add` | 搭建一个新的实体脚手架 | [脚手架](/l/zh/developers/extend/apps/getting-started/scaffolding) |
| `dev:generate-client` | 重新生成类型化 API 客户端 | 本页面 |
| `dev:function:exec` / `dev:function:logs` | 执行函数并流式输出其日志 | 本页面 |
| `dev:translations-extract` | 将可翻译字符串提取到 `locales/` 目录下的目录文件中 | [翻译](/l/zh/developers/extend/apps/operations/translations) |
| `dev:catalog-sync` | 触发一次市场目录同步 | [发布](/l/zh/developers/extend/apps/operations/publishing#how-marketplace-discovery-works) |
| `app:publish` / `app:install` / `app:uninstall` | 发布生命周期 | [发布](/l/zh/developers/extend/apps/operations/publishing) 和本页面 |
| `docker:*` | 管理本地 Twenty 服务器容器 | [本地服务器](/l/zh/developers/extend/apps/getting-started/local-server) |
| `remote:*` | 管理服务器连接 | 本页面 |
每个命令都接受 `-r, --remote \<name>` 参数,以便针对特定远程目标而不是默认远程。
## 执行函数(`yarn twenty dev:function:exec`)
手动运行逻辑函数,而无需通过 HTTP、定时任务或数据库事件来触发:
```bash filename="Terminal"
# Execute by function name
yarn twenty dev:function:exec -n create-new-post-card
# Execute by universalIdentifier
yarn twenty dev:function:exec -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
# Pass a JSON payload
yarn twenty dev:function:exec -n create-new-post-card -p '{"name": "Hello"}'
# Execute the install and uninstall hooks
yarn twenty dev:function:exec --postInstall
yarn twenty dev:function:exec --preInstall
yarn twenty dev:function:exec --uninstall
```
## 查看函数日志(`yarn twenty dev:function:logs`)
实时流式查看你的应用逻辑函数的执行日志:
```bash filename="Terminal"
# Stream all function logs
yarn twenty dev:function:logs
# Filter by function name
yarn twenty dev:function:logs -n create-new-post-card
# Filter by universalIdentifier
yarn twenty dev:function:logs -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
```
<Note>
这与 `yarn twenty docker:logs` 不同,后者显示的是 Docker 容器日志。 `yarn twenty dev:function:logs` 会显示来自 Twenty 服务器的应用函数执行日志。
</Note>
## 生成类型化客户端(`yarn twenty dev:generate-client`)
根据当前远程的 schema 重新生成类型化 API 客户端(`twenty-client-sdk`),无需构建或同步应用。 在任何项目中使用它来获取一个类型化客户端——例如位于单独代码库中的后端服务——用于与您的 Twenty 实例通信:
```bash filename="Terminal"
# In your project (no Twenty app definition required)
yarn add twenty-sdk twenty-client-sdk
# Connect to the Twenty instance to generate the client from
yarn twenty remote:add
# Generate the typed client into node_modules/twenty-client-sdk
yarn twenty dev:generate-client
```
然后在代码中导入该客户端:
```typescript
import { CoreApiClient } from 'twenty-client-sdk/core';
```
每当数据模型发生更改时,请重新运行该命令,以刷新生成的类型。
<Note>
客户端是在 `node_modules` 中生成的,因此不会随代码一起提交。 每次安装后运行 `yarn twenty dev:generate-client`(例如在 `postinstall` 脚本或 CI 中)。
</Note>
## 卸载应用(`yarn twenty app:uninstall`)
将你的应用从活动工作区中移除:
```bash filename="Terminal"
yarn twenty app:uninstall
# Skip the confirmation prompt
yarn twenty app:uninstall --yes
```
## 管理远程
“远程”是指你的应用连接到的 Twenty 服务器。 在设置期间,脚手架工具会为你自动创建一个。 你可以随时添加更多远程或在它们之间切换。
```bash filename="Terminal"
# Add a new remote (opens a browser for OAuth login)
yarn twenty remote:add
# Connect to a local Twenty server (auto-detects port 2020 or 3000)
yarn twenty remote:add --local
# Add a remote non-interactively (useful for CI)
yarn twenty remote:add --url https://your-twenty-server.com --api-key $TWENTY_API_KEY --as my-remote
# List all configured remotes
yarn twenty remote:list
# Set the active remote
yarn twenty remote:use <name>
# Check that the active remote's authentication is still valid
yarn twenty remote:status
# Remove a remote
yarn twenty remote:remove <name>
```
你的凭据存储在 `~/.twenty/config.json` 中。

View File

@@ -0,0 +1,32 @@
---
title: 概览
description: 构建、测试并发布你的应用——CLI 命令、集成测试、CI,以及发布到服务器或 npm。
icon: rocket
---
**运维层**指的是你对应用*进行*而不是*使用*它所做的一切:调用 CLI 命令、针对真实的 Twenty 服务器运行集成测试、配置 CI,以及发布发行版——可以是部署到单个服务器的 tarball,也可以是发布到应用市场中列出的 npm 包。
```text
develop ─▶ test ─▶ build ─▶ deploy / publish
─────── ──── ───── ─────────────────
yarn yarn yarn yarn twenty app:publish --private (tarball → one server)
twenty test twenty
dev dev:build yarn twenty app:publish (npm → marketplace)
```
## 本节内容
<CardGroup cols={2}>
<Card title="CLI" icon="terminal" href="/l/zh/developers/extend/apps/operations/cli">
`yarn twenty` 参考——exec、logs、uninstall、remotes。
</Card>
<Card title="同步与恢复" icon="compass" href="/l/zh/developers/extend/apps/operations/sync-and-recovery">
在阅读同步差异时应使用哪些命令,以及分步恢复流程。
</Card>
<Card title="测试" icon="flask" href="/l/zh/developers/extend/apps/operations/testing">
Vitest 配置、集成测试、类型检查、CI 工作流。
</Card>
<Card title="发布" icon="upload" href="/l/zh/developers/extend/apps/operations/publishing">
构建、部署 tarball、发布到 npm、安装。
</Card>
</CardGroup>

View File

@@ -0,0 +1,293 @@
---
title: 发布
icon: upload
description: 将你的 Twenty 应用分发到应用市场,或进行内部部署。
---
## 概览
一旦你的应用已[在本地构建并完成测试](/l/zh/developers/extend/apps/getting-started/concepts),你可以通过两种方式进行分发:
* **部署 tar 包** — 直接将你的应用上传到特定的 Twenty 服务器,以供内部或私有使用。
* **发布到 npm** — 将你的应用在 Twenty 应用市场上架,供任何工作区发现并安装。
两种路径都从同一个**构建**步骤开始。
## 构建你的应用
运行构建命令来编译你的应用,并生成适用于分发的 `manifest.json`:
```bash filename="Terminal"
yarn twenty dev:build
```
这会编译 TypeScript 源码,转译逻辑函数和前端组件,并将所有内容写入 `.twenty/output/`。 添加 `--tarball`,还可以生成用于手动分发或 publish 命令的 `.tgz` 包。
## 部署到服务器(tar 包)
对于你不希望公开的应用(专有工具、仅供企业使用的集成或实验性构建),你可以将 tar 包直接部署到某台 Twenty 服务器。
### 先决条件
在部署之前,你需要配置一个指向目标服务器的远程。 远程会将服务器 URL 和身份验证凭据本地存储在 `~/.twenty/config.json` 中。
添加远程:
```bash filename="Terminal"
yarn twenty remote:add --url https://your-twenty-server.com --as production
```
### 部署
一步构建并将你的应用上传到服务器:
```bash filename="Terminal"
yarn twenty app:publish --private
# To deploy to a specific remote:
# yarn twenty app:publish --private --remote production
```
### 共享已部署的应用
通过 tar 包分发的应用不会出现在公共市场中,因此同一服务器上的其他工作区无法通过浏览发现它们。 要共享已部署的应用:
1. 前往 **Settings > Applications > Registrations** 并打开你的应用
2. 在 **Distribution** 选项卡中,点击 **Copy share link**
3. 将此链接分享给其他工作区的用户 — 它会将他们直接带到该应用的安装页面
该分享链接使用服务器的基础 URL(不包含任何工作区子域),因此适用于该服务器上的任意工作区。
### 版本管理
在更新已部署的 tarball 应用时,服务器要求 `package.json` 中的 `version` 必须**严格高于**(按[语义化版本](https://semver.org)排序)当前已部署的版本。 在 tar 包存储之前,重新部署相同版本或推送更低版本都会被拒绝 — 你会在 CLI 中看到 `VERSION_ALREADY_EXISTS` 错误。
要发布更新:
1. 将 `package.json` 中的 `version` 字段递增(例如:`1.2.3` → `1.2.4`、`1.3.0` 或 `2.0.0`)
2. 运行 `yarn twenty app:publish --private`(或 `yarn twenty app:publish --private --remote production`)
3. 已安装此应用并在应用的 Settings 选项卡中启用自动升级的工作区会在后台自动升级;其他工作区会在其设置中看到可用的升级
<Note>
预发布标签按预期工作:将 `1.0.0-rc.1` 递增为 `1.0.0-rc.2` 是允许的,并且像 `1.0.0` 这样的正式发布会被正确识别为高于 `1.0.0-rc.5`。 `package.json` 中的版本本身必须是有效的 SemVer 字符串。
</Note>
{/* TODO: add screenshot of the Upgrade button */}
### 服务器版本兼容性
如果你的应用使用了特定 Twenty 服务器版本中引入的功能(例如在 v2.3.0 中新增的 OAuth 提供方),应当在 `package.json` 的 `engines.twenty` 字段中声明应用所需的最低服务器版本:
```json filename="package.json"
{
"name": "twenty-my-app",
"version": "1.0.0",
"engines": {
"node": "^24.5.0",
"twenty": ">=2.3.0"
}
}
```
该值是标准的 [semver 范围](https://github.com/npm/node-semver#ranges)。 常见模式:
| 范围 | 含义 |
| ---------------------------------- | --------------------------------------- |
| `>=2.3.0` | 任何 2.3.0 及以上的服务器 |
| `>=2.3.0 \<3.0.0` | 2.3.0 或更高,但低于下一个主版本 |
| `^2.3.0` | 与 `>=2.3.0 \<3.0.0` 相同 |
**在部署和安装时会发生什么:**
* 如果已设置 `engines.twenty`,且目标服务器的版本不满足该范围,则部署(tarball 上传)或安装将被拒绝,并返回 `SERVER_VERSION_INCOMPATIBLE` 错误以及一条同时指明所需范围和实际服务器版本的消息。
* 如果 `engines.twenty` **未设置**,则该应用可在任何服务器版本上被接受(与现有应用向后兼容)。
* 如果服务器未配置 `APP_VERSION`,则跳过该检查。
<Note>
服务器是权威校验方——它会在 tarball 上传和工作区安装时验证 `engines.twenty`。 即使你通过带外方式部署 tarball 或从应用市场安装,服务器仍会强制执行兼容性要求。
</Note>
## 自动化 CI/CD(脚手架生成的工作流)
使用 `create-twenty-app` 生成的应用开箱即带有三个 GitHub Actions 工作流,位于 `.github/workflows/`。 CI 可在无需任何设置的情况下运行,CD 只需要配置一个 secret,而发布到 npm 只需进行一次性的 npm trusted-publisher 设置。
### CI — `ci.yml`
它会在每次向 `main` 推送以及拉取请求上自动运行你的集成测试。
**作用:**
1. 检出你的应用源代码。
2. 使用 `twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main` 组合 action 启动一个隔离的 Twenty 测试实例(相当于 CI 中的 `yarn twenty docker:start --test`)。
3. 启用 Corepack,从你的 `.nvmrc` 设置 Node.js,并使用 `yarn install --immutable` 安装依赖。
4. 运行 `yarn test`,并从启动的实例传入 `TWENTY_API_URL` 和 `TWENTY_API_KEY`,以便你的测试可以与真实服务器通信。
**配置选项:**
* `TWENTY_VERSION`(环境变量,默认 `latest`)— 通过在 `ci.yml` 中编辑它来固定 CI 使用的 Twenty 服务器版本。
* 并发按 `github.ref` 分组,并会在有新的推送时取消进行中的运行。
不需要任何机密——测试实例是临时的,只在作业持续期间存在。
### CD — `cd.yml`
在每次向 `main` 推送时将你的应用部署到已配置的 Twenty 服务器;当为拉取请求添加 `deploy` 标签时,也可从该拉取请求进行部署。
**作用:**
1. 检出 PR 的 head(针对已加标签的 PR),或被推送的提交。
2. 运行 `twentyhq/twenty/.github/actions/deploy-twenty-app@main`——相当于 CI 中的 `yarn twenty app:publish --private`。
3. 运行 `twentyhq/twenty/.github/actions/install-twenty-app@main`,将新部署的版本安装到目标工作区。
**必需的配置:**
| 设置 | 位置 | 目的 |
| ----------------------- | -------------------------------------------------------- | ---------------------------------------- |
| `TWENTY_DEPLOY_URL` | `cd.yml` 中的 `env`(默认值为 `http://localhost:3000`) | 要部署到的 Twenty 服务器。 首次使用前将其更改为你真实的服务器 URL。 |
| `TWENTY_DEPLOY_API_KEY` | GitHub 仓库 **Settings → Secrets and variables → Actions** | 在目标服务器上具有部署权限的 API 密钥。 |
<Note>
默认的 `TWENTY_DEPLOY_URL` 值 `http://localhost:3000` 只是占位符——从 GitHub 托管的 runner 无法访问任何资源。 在启用 CD 之前,将其更新为你服务器的公网 URL(或使用具有网络访问权限的自托管 runner)。
</Note>
**从 PR 触发预览部署:**
为拉取请求添加 `deploy` 标签。 在 `cd.yml` 中的 `if:` 守卫会使用该 PR 的 head 提交为其运行作业,使你能在合并前在目标服务器上验证更改。
### 发布 — `publish.yml`
当你推送版本标签(例如 `v1.0.0`)时,或从 Actions 选项卡中手动运行该工作流时,会将你的应用附带来源证明发布到 npm。
**作用:**
1. 检出你的应用,设置 Node.js,并更新 npm(受信任发布需要 npm 11.5.1 或更高版本)。
2. 运行 `yarn twenty app:publish`,它会构建应用并将 `.twenty/output` 发布到 npm。 在 CI 中,它会自动添加 `--provenance` 和 `--access public`,因此在工作流中不需要任何标志。
**一次性设置:**
在 npmjs.com 上打开你的包 > **Settings → Trusted Publisher**,并使用 `publish.yml` 工作流为此仓库注册(参见 [npm trusted publishing 文档](https://docs.npmjs.com/trusted-publishers))。 带有 provenance 的发布会证明是哪个 GitHub 仓库构建了该包,这也同样是你在 Twenty 市场中声明应用所有权的方式。
<Note>
npm 仅接受来自**公共**源代码仓库的 provenance。 如果你从私有仓库发布,npm 会以 `E422 ...` 错误拒绝 OIDC provenance 包。 Unsupported GitHub Actions source repository visibility: "private"`错误。 要从私有仓库进行发布,请在发布步骤的`env`中将`TWENTY_APP_PUBLISH_DISABLE_PROVENANCE: 'true'`设置为退出 provenance(在脚手架生成的`publish.yml\` 中包含了一条已注释的提示):
```yaml filename=".github/workflows/publish.yml"
- name: Publish to npm
env:
TWENTY_APP_PUBLISH_DISABLE_PROVENANCE: 'true'
run: yarn twenty app:publish
```
</Note>
### 固定可复用的 actions
`ci.yml` 和 `cd.yml` 工作流引用了 `@main` 上的可复用 actions,因此会自动获取 `twentyhq/twenty` 仓库中的 action 更新。 如果你希望构建具有确定性,请在每个 `uses:` 行中将 `@main` 替换为某个提交的 SHA 或发行标签。
## 发布到 npm
发布到 npm 可让你的应用在 Twenty 应用市场中被发现。 任何 Twenty 工作区都可以直接通过 UI 浏览、安装和升级应用市场中的应用。
### 要求
* 一个 [npm](https://www.npmjs.com) 账户
* 你在 `package.json` 的 `keywords` 数组中的 `twenty-app` 关键字(需要手动添加 — 在 `create-twenty-app` 模板中默认不包含)
```json filename="package.json"
{
"name": "twenty-app-postcard-sender",
"version": "1.0.0",
"keywords": ["twenty-app"]
}
```
### 应用市场元数据
`defineApplication()` 配置支持可选字段,用于控制你的应用在市场中的展示方式。 使用 `logo` 和 `galleryImages` 来引用 `public/` 文件夹中的图片:
```ts src/application-config.ts
export default defineApplication({
universalIdentifier: '...',
displayName: 'My App',
description: 'A great app',
logo: 'public/logo.png',
galleryImages: [
'public/screenshot-1.png',
'public/screenshot-2.png',
],
});
```
完整的市场字段列表(`author`、`category`、`aboutDescription`、`websiteUrl`、`termsUrl` 等)请参见“构建应用”页面中的 [defineApplication 折叠面板](/l/zh/developers/extend/apps/config/application#marketplace-metadata)。
#### 建议的图库图片尺寸
该市场会在固定的 `8:5` 容器中渲染 `galleryImages`(例如,`1600×1000 px`)。
<Note>
任意纵横比的图库图片都会完整显示,且绝不会被裁剪,但相对于 `8:5` 明显更高或更窄的图片,两侧会出现空白边。
</Note>
#### 图片大小限制
`logo` 和每个 `galleryImages` 文件大小不得超过 **10 MB**。 当 marketplace 重新托管你已发布的资源时,较大的文件将被跳过,因此将不会显示。
### 发布
```bash filename="Terminal"
yarn twenty app:publish
```
要在特定的 dist-tag(例如 `beta` 或 `next`)下发布:
```bash filename="Terminal"
yarn twenty app:publish --tag beta
```
### 应用市场的发现机制如何运作
Twenty 服务器会**每小时**从 npm 注册表同步其市场目录。
你可以立即触发同步,而无需等待:
```bash filename="Terminal"
yarn twenty dev:catalog-sync
# To target a specific remote:
# yarn twenty dev:catalog-sync --remote production
```
在应用市场中显示的元数据来自你的 `defineApplication()` 配置——参见上面的 [应用市场元数据](#marketplace-metadata)。
<Note>
如果您的应用未在 `defineApplication()` 中定义 `aboutDescription`,市场将自动使用 npm 上您的软件包的 `README.md` 作为关于页面内容。 这意味着您可以为 npm 和 Twenty 市场维护同一个 README。 如果您希望在市场中使用不同的描述,请显式设置 `aboutDescription`。
</Note>
### CI 发布
上文所述生成的 `publish.yml` 工作流会在打上版本标签时,自动将包连同 provenance 一并发布到 npm。 由于 `yarn twenty app:publish` 在 CI 中运行时会为你添加 `--provenance` 和 `--access public`,因此该工作流不需要任何 npm 标志——只需要一次性完成受信任发布者的设置。
对于其他 CI 系统(GitLab CI、CircleCI 等),先运行 `yarn install`,然后运行 `yarn twenty app:publish`。 当环境可以生成 OIDC 令牌时会发出 provenance,否则会自动跳过。
<Note>
**npm provenance** 会在你的 npm 列表中添加可信徽章,使用户可以验证该包是由公共 CI 流水线中的特定提交构建的。 它也同样是你在 Twenty 市场中声明应用所有权的方式。 有关详细信息,请参见 [npm provenance 文档](https://docs.npmjs.com/generating-provenance-statements)。
</Note>
## 安装应用
一旦应用已发布(npm)或已部署(tarball),各工作区就可以通过 UI 进行安装。
在 Twenty 的 **Settings > Applications** 页面中,既可以浏览也可以安装来自市场或通过 tarball 部署的应用。
{/* TODO: add screenshot of the UI when the app is registered */}
你也可以通过命令行安装应用:
```bash filename="Terminal"
yarn twenty app:install
```
<Note>
服务器在安装时强制执行 SemVer 版本控制,与部署时的规则一致:
* 尝试安装与工作区中已安装版本相同的版本将被拒绝,并返回 `APP_ALREADY_INSTALLED` 错误。
* 尝试安装低于当前已安装版本的版本将被拒绝,并返回 `CANNOT_DOWNGRADE_APPLICATION` 错误。
若要安装较新的版本,请先部署或发布它,然后重新运行 `yarn twenty app:install`。
</Note>

View File

@@ -0,0 +1,126 @@
---
title: 同步与恢复
description: 何时使用哪个命令、如何解读同步输出,以及在本地元数据发生漂移时的恢复阶梯——在不得不执行完全重置之前。
icon: compass
---
本地应用开发围绕着**同步**展开:CLI 会重建你的 manifest,而服务器只会应用它与工作区中已存在元数据之间的差异。 本页介绍在不同情况下应使用哪个命令、如何阅读同步修改内容,以及当本地状态看起来不一致时——按顺序——应该怎么做。
## 在什么情况下用哪个命令
<Note>
在日常本地迭代中,你几乎总是需要 `yarn twenty dev`。 Deploy 和 publish 用于发布版本,**而不是**用于本地循环。
</Note>
| 如果你想要…… | 命令 | 备注 |
| ------------------ | ----------------------------------- | ------------------------------------------------------------------------------------------------ |
| 使用实时同步进行本地迭代 | `yarn twenty dev` | 监视你的文件,并在每次更改时执行同步。 |
| 同步一次后退出(CI、脚本、钩子) | `yarn twenty apply` | 执行一次构建和同步,然后退出。 添加 `--force` 以跳过破坏性变更确认。 |
| 在**不应用变更**的前提下预览更改 | `yarn twenty plan` | 计算并打印 diff;不写入任何内容。 |
| 从工作区中移除该应用 | `yarn twenty app:uninstall` | 添加 `--yes` 以跳过提示。 |
| 将 tar 包发送到服务器 | `yarn twenty app:publish --private` | 需要一个**严格更高的** `package.json` 版本——参见 [Publishing](/l/zh/developers/extend/apps/operations/publishing)。 |
| 发布到应用市场(npm) | `yarn twenty app:publish` | — |
| 安装 / 升级已部署的版本 | `yarn twenty app:install` | 安装当前已部署的版本。 |
| 清空本地服务器并重新开始 | `yarn twenty docker:reset` | 删除**所有**本地数据——最后的手段。 |
<Note>
`yarn twenty dev --once` 和 `yarn twenty dev --once --dry-run` 仍然可以作为已弃用的别名使用,对应 `yarn twenty apply` 和 `yarn twenty plan`。
</Note>
### 本地同步不需要提升版本号
严格递增的 `version` 规则(在 deploy 时为 `VERSION_ALREADY_EXISTS`,在 install 时为 `APP_ALREADY_INSTALLED` / `CANNOT_DOWNGRADE_APPLICATION`)适用于 **`app:publish` / `app:install`**——即发布路径。 `yarn twenty dev` 就地同步你的 manifest,且从不要求修改版本,因此你无需为了迭代去改动 `package.json`。 如果你发现自己为了测试本地改动而不断提升版本号,说明你在想要使用开发循环时却走了发布路径。
## 阅读同步输出
每次同步都会打印它实际应用的(或在使用 `plan` 时将要应用的)元数据更改,Terraform 风格——每个实体一个区块,包含其属性,然后是一行总结:
```text filename="Terminal"
# objectMetadata "rocket" will be created
+ icon = "IconRocket"
+ labelSingular = "Rocket"
+ ...
# fieldMetadata "launchedAt" will be updated
~ isNullable = false -> true
Plan: 2 to add, 1 to change, 1 to destroy.
✓ Synced My App (4 files)
```
这是你的首要诊断工具:它会准确告诉你哪些对象、字段和布局发生了变化,这样你就可以在查看 UI 之前确认同步是否按预期进行。
破坏性更改(`to destroy`)会列出它们会删除的内容(例如 `objectMetadata "auditNote" — drops the table and all its rows`),并且需要交互式确认,或者在脚本中使用 `--force`。
当同步在某个实体上失败时,错误信息会给出有问题的实体及其 `universalIdentifier`,例如:
```text
Migration action 'create' for 'fieldMetadata' (universalIdentifier: 2020...4337) failed
```
使用该标识符在 manifest 中(以及在需要时在工作区中)定位该实体,而不是猜测哪个实体发生了冲突。
## 预览更改(计划)
`yarn twenty plan` 会构建你的 manifest,向服务器请求迁移计划并打印出来——**不会实际应用任何内容**。 这是在真正执行前,以安全方式回答“这次同步会改动什么?”的办法。
```bash filename="Terminal"
yarn twenty plan
```
```text filename="Terminal"
Building manifest...
Computing metadata plan (read-only, nothing will be applied)...
# fieldMetadata "crewCapacity" will be created
+ ...
Plan: 1 to add, 1 to change, 0 to destroy.
✓ Plan complete for My App — no changes were applied
```
一个计划:
* **不会写入任何内容**——不会进行元数据迁移、不会更新应用记录、不会更改默认角色/标签,也不会生成 API 客户端。
* 返回与真实同步将要应用的**相同 diff**,这样你可以预先审查将被创建 / 更新 / 删除的实体。
* 在执行高风险改动之前、在审查 AI 生成的改动时,或在某些如果即将落地意外变更就应当失败的脚本中,dry run 都非常有用。
<Note>
计划只会预览**元数据**更改,并且要求应用至少已经同步过一次(这样工作区才知道它的存在)。 如果你在一个从未同步过的应用上运行它,服务器会报告该应用尚未安装——先运行一次 `yarn twenty dev`。
</Note>
## 恢复梯子
当本地元数据看起来不对时,按以下顺序逐步升级,并在问题解决后立即停止。 每一步都比前一步更具破坏性。
1. **重新同步。** 再次运行 `yarn twenty apply`。 同步是幂等的——在干净的 manifest 上重新运行是安全的,并且通常可以解决瞬时故障。
2. **预览计划。** 运行 `yarn twenty plan`,在不应用变更的前提下,准确查看下一次同步打算修改什么。
3. **阅读具名错误。** 如果同步失败,记录消息中的元数据类型和 `universalIdentifier`(见上),并在 manifest 中定位该实体。 冲突通常指向重复或被重复使用的标识符。
4. **卸载并重新安装。** 先执行 `yarn twenty app:uninstall`,然后再次同步(`yarn twenty dev`)。 这会在保留你工作区其余部分不变的情况下,从零重建该应用的元数据。
5. **完全重置(最后手段)。** 执行 `yarn twenty docker:reset`,然后重新播种并重新同步。
<Warning>
`yarn twenty docker:reset` 会删除本地实例中的**所有**数据——包括每个工作区、记录和应用。 只有在前面步骤全部无效时才使用它。
</Warning>
<Note>
遇到元数据错误了吗? 请[提交 issue](https://github.com/twentyhq/twenty/issues/new/choose),并附上失败的迁移消息(包含其中的元数据类型和 `universalIdentifier`)、同步时的 `Metadata changes` 输出,以及你执行过的命令。
</Note>
## 避免在同一工作区上并发同步
同步会应用元数据迁移。 在**同一个工作区同时**运行多个同步、部署或安装操作——例如多个终端或多个 AI 代理并行迭代——会让这些迁移交错执行,从而让元数据处于部分应用的状态。
服务器会按工作区串行化同步以防止这种情况,但你仍然应当通过**单一**进程来处理敏感的元数据操作,而不要并发触发。 如果你使用多个代理来编排开发,请将它们的同步 / 部署 / 安装调用通过一个队列进行排队,这样任何时刻只有一个操作在运行。
## 区分不同类型的失败
当出现问题时,元数据 diff 和具名错误可以帮助你定位失败位置:
* **Manifest 构建错误**——CLI 在同步前失败(`MANIFEST_BUILD_FAILED`、`TYPECHECK_FAILED`);请修复你的应用源代码。
* **同步 / 迁移错误**——构建成功,但在应用 diff 时失败,并给出实体名称和 `universalIdentifier`;请修复冲突的元数据。
* **依赖项大小错误** — 同步或安装失败,是因为应用的生产环境 `dependencies` 过大,无法作为运行时层进行安装(`LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED`);请将逻辑函数在运行时不导入的包(UI 库、开发工具等)移动到 `devDependencies`。
* **应用代码运行时错误** — 同步成功,但你的逻辑函数或组件在运行时行为异常;请检查[函数日志](/l/zh/developers/extend/apps/operations/cli)。
* **本地实例状态** — 不属于以上任何一种情况,但工作区仍然显示异常;请按照恢复步骤逐级排查。

View File

@@ -0,0 +1,264 @@
---
title: 测试
description: Vitest 设置、针对真实 Twenty 服务器的集成测试、类型检查,以及使用 GitHub Actions 的 CI。
icon: flask
---
该 SDK 提供可编程的 API,使你可以在测试代码中构建、部署、安装和卸载你的应用。 结合 [Vitest](https://vitest.dev/) 和类型化 API 客户端,你可以编写集成测试,在真实的 Twenty 服务器上验证你的应用端到端运行是否正常。
## 使用 npm 包
可以在应用中安装并使用任意 npm 包。 逻辑函数和前端组件都通过 [esbuild](https://esbuild.github.io/) 打包,其依赖会被内联到输出中——运行时不需要 `node_modules`。
### 安装包
```bash filename="Terminal"
yarn add axios
```
然后在代码中导入它:
```ts src/logic-functions/fetch-data.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import axios from 'axios';
const handler = async (): Promise<any> => {
const { data } = await axios.get('https://api.example.com/data');
return { data };
};
export default defineLogicFunction({
universalIdentifier: '...',
name: 'fetch-data',
description: 'Fetches data from an external API',
timeoutSeconds: 10,
handler,
});
```
前端组件同样适用:
```tsx src/front-components/chart.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { format } from 'date-fns';
const DateWidget = () => {
return <p>Today is {format(new Date(), 'MMMM do, yyyy')}</p>;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'date-widget',
component: DateWidget,
});
```
### 打包的工作原理
构建步骤使用 esbuild 为每个逻辑函数和每个前端组件生成一个自包含文件。 导入的包会被内联到打包文件中,下面列出的除外。
**逻辑函数** 运行在 Node.js 环境中。 Node 内置模块(`fs`、`path`、`crypto`、`http` 等) 可用且无需安装。
**前端组件** 运行在 Web Worker 中。 Node 内置模块不可用——仅可使用可在浏览器环境中运行的 npm 包。 请注意,沙盒实现了*部分* DOM,因此一个包在构建时可能一切正常,但在运行时仍可能失败;参见[当前限制](/l/zh/developers/extend/apps/layout/front-components#current-limitations)。
两个环境都将 `twenty-client-sdk/core` 和 `twenty-client-sdk/metadata` 作为预置模块提供 — 这些模块不会被打包,而是在运行时由服务器解析。
前端组件也可以通过[共享依赖包](/l/zh/developers/extend/apps/layout/front-components#sharing-dependencies-across-front-components)来共享依赖。 列在应用 package.json 的 `frontComponentSharedDependencies` 字段中的包会被构建到一个单独的文件中,并在运行时从该文件加载,而不是被内联到每个组件中,因此只导入共享包的组件几乎不包含任何自身的依赖代码。
## 设置
脚手架生成的应用已包含 Vitest。 如果你手动进行设置,请安装这些依赖:
```bash filename="Terminal"
yarn add -D vitest vite-tsconfig-paths
```
在应用根目录下创建一个 `vitest.config.ts`:
```ts vitest.config.ts
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
const TWENTY_API_KEY = process.env.TWENTY_API_KEY ?? '<the pre-seeded local dev key>';
// Make env vars available to globalSetup (test.env only applies to workers)
process.env.TWENTY_API_URL = TWENTY_API_URL;
process.env.TWENTY_API_KEY = TWENTY_API_KEY;
export default defineConfig({
plugins: [
tsconfigPaths({
projects: ['tsconfig.spec.json'],
ignoreConfigErrors: true,
}),
],
test: {
testTimeout: 120_000,
hookTimeout: 120_000,
fileParallelism: false,
include: ['src/**/*.integration-test.ts'],
globalSetup: ['src/__tests__/global-setup.ts'],
env: {
TWENTY_API_URL,
TWENTY_API_KEY,
},
},
});
```
创建一个全局设置文件,用于验证服务器是否可访问,写入 SDK 的测试配置文件(`~/.twenty/config.test.json`),并在测试运行前同步应用:
```ts src/__tests__/global-setup.ts
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
const APP_PATH = process.cwd();
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
export async function setup() {
const apiUrl = process.env.TWENTY_API_URL!;
const apiKey = process.env.TWENTY_API_KEY!;
// Verify the server is running
const response = await fetch(`${apiUrl}/healthz`);
if (!response.ok) {
throw new Error(`Twenty server is not reachable at ${apiUrl}.`);
}
// Write the SDK's test config (the CLI reads config.test.json when NODE_ENV=test)
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(
path.join(CONFIG_DIR, 'config.test.json'),
JSON.stringify({
remotes: { local: { apiUrl, apiKey } },
defaultRemote: 'local',
}, null, 2),
);
// Start from a clean slate, then sync the app
await appUninstall({ appPath: APP_PATH }).catch(() => {});
const result = await appDevOnce({ appPath: APP_PATH });
if (!result.success) {
throw new Error(`Dev sync failed: ${result.error?.message}`);
}
}
export async function teardown() {
await appUninstall({ appPath: APP_PATH });
}
```
## 可编程的 SDK API
子路径 `twenty-sdk/cli` 导出了可直接在测试代码中调用的函数:
| 函数 | 描述 |
| -------------- | ----------------------------------- |
| `appBuild` | 构建应用,并可选地打包为 tar 包 |
| `appDeploy` | 将 tar 包上传到服务器 |
| `appDevOnce` | 构建并同步应用一次(与 `yarn twenty apply` 相同) |
| `appInstall` | 在活动工作区安装该应用 |
| `appUninstall` | 从活动工作区卸载该应用 |
每个函数都会返回一个结果对象,包含 `success: boolean`,以及 `data` 或 `error` 之一。
## 编写集成测试
下面是一个完整示例:构建、部署并安装该应用,然后验证它出现在工作区中:
```ts src/__tests__/app-install.integration-test.ts
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config';
import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const APP_PATH = process.cwd();
describe('App installation', () => {
beforeAll(async () => {
const buildResult = await appBuild({
appPath: APP_PATH,
tarball: true,
onProgress: (message: string) => console.log(`[build] ${message}`),
});
if (!buildResult.success) {
throw new Error(`Build failed: ${buildResult.error?.message}`);
}
const deployResult = await appDeploy({
tarballPath: buildResult.data.tarballPath!,
onProgress: (message: string) => console.log(`[deploy] ${message}`),
});
if (!deployResult.success) {
throw new Error(`Deploy failed: ${deployResult.error?.message}`);
}
const installResult = await appInstall({ appPath: APP_PATH });
if (!installResult.success) {
throw new Error(`Install failed: ${installResult.error?.message}`);
}
});
afterAll(async () => {
await appUninstall({ appPath: APP_PATH });
});
it('should find the installed app in the workspace', async () => {
const metadataClient = new MetadataApiClient();
const result = await metadataClient.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const installedApp = result.findManyApplications.find(
(app: { universalIdentifier: string }) =>
app.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(installedApp).toBeDefined();
});
});
```
## 运行测试
确保你的本地 Twenty 服务器正在运行,然后:
```bash filename="Terminal"
yarn test
```
或者在开发期间使用监听模式:
```bash filename="Terminal"
yarn test:watch
```
## 类型检查
你也可以在不运行测试的情况下对应用进行类型检查:
```bash filename="Terminal"
yarn twenty dev:typecheck
```
这会针对你的应用的 `tsconfig.json` 运行 `tsc --noEmit`,并报告所有类型错误。 脚手架生成的应用还会提供一个 `yarn typecheck` 脚本,它也会覆盖测试文件(`tsconfig.spec.json`)。
## 使用 GitHub Actions 进行 CI
脚手架工具会在 `.github/workflows/ci.yml` 生成一个开箱即用的工作流。 在每次向 `main` 推送代码以及每个拉取请求上,它都会在 runner 中启动一个临时的 Twenty 服务器(通过 `twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test` action),然后运行 `yarn lint`、`yarn typecheck`、`yarn test:unit` 和 `yarn test`,并将 `TWENTY_API_URL` / `TWENTY_API_KEY` 指向该服务器。 无需任何机密信息,你可以在工作流顶部通过 `TWENTY_VERSION` 环境变量固定服务器版本。
完整的三个脚手架工作流(`ci.yml`、`cd.yml` 部署流水线以及用于 npm 发布的 `publish.yml`)的详细演练说明,请参见 [发布 → 自动化 CI/CD](/l/zh/developers/extend/apps/operations/publishing#automated-cicd-scaffolded-workflows)。

View File

@@ -0,0 +1,120 @@
---
title: 翻译
description: 以多种语言发布你的应用——通过一个 locales 目录翻译 manifest 标签和 UI 字符串。
icon: language
---
Twenty 应用均以**英文**编写:你的源码和 manifest 中的字符串
是英文源文本,`en` 是你进行翻译时的源语言环境,任何未被翻译的语言环境都会回退到它。
你的应用有两类可翻译文本,并且它们都通过同一个
`locales/` 目录:
* **Manifest labels** —— 对象和字段名称、视图标题、菜单项,以及
在应用元数据中声明的其他字符串。
* **前端组件字符串** —— 你的 React 前端组件渲染的 UI 文本。
你标记可翻译字符串,将它们提取到按语言环境划分的目录中,翻译这些目录,然后构建过程会为当前用户提供正确的语言——无需额外配置。
## 标记前端组件字符串
从 `twenty-sdk/front-component` 中导入翻译辅助函数:
```tsx
import { Trans, t, msg, useTranslate } from 'twenty-sdk/front-component';
const STATUSES = [
{ id: 'draft', label: msg('Draft') },
{ id: 'sent', label: msg('Sent') },
];
const Card = ({ count, name }: { count: number; name: string }) => {
const { t } = useTranslate();
return (
<section>
{/* Static text — reactive to the user's locale */}
<Trans>Loading postcard…</Trans>
{/* Disambiguate identical sources with a context */}
<Trans context="card-title">Untitled</Trans>
{/* Interpolation: pass values explicitly */}
<p>{t('Hi {name}', { name })}</p>
<p>{t('Saved {count} cards', { count })}</p>
{/* Resolve a lazily-declared descriptor */}
<ul>{STATUSES.map((s) => <li key={s.id}>{t(s.label)}</li>)}</ul>
</section>
);
};
```
### 何时使用哪一种
* **`Trans`** —— JSX 中的静态文本。 使用 `message` 和 `values` 属性进行插值,如 `Trans message="Hi {name}" values={{ name }}`;直接在子节点中插值无法被静态提取。
* **`useTranslate().t`** —— 组件内部的动态字符串。 当用户切换语言时会重新渲染。 在渲染逻辑中优先使用这个。
* **`t(...)`**(直接导入)—— 可在**任何地方**使用的即时翻译,包括事件处理函数、辅助函数和模块作用域——不仅仅在渲染逻辑中。
* **`msg(...)`** —— 以数据形式(常量、配置)声明字符串的惰性描述符。 在之后通过 `t(descriptor)` 来解析它。
### 上下文
传入 `context` 以区分那些源字符串相同但译法不同的情况:
```tsx
t({ message: 'Open', context: 'door' });
t({ message: 'Open', context: 'window' });
<Trans context="card-title">Untitled</Trans>
```
在区域设置目录中,上下文字符串会作为一组内容出现,其键为对应的上下文:`"\<context>": { "\<message>": "\<translation>" }`。 清单(Manifest)标签在结构上就是带有上下文的——每个标签都携带其所扮演的元数据角色(`objectMetadata.labelSingular`、`fieldMetadata.label`、`view.name` 等),因此同一个词在作为对象名称和作为视图名称时,可以有不同的翻译。
## 提取和翻译
在你的应用目录中运行提取命令:
```bash
twenty dev:translations-extract # collect strings into locales/en.json
twenty dev:translations-extract --locale fr-FR # also scaffold a target locale
```
提取会收集你的 manifest 标签以及前端组件源码中的 `t()`/`msg()`/`Trans` 字符串,并将它们放入 `locales/\<locale>.json` 中,以源字符串作为键。 填写翻译:
```json
// locales/fr-FR.json
{
"Loading postcard…": "Chargement de la carte…",
"Hi {name}": "Bonjour {name}",
"Saved {count} cards": "{count} cartes enregistrées",
"door": {
"Open": "Ouvrir"
},
"objectMetadata.labelSingular": {
"Postcard": "Carte postale"
}
}
```
无上下文字符串是扁平条目;有上下文字符串——包括每个清单标签——都嵌套在其上下文键下。
像 `{name}` 这样的占位符会在运行时被替换——请在翻译中保留它们。 任何留空的字符串都会回退到源文本。
## 运行方式
`twenty dev:build` 会编译目录并为当前用户提供正确的语言:manifest 标签在服务端解析,前端组件目录被打包进每个组件 bundle 中。 在运行时,组件会从其执行上下文(宿主的当前语言)中读取语言环境,并将每个字符串在其目录中解析,当翻译缺失时回退到源文本。 在宿主中切换语言会实时重新渲染 `Trans` 和 `useTranslate().t` 的字符串。
清单标签会在每次同步时编译——`twenty dev:build`、`twenty apply`
以及持续监视的 `twenty dev` 都是如此——因此,编辑区域设置文件并
同步即可看到新文本。 Front 组件目录会在构建时被嵌入到
每个组件包中,因此更改其中之一仍意味着
需要重新运行 `twenty dev:build`(并重新部署)。
`Trans` 的文本子节点可以跨多行——空白会以与 JSX 相同的方式被折叠,因此这两种写法都会被提取为键 `Welcome back`:
```tsx
<Trans>Welcome back</Trans>
<Trans>
Welcome
back
</Trans>
```

View File

@@ -0,0 +1,81 @@
---
title: 5. An AI agent
icon: robot
description: 让代理人使用您的工具从聊天室生成文档。
---
因为`生成文档` 暴露于一个 **工具**\*,故AI 代理人可以调用它。
让我们添加一个代理人和技能,用户可以说\*"生成一个
Jeffery Griffin"\*。
## 技能
[skill](/l/zh/developers/extend/apps/logic/skills-and-agents) 是可重复使用的
说明 — — 您附加到代理的知识。 我们教的模型如何使用
这个工具。
```ts filename="src/skills/document-drafting.skill.ts"
import { defineSkill } from 'twenty-sdk/define';
export default defineSkill({
universalIdentifier: DOCUMENT_SKILL_UNIVERSAL_IDENTIFIER,
name: 'document-drafting',
label: 'Document drafting',
icon: 'IconFileText',
content: [
'To generate a document, call the `generate-document` tool with:',
'- `templateId`: the id of the document template to use.',
'- `recordId`: the id of the Person or Company the document is for.',
'',
'If the user names a template or person instead of an id, find the record first,',
'then pass its id. Make sure the template target matches the record type.',
].join('\n'),
});
```
## 代理
[agent](/l/zh/developers/extend/apps/logic/skills-and-agents) 与
模型配对一个提示。 明确设置 "responseFormat" 以避免构建警告。
```ts filename="src/agents/document-assistant.agent.ts"
import { defineAgent } from 'twenty-sdk/define';
export default defineAgent({
universalIdentifier: DOCUMENT_AGENT_UNIVERSAL_IDENTIFIER,
name: 'document-assistant',
label: 'Document Assistant',
description: 'Generates documents from your templates and CRM records.',
icon: 'IconFileText',
responseFormat: { type: 'text' },
prompt: [
'You are the Document Assistant for a CRM.',
'You help users generate personalized documents from reusable templates',
'and the data already in their CRM. Use the generate-document tool, and',
'always confirm what you created.',
].join(' '),
});
```
<Note>
代理只能在其角色允许的情况下调用工具。 我们已经在应用的角色中设置了
`canAccessAllTools: true` 和 `canBeAssignedToAgents: true`,
详见[第 2 章](/l/zh/developers/extend/apps/tutorials/document-generator/generating-documents#grant-it-access)。
</Note>
## 试试
打开与 **Document Assistant** 的聊天,并要求它为您的 CRM 中的
人起草一份文档。 它找到记录, 调用 \`generate-document', 并报告
返回它创建的文档 — 现在出现在你的 **Documents** 视图中。
就像命令菜单和工作流路径。
这是显示逻辑作为工具的回报:**一个函数、多个前门** -
命令菜单、HTTP、工作流步骤以及现在的自然语言。
**在这一步之后:** 应用程序是功能完整和真正有用的。
送货时间。
<Card title="下一步:发布 →" icon="rocket" href="/l/zh/developers/extend/apps/tutorials/document-generator/发布">
添加市场元数据和发布。
</Card>

View File

@@ -0,0 +1,296 @@
---
title: 4. 构建界面
icon: table-columns
description: 查看、侧边栏导航、命令和前部件。
---
现在,对象只能通过设置访问。 让应用在 UI 中真正“现身”:列表视图、侧边栏条目、一键式**生成文档**命令、用于**预览**文档的记录页面前端组件,以及用于模板的原生富文本**编辑器**选项卡。
## 视图和导航
[view](/l/zh/developers/extend/apps/layout/views) 是一个已保存的对象列表。
[导航菜单项](/l/zh/developers/extend/apps/layout/navigation-menu-items)
将此视图放置在侧边栏中。 服务器已经为每个对象自动预配了默认的 "All ..." 视图,因此应用定义的视图是额外的、经过精心筛选的视图。
```ts filename="src/views/documents.view.ts"
import { defineView } from 'twenty-sdk/define';
export default defineView({
universalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
name: 'All documents',
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconFile',
position: 0,
fields: [
{ universalIdentifier: DOCUMENTS_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: DOCUMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
position: 0, isVisible: true, size: 280 },
{ universalIdentifier: DOCUMENTS_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: DOCUMENT_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
position: 1, isVisible: true, size: 120 },
{ universalIdentifier: DOCUMENTS_VIEW_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
position: 2, isVisible: true, size: 200 },
],
});
```
```ts filename="src/navigation-menu-items/documents.navigation-menu-item.ts"
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
export default defineNavigationMenuItem({
universalIdentifier: DOCUMENTS_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
name: 'Documents',
icon: 'IconFile',
color: 'green',
position: 1,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
});
```
为模板添加相同的配对。 两者现在都显示在侧边栏:
<Frame caption="侧边栏中的文档和模板,并列出生成的文档。">
<img src="/images/docs/developers/extends/apps/documents/document-generator/04-documents-view.png" alt="使用生成文档的文档视图" />
</Frame>
## 前台组件
[前端组件](/l/zh/developers/extend/apps/layout/front-components)是一个被沙箱隔离在 Twenty 内部的 React 组件。 我们读取了选中的记录,通过 `CoreApiClient` 和 POSTs 将
人模板加载到最后一章的
路线。
```tsx filename="src/front-components/generate-document-form.front-component.tsx"
import { useEffect, useState } from 'react';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { RestApiClient } from 'twenty-client-sdk/rest';
import { defineFrontComponent } from 'twenty-sdk/define';
import { enqueueSnackbar, useSelectedRecordIds } from 'twenty-sdk/front-component';
const GenerateDocumentForm = () => {
const selectedRecordIds = useSelectedRecordIds();
const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
const [templateId, setTemplateId] = useState('');
useEffect(() => {
new CoreApiClient()
.query({ documentTemplates: {
__args: { filter: { target: { eq: 'PERSON' } }, first: 100 },
edges: { node: { id: true, name: true } } } })
.then(({ documentTemplates }) => {
const list = documentTemplates?.edges?.map((e) => e.node) ?? [];
setTemplates(list);
if (list[0]) setTemplateId(list[0].id);
});
}, []);
const generate = async () => {
const res = await new RestApiClient().post<{ success: boolean }>(
'/s/documents/generate',
{ templateId, recordId },
);
await enqueueSnackbar({
message: res.success ? 'Document generated.' : 'Generation failed.',
variant: res.success ? 'success' : 'error',
});
};
// ...render a <select> of templates and a Generate button
};
export default defineFrontComponent({
universalIdentifier: GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'generate-document-form',
component: GenerateDocumentForm,
});
```
<Warning>
内联 CSS 变量的样式(`var(--t-color-blu)`),不是从
`twai`导入的值。 构建过程中的 SDK 模型,所以模块级导入的
主题常量将是“未定义的”。 请参阅
[完整组件](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx)。
</Warning>
## 打开它的命令
当选中某个 Person 时,带有 `availabilityType: 'RECORD_SELECTION'` 的[命令菜单项](/l/zh/developers/extend/apps/layout/command-menu-items)会出现,并在侧边面板中打开该组件。
```ts filename="src/command-menu-items/generate-document.command-menu-item.ts"
import { defineCommandMenuItem, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: GENERATE_DOCUMENT_COMMAND_UNIVERSAL_IDENTIFIER,
label: 'Generate document',
availabilityType: 'RECORD_SELECTION',
availabilityObjectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
frontComponentUniversalIdentifier:
GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
});
```
## 尝试整个流程
打开 **People**,勾选一位人员,然后按下 <kbd>⌘K</kbd> / <kbd>Ctrl K</kbd>。
“生成文档”出现,标记为您的应用:
<Frame caption="命令显示何时选中某人。">
<img src="/images/docs/developers/extends/apps/documents/document-generator/06-command-menu.png" alt="带生成文档的命令菜单" />
</Frame>
运行它 — — 您的组件在侧面板中打开. 选择一个模板,点击
**生成**,以及在 **Documents** 中选择一个新的记录土地。
<Frame caption="前端组件,加载模板并生成点击。">
<img src="/images/docs/developers/extends/apps/documents/document-generator/06b-front-compon.png" alt="生成文档侧面板" />
</Frame>
每个生成的文档都将您的应用记录为其作者:
<Frame caption="由文档生成器创建,状态已生成。">
<img src="/images/docs/developers/extends/apps/documents/document-generator/05-document-record.png" alt="生成的文档记录" />
</Frame>
## 预览其记录页面上的文档
前面的组件不仅仅是命令菜单 — — 你可以在
录制页面上挂载一个 \*\*选项卡。 让我们在文档记录中添加一个 *Preview* 选项卡,使得
Markdown 物体作为一个可打印的打印页面。
组件从执行上下文读取当前记录ID,加载
文档并将其渲染。 前端组件运行在一个只允许白名单 HTML 标签的**沙箱**中——原始 HTML 注入(`dangerouslySetInnerHTML`)和 `\<style>` 会被阻止——因此我们通过一个小型的 [`Markdown`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/markdown-to-react.tsx) 助手,将 Markdown 渲染为带内联样式的 React 元素。
```tsx filename="src/front-components/document-viewer.front-component.tsx"
import { CoreApiClient } from 'twenty-client-sdk/core';
import { RestApiClient } from 'twenty-client-sdk/rest';
import { defineFrontComponent } from 'twenty-sdk/define';
import { useFrontComponentExecutionContext } from 'twenty-sdk/front-component';
import { Markdown } from 'src/utils/markdown-to-react';
const DocumentViewer = () => {
const recordId = useFrontComponentExecutionContext((c) => c.recordId ?? null);
// ...load { content, file } for recordId, then derive the links:
const pdfUrl = document.file?.[0]?.url;
const webUrl = new RestApiClient().resolveUrl('/s/documents/view', {
query: { id: recordId },
});
// Render the template body, plus quick links to the web page and the PDF.
// Links open in a new tab so they don't navigate the embedded component.
return (
<div style={styles.scroll}>
<div style={styles.actions}>
<a style={styles.actionLink} href={webUrl} target="_blank" rel="noopener noreferrer">
Open web page
</a>
{pdfUrl ? (
<a style={styles.actionLink} href={pdfUrl} target="_blank" rel="noopener noreferrer">
Download PDF
</a>
) : null}
</div>
<div style={styles.paper}>
<div style={styles.body}>
<Markdown content={document.content} />
</div>
</div>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'document-viewer',
component: DocumentViewer,
});
```
用[页面布局](/l/zh/developers/extend/apps/layout/page-layouts)挂载它。 `RECORD_PAGE` 布局会在对象的记录视图中添加选项卡;包含单个 `FRONT_COMPONENT` 小部件的选项卡会以全宽方式承载该组件:
```ts filename="src/page-layouts/document-record.page-layout.ts"
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
export default definePageLayout({
universalIdentifier: DOCUMENT_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
name: 'Document record page',
type: 'RECORD_PAGE',
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
tabs: [{
universalIdentifier: DOCUMENT_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
title: 'Preview',
icon: 'IconEye',
position: 50,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [{
universalIdentifier: DOCUMENT_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Document preview',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
}],
}],
});
```
打开任何文档 — **预览** 选项卡使它美丽,并链接到
可分享的网页和 PDF :
<Frame caption="预览选项卡使用内联样式以及快速链接打开文档。">
<img src="/images/docs/developers/extends/apps/documents/document-generator/09-document-viewer.png" alt="记录页面选项卡中的文档查看器前置组件" />
</Frame>
## 使用富文本编辑器编辑模板
模板根本不需要自定义组件。 因为`body` 是一个
`RICH_TEXT` 字段 20个已经为此提供了一个完整的文本编辑器——
与标准注释和任务对象相同。 我们只是在
模板记录页面上显示。
在 `EDITOR` 显示模式中添加一个 `FIELD` 小部件的标签,通过 `field MetadataId` 指向`body`
字段:
```ts filename="src/page-layouts/template-record.page-layout.ts"
{
universalIdentifier: TEMPLATE_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
title: 'Template',
position: 1,
layoutMode: PageLayoutTabLayoutMode.GRID,
widgets: [{
universalIdentifier: TEMPLATE_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Template',
type: 'FIELD',
position: { layoutMode: PageLayoutTabLayoutMode.GRID, row: 0, column: 0, rowSpan: 6, columnSpan: 12 },
configuration: {
configurationType: 'FIELD',
fieldMetadataId: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
fieldDisplayMode: 'EDITOR',
},
}],
}
```
一个`RICH_TEXT`字段既存储编辑器块JSON也存储Markdown
投影。 生成管道读取Markdown 投影, 所以
占位符, PDF, 和可共享的网页都保持正常工作状态 —
查看完整的
[“模板-记录”。 年龄布局。s`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/page-layouts/template-record.page-layout.ts)。
`FIELD\` 小部件也支持其他显示模式和选项,包括
列出相隔两跳的嵌套关联记录——请参阅
[字段小部件参考](/l/zh/developers/extend/apps/layout/page-layouts#field-widgets).
现在编辑器在一个合适的富文本编辑器中写入模板:
<Frame caption="模板选项卡:20个本地的富文本编辑器绑定到实体字段。">
<img src="/images/docs/developers/extends/apps/documents/document-generator/10-template-editor.png" alt="本地富文本编辑器选项卡的模板记录" />
</Frame>
**在这一步之后:** 文档预览,模板是可编辑的
在应用中。 接下来,让AI 代理人从聊天室中生成它们。
<Card title="下一步:一个 AI 代理 →" icon="robot" href="/l/zh/developers/extend/apps/tutorials/document-generator/ai-agent">
添加代理和技能来呼叫您的工具。
</Card>

View File

@@ -0,0 +1,132 @@
---
title: 1. 数据模型
icon: database
description: 带有对象、字段和关系的模型文档和模板。
---
我们的应用需要两个自定义对象:**文档模板**(写什么)和
**文档**(生成的结果)。 让我们来定义它们。
用CLI扫描每个实体的文件 — — 它为您生成一个有效的 UUID 和
文件夹:
```bash filename="Terminal"
yarn twenty dev:add object
```
在下方显示完成的文件。
<Note>
每一个 `*_UNIVERSAL_IDENTIFIER' 常住寿命为
`src/constants/universal-identifiers.ts\` 并且在使用时导入。 下面的
代码片段省略了这些导入的简洁度 - 将它们保留在您自己的文件中。
</Note>
## 模板对象
一个模板具有一个 `name`、一个包含 `{{placeholders}}` 的 `body`,以及一个 `target`,用于指明它是为 Person 还是 Company 编写的。 `body` 是一个
`RICH_TEXT` 字段,所以二十个字段给它一个完整的文本编辑器。
```ts filename="src/objects/document-template.object.ts"
import { defineObject, FieldType } from 'twenty-sdk/define';
export default defineObject({
universalIdentifier: DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'documentTemplate',
namePlural: 'documentTemplates',
labelSingular: 'Document template',
labelPlural: 'Document templates',
icon: 'IconFileText',
labelIdentifierFieldMetadataUniversalIdentifier:
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{ universalIdentifier: TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT, name: 'name', label: 'Name', icon: 'IconAbc' },
{ universalIdentifier: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.RICH_TEXT, name: 'body', label: 'Body', icon: 'IconFileText',
description: 'Use {{placeholders}} like {{name.firstName}} or {{jobTitle}}.' },
{ universalIdentifier: TEMPLATE_TARGET_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.SELECT, name: 'target', label: 'Target', icon: 'IconTarget',
defaultValue: `'PERSON'`,
options: [
{ id: TEMPLATE_TARGET_OPTION_PERSON_UNIVERSAL_IDENTIFIER,
value: 'PERSON', label: 'Person', color: 'blue', position: 0 },
{ id: TEMPLATE_TARGET_OPTION_COMPANY_UNIVERSAL_IDENTIFIER,
value: 'COMPANY', label: 'Company', color: 'green', position: 1 },
] },
],
});
```
<Warning>
`SELECT` 选项 **值** 必须是 `UPPER_CASE` (`PERSON`, 而不是`person`),并且
`defaultValue` 用额外引号包裹:`` `PERSON` ``。 "label" 是
用户所看到的。
</Warning>
## 文档对象
生成的文档存储渲染的 `content` 和 `status` 。 以相同的方式定义它,并添加一个 `status` 选择字段,取值为 `DRAFT` / `GENERATED`。 完整文件:
[`document.object.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/objects/document.object.ts)。
## 将它们与关联到关系
每个文档都应该回到它所产生的模板。 关系是
**双向** — — 您在自己的字段文件中定义双方。
```ts filename="src/fields/document-template-relation.field.ts"
import { defineField, FieldType, OnDeleteAction, RelationType } from 'twenty-sdk/define';
// The "many" side: each document belongs to one template.
export default defineField({
universalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'template',
label: 'Template',
relationTargetObjectMetadataUniversalIdentifier:
DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
TEMPLATE_DOCUMENTS_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'templateId',
},
});
```
另一边(`template-documents-relation.field.ts`) 是一个名为`documents`的
`RelationType.ONE_TO_MANY` 字段,它指出相反的方向。
完整模式请参阅 [Relations](/l/zh/developers/extend/apps/data/relations)。
## 在 20 中查看
用 `yarn 20dev` 运行,打开 **设置 -> 数据模型**。 这两个对象都会出现,并带有你的应用标签。
<Frame caption="两个自定义对象,由文档生成器应用程序所拥有。">
<img src="/images/docs/developers/extends/apps/documents/document-generator/01-data-model.png" alt="显示文档和文档模板的数据模型设置" />
</Frame>
创建一个模板来测试 — 名称是 *销售建议*, 设置 **Target** 为
*Person*, 并粘贴一个几个占位符的机构:
```text
Dear {{name.firstName}} {{name.lastName}},
As {{jobTitle}} at {{company.name}}, we think you'll love our product.
Best,
The Team
```
<Frame caption="模板记录。 该机构保留其占位符直到文档生成为止。">
<img src="/images/docs/developers/extends/apps/documents/document-generator/03-template-record.png" alt="带占位符主体的销售建议模板记录" />
</Frame>
**在这个步骤之后:** 你有 `documentTemplate` 和 `document` 对象,用
的关系链接和一个模板生成它们。 接下来,填充它的逻辑。
<Card title="下一步:生成文档 →" icon="bolt" href="/l/zh/developers/extend/apps/tutorials/document-generator/generating-document">
写下填充模板的逻辑函数。
</Card>

View File

@@ -0,0 +1,238 @@
---
title: 2. 正在生成文档
icon: bolt
description: 一个逻辑函数作为一个 AI 工具和工作流动作暴露。
---
现在核心:一个[逻辑函数](/l/zh/developers/extend/apps/logic/logic-functions)
加载模板和记录,填充占位符,并保存一个新的
文档。
我们将把业务逻辑写成一个**处理器**,然后透露它通过
几个触发器。 本章将其中的两个线路连接起来——一个 **AI 工具** 和
**Workflow 操作** 。
## 渲染帮助器
将纯逻辑保留在它自己的文件中,这样便于拆除测试。 这会将一条记录
展平成 `{{dot.path}}` 占位符并进行替换。
```ts filename="src/logic-functions/utils/render-template.ts"
const PLACEHOLDER_PATTERN = /\{\{\s*([\w.]+)\s*\}\}/g;
export const renderTemplate = (body: string, values: Record<string, string>) => {
const missingTokens = new Set<string>();
const content = body.replace(PLACEHOLDER_PATTERN, (_m, token: string) => {
const value = values[token];
if (value === undefined || value === '') { missingTokens.add(token); return ''; }
return value;
});
return { content, missingTokens: [...missingTokens] };
};
```
<Tip>
由于此文件没有副作用,你可以使用快速单元测试对其进行覆盖
(`yarn test:unit`)。 见 [Testing](/l/zh/developers/extend/apps/operations/testing)。
</Tip>
## 处理程序
处理程序使用生成的 [`CoreApiClient`](/l/zh/developers/extend/apps/logic/logic-functions)
读写CRM 数据。 它加载模板,加载目标记录,填充
物体,并创建一个“文档”。
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
import { CoreApiClient } from 'twenty-client-sdk/core';
import { loadRecordValues } from 'src/logic-functions/utils/load-record-values';
import { renderTemplate } from 'src/logic-functions/utils/render-template';
export const generateDocumentHandler = async (
input: { templateId: string; recordId: string },
) => {
const client = new CoreApiClient();
// Use a filtered list query, not the singular lookup: the singular query
// throws when nothing matches, which would become a 500 instead of a 404.
const { documentTemplates } = await client.query({
documentTemplates: {
__args: { filter: { id: { eq: input.templateId } }, first: 1 },
edges: { node: { id: true, name: true, body: true, target: true } },
},
});
const documentTemplate = documentTemplates?.edges?.[0]?.node;
if (!documentTemplate?.id) return { success: false, status: 404, message: 'Template not found.' };
const record = await loadRecordValues(client, documentTemplate.target, input.recordId);
if (!record.found) return { success: false, status: 404, message: 'Record not found.' };
const { content, missingTokens } = renderTemplate(documentTemplate.body ?? '', record.values);
const { createDocument } = await client.mutation({
createDocument: {
__args: { data: {
name: `${documentTemplate.name} — ${record.displayName}`,
content, status: 'GENERATED', templateId: documentTemplate.id,
} },
id: true, name: true,
},
});
return { success: true, documentId: createDocument.id, content, missingTokens };
};
```
`loadRecordValues` 会针对 Person 与 Company 运行不同的查询,并将结果展平 — 参见
[`load-record-values.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/load-record-values.ts)。
## 显示为一个工具和工作流操作
单个的 `defineeLogicFunction` 可以带几个触发器。 在这里,`toolTriggerSettings`
让它可以被AI 代理人调用,`workflowActionTriggerSettings` 将它变成可视工作流生成器中的
步骤。 两者都用JSON方案描述他们的输入情况。
```ts filename="src/logic-functions/generate-document.ts"
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'twenty-sdk/logic-function';
import { GENERATE_DOCUMENT_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { generateDocumentHandler } from 'src/logic-functions/handlers/generate-document-handler';
import { generateDocumentInputSchema } from 'src/logic-functions/schemas/generate-document-input.schema';
export default defineLogicFunction({
universalIdentifier: GENERATE_DOCUMENT_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'generate-document',
description: 'Generate a document from a template and a CRM record.',
timeoutSeconds: 30,
toolTriggerSettings: {
inputSchema: generateDocumentInputSchema,
},
workflowActionTriggerSettings: {
label: 'Generate Document',
icon: 'IconFileText',
inputSchema: jsonSchemaToInputSchema(generateDocumentInputSchema),
outputSchema: [{ type: 'object', properties: {
success: { type: 'boolean' }, documentId: { type: 'string' },
} }],
},
handler: generateDocumentHandler,
});
```
输入schema是一个普通的 JSON schema 描述了 `templateId` 和 `recordId` -
查看 [`generate-document-input.schema.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/logic-functions/schemas/generate-document-input.schema.ts)。
## 授予访问权限
逻辑函数作为应用程序的角色运行。 它需要阅读模板和记录
并创建文档,以便允许在 "src/roles/default-role.ts" 中:
```ts
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Document Generator default role',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canAccessAllTools: true,
canBeAssignedToAgents: true,
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.UPLOAD_FILE],
});
```
`UPLOAD_FILE` 让函数在下一节上传生成的 PDF。
请参阅 [Roles](/l/zh/developers/extend/apps/config/roles) 以获取更精细的权限。
## 附加一个真实的 PDF 文件
渲染文本字段是有用的,但用户需要一个真正的文档。 让我们生成一个
**PDF** 并将其作为可下载的文件存储在记录上。
首先,给`文档`对象一个`FILES`字段来持有PDF。 应用将
上传到他们**拥有** 的文件字段,所以此字段是上传的路线:
```ts filename="src/objects/document.object.ts"
{
universalIdentifier: DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.FILES,
name: 'file',
label: 'File',
icon: 'IconFileTypePdf',
universalSettings: { maxNumberOfValues: 1 },
}
```
现在渲染那个PDF。 一个应用是一个真正的节点项目,所以您可以添加任何您需要的npm
包,并且像其他任何地方一样导入它。 我们使用 **[pdf-lib](https://pdf-lib.js.org/)**
绘制PDF 和 **[marked](https://marked.js.org/)** 解析Markdown
正文--CLI 将它们安装到函数中供您使用的运行时间:
```bash filename="Terminal"
yarn add pdf-lib marked
```
完整的助手是
[`generate-document-pdf.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/generate-document-pdf.ts)。
它将Markdown解析为代币,标记为“标记”。 \`运行,然后使用
pdf-lib:真实标题,**bold**/*italic* 运行,子弹和编号列表
blockquotes and rules — — 一个经过筛选、多页的 A4 渲染模板
本身,而不是一个文本墙。
<Frame caption="生成的 PDF:真实的排版和Markdown 格式化,呈现模板正文。">
<img src="/images/docs/developers/extends/apps/documents/document-generator/07b-generated-pdf.png" alt="一个打造的可营销的 PDF" />
</Frame>
<Note>
pdf-lib的内置字体使用WinAnsi编码,所以西欧语音会将
渲染到盒子之外; 助手地图的智能引用和破折号并掉落它
无法编码。 渲染非拉丁脚本(中文、 阿拉伯语、 西里尔语) 将意味着
嵌入一个 Unicode 字体。
</Note>
然后上传它并在记录中存储参考。 `uploadFile` 路由字节
到你的应用拥有的文件字段。返回的 `id` 是你保存的:
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { generateDocumentPdf } from 'src/logic-functions/utils/generate-document-pdf';
const documentName = `${documentTemplate.name} — ${record.displayName}`;
const bytes = await generateDocumentPdf(documentName, content);
const fileName = 'proposal.pdf';
const uploaded = await new MetadataApiClient().uploadFile(
Buffer.from(bytes),
fileName,
'application/pdf',
DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
);
await client.mutation({
updateDocument: {
__args: {
id: documentId,
data: { file: [{ fileId: uploaded.id, label: fileName }] },
},
id: true,
},
});
```
生成的文档现在带有可下载的 PDF:
<Frame caption="已生成的 PDF 存储在文档文件字段中。">
<img src="/images/docs/developers/extends/apps/documents/document-generator/08-document-with-pdf.png" alt="生成一个 PDF 文件的文档记录" />
</Frame>
<Note>
`uploadFile` 仅针对**app-owned** 文件字段。(所以上传文件总需要一个拥有字段的
应用,加上`UPLOAD_FILE` 角色标志)。 这就是为什么PDF
会降落在记录自己的“file”字段上——相同的样式
[call-recorder app](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/public/call-recorder)
用于录制的原因。
</Note>
**在这一步之后:** 每个生成的文档都有一个真实的、可下载的 PDF。 但
没有任何东西能够\*调用UI 的生成器 — — 因为我们需要一个 HTTP 路由。
<Card title="下一步:HTTP路由 →" icon="全局模式" href="/l/zh/developers/extend/apps/tutorials/document-generator/http-route">
通过 HTTP 提供函数并将文档渲染为 web 页面。
</Card>

View File

@@ -0,0 +1,146 @@
---
title: 3. HTTP 路由
icon: globe
description: 通过 HTTP 触发函数并将文档渲染为 web 页面。
---
相同的处理程序也可以回答 HTTP 请求。 我们将添加两个路由:
* a **POST** 让UI 调用来生成文档的端点,和
* 一个公开的 **GET** 端点,将文档作为可打印的网页。
两者都使用 `httpRouteTriggerSettings` 。 应用路由使用 `/s/` 路径;在本地和自托管实例上,该路径由你的 Twenty 服务器提供服务(例如:`http://localhost:2020/s/documents/generate`)。 在 Twenty Cloud 上,`RestApiClient` 通过工作区的 functions 域名来解析该路径。
## POST 路由 — 按需生成
这会重用\`generateDocuments Handler',所以没有重复的逻辑——只是一个能读取请求正文的薄
适配器。
```ts filename="src/logic-functions/generate-document-route.ts"
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { Response } from 'twenty-sdk/logic-function';
import { generateDocumentHandler } from 'src/logic-functions/handlers/generate-document-handler';
const handler = async (event: RoutePayload): Promise<Response> => {
const body = event.body as Record<string, unknown> | null;
const result = await generateDocumentHandler({
templateId: (body?.templateId as string) ?? '',
recordId: (body?.recordId as string) ?? '',
});
// Map the handler's failure reason onto a real HTTP status (400/404/500)
// instead of always returning 200.
return new Response(JSON.stringify(result), {
status: result.success ? 200 : (result.status ?? 400),
headers: { 'Content-Type': 'application/json' },
});
};
export default defineLogicFunction({
universalIdentifier: GENERATE_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
name: 'generate-document-route',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/documents/generate',
httpMethod: 'POST',
isAuthRequired: true,
},
});
```
共享处理程序在失败时会返回建议的 `status`,因此路由可以使用适当的 `4xx`/`5xx` 状态码进行响应。 `isauth:true`是指调用者
必须提供一个有效的令牌——下一章的前面组件自动通过
用户的访问令牌。
## GET 路由 — 渲染为网页
若要返回HTML而不是JSON,将物体用
`Content-Type`标头包装`Response`。 此路由是公开的(`isauth:必填:false`),所以
生成的文档可以作为链接共享。
```ts filename="src/logic-functions/view-document.ts"
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { Response } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { documentHtmlPage } from 'src/utils/render-document';
const htmlResponse = (html: string, status = 200): Response =>
new Response(html, { status, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
const handler = async (event: RoutePayload): Promise<Response> => {
const documentId = event.queryStringParameters?.id;
if (!documentId) {
return htmlResponse(documentHtmlPage('Missing document id', 'Provide ?id=<documentId>.'), 400);
}
// Filtered list query so an unknown id renders a clean 404 page instead of throwing.
const { documents } = await new CoreApiClient().query({
documents: {
__args: { filter: { id: { eq: documentId } }, first: 1 },
edges: { node: { id: true, name: true, content: true } },
},
});
const document = documents?.edges?.[0]?.node;
if (!document?.id) {
return htmlResponse(documentHtmlPage('Document not found', `No document with id ${documentId}.`), 404);
}
return htmlResponse(documentHtmlPage(document.name ?? 'Document', document.content ?? ''));
};
export default defineLogicFunction({
universalIdentifier: VIEW_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
name: 'view-document',
timeoutSeconds: 15,
handler,
httpRouteTriggerSettings: {
path: '/documents/view',
httpMethod: 'GET',
isAuthRequired: false,
},
});
```
`documentHtmlPage`将Markdown体变成HTML(带 [marked](https://marked.js.org/),
净化) 只显示模板
内容的可打印页面——与 PDF 和应用程序内预览相同。
[参见辅助函数](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/render-document.ts)。
## 试试
在您的工作区内有一个模板和一个人, 调用路由(从
**设置 -> API 和 Webhooks**获取一个令牌):
```bash filename="Terminal"
curl -X POST http://localhost:2020/s/documents/generate \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"templateId":"<templateId>","recordId":"<personId>"}'
# → {"success":true,"documentId":"...","content":"Dear Jeffery Griffin, ..."}
```
在您的浏览器中打开返回的文档:
```
http://localhost:2020/s/documents/view?id=<documentId>
```
<Frame caption="公开的 GET 路由使文档成为一个可打印的页面。">
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="呈现的文档页面" />
</Frame>
<Tip>
您也可以在测试
`yarn 20dev:functions:logs`时串流函数日志,或直接通过
`yarn 20dev:function:exec` 直接调用。
</Tip>
**在这一步之后:** 应用程序可以通过 HTTP 生成文档并以
网页服务。 现在让我们让它在没有“curl”的情况下可以使用。
<Card title="下一步:构建界面→" icon="table-columns" href="/l/zh/developers/extend/apps/tutorials/document-generator/building-the-ui">
查看、导航、命令和前端组件。
</Card>

View File

@@ -0,0 +1,63 @@
---
title: 教程:文档生成器
icon: wand-magic-sparkles
description: 构建一个真实的 Twenty 应用,从你的 CRM 数据生成个性化文档。
---
在本教程中,你将构建 **Document Generator** —— 一个应用,它将可复用的模板转换为使用你现有 CRM 数据生成的个性化文档。
使用 `{{placeholders}}` 一次性编写模板,然后即可为任意 Person 或 Company 一键生成已填充的文档——可从命令菜单、AI 代理或工作流中发起。
<Frame caption="为特定人员生成的单个模板,以可打印页面的形式打开。">
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="生成的销售提案文档" />
</Frame>
## 您将会学到什么
每一章都会增加一项功能。 在本教程结束时,您将接触到 SDK 的大部分内容。
| 章节 | 功能 | 参考 |
| ------------------------------------------------------------------------------------ | -------------------------------------------------- | --------------------------------------------------------- |
| [1. 数据模型](/l/zh/developers/extend/apps/tutorials/document-generator/data-model) | 对象、字段和一个关系 | [数据](/l/zh/developers/extend/apps/data/overview) |
| [2. 生成文档](/l/zh/developers/extend/apps/tutorials/document-generator/generating-documents) | 一个逻辑函数(AI 工具 + 工作流操作),用于填充 Markdown 模板并附加一份精美的 PDF | [逻辑函数](/l/zh/developers/extend/apps/logic/logic-functions) |
| [3. HTTP 路由](/l/zh/developers/extend/apps/tutorials/document-generator/http-routes) | 从路由提供 JSON 和可分享的 HTML 页面 | [逻辑函数](/l/zh/developers/extend/apps/logic/logic-functions) |
| [4. 构建 UI](/l/zh/developers/extend/apps/tutorials/document-generator/building-the-ui) | 视图、导航、命令菜单,以及用于预览文档和编辑模板的前端组件 | [布局](/l/zh/developers/extend/apps/layout/overview) |
| [5. 一个 AI 智能体](/l/zh/developers/extend/apps/tutorials/document-generator/ai-agent) | 智能体 + 技能 | [技能与智能体](/l/zh/developers/extend/apps/logic/skills-and-agents) |
| [6. 发布](/l/zh/developers/extend/apps/tutorials/document-generator/publishing) | 把它发布到应用市场 | [发布](/l/zh/developers/extend/apps/operations/publishing) |
## 先决条件
您应该已经完成了[快速开始](/l/zh/developers/extend/apps/getting-started/quick-start):
一个在端口 `2020` 上运行的本地 Twenty 服务器,以及已通过身份验证并连接到它的 CLI。
如果还没有,现在就创建并启动一个:
```bash filename="Terminal"
npx create-twenty-app@latest document-generator
cd document-generator
yarn twenty dev
```
<Note>
更想直接阅读完成版代码? 完整应用位于
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator)。
下面的每个代码片段都来自该应用。
</Note>
## 应用如何协同运作
<Frame>
<img src="/images/docs/developers/extends/apps/document-generator/how-it-fits.svg" alt="带有占位符的模板会被生成为一份带 PDF 的精美文档,可以通过命令菜单、AI 智能体、工作流或可分享链接来触发" />
</Frame>
您只需在富文本编辑器中编写一次**模板**,其中包含 `{{placeholders}}`。 选择一个
模板和一条 CRM 记录,就会填充占位符,并存储一份精美的
**文档**(带有一个 PDF 文件)。 其他所有内容——命令菜单、AI 智能体、工作流步骤、可分享链接——都只是触发同一个生成器的不同方式。
## 让这个循环持续运行
在整个教程期间,在一个终端中保持运行 `yarn twenty dev`。 每当您在 `src/` 下添加或编辑文件时,它都会在几秒钟内重新同步到服务器,这样您就可以在构建的同时,在 UI 中看到每项功能逐步出现。
<Card title="开始构建 →" icon="database" href="/l/zh/developers/extend/apps/tutorials/document-generator/data-model">
第 1 章:为文档和模板建模。
</Card>

View File

@@ -0,0 +1,136 @@
---
title: 6. 发布
icon: rocket
description: 添加市场元数据并发布您的应用。
---
您的应用正常工作。 最后一步是为市场描述并发布。
## 添加市场元数据
[应用程序配置](/l/zh/developers/extend/apps/config/application) 带有显示在市场中的
身份:作者、类别、标志和支持
链接。 将一个 logo 放入 `public/` 中,并使用 `logo` 来引用它。
```ts filename="src/application-config.ts"
import { defineApplication } from 'twenty-sdk/define';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: 'Document Generator',
description:
'Create reusable document templates and generate personalized documents from your CRM records.',
logo: 'public/document-generator.svg',
author: 'Twenty',
category: 'Productivity',
websiteUrl: 'https://docs.twenty.com/l/zh/developers/extend/apps',
termsUrl: 'https://www.twenty.com/terms',
emailSupport: 'contact@twenty.com',
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
});
```
<Tip>
默认角色在其自身文件中使用 `defineApplicationRole()` 声明——你不再在这里传递 `defaultRoleUniversalIdentifier`。
</Tip>
也将 `twentapp` 关键字添加到 `package.json` 中,所以应用程序是可以发现的:
```json filename="package.json"
{ "keywords": ["twenty-app"] }
```
## 添加相册截图
市场列表用屏幕截图销售自己。 将几个 PNG 放入
`public/gallery/` 中,并使用 `galleryImages` 来引用它们——它们会按照数组顺序在列表页面上
以画廊形式渲染。
```ts filename="src/application-config.ts"
export default defineApplication({
// ...identity from above
galleryImages: [
'public/gallery/01-generated-document.png',
'public/gallery/02-command-menu.png',
'public/gallery/03-template-editor.png',
'public/gallery/04-documents.png',
],
});
```
<Tip>
领取付款: 让第一个屏幕截图完成的结果(生成的
文档),然后显示它是如何触发和编写的。 使用简洁的高分辨率
抓取 — — 他们是用户看到的第一件事。
</Tip>
给`README.md`同样的处理方法——它是npm 和 GitHub 的首页。
打开时使用值建议和屏幕截图,列出标题功能,
然后保留折叠下方的构建详细信息。
## 在您发货前检查
运行相同的 CI 门:
```bash filename="Terminal"
yarn lint # oxlint
yarn typecheck # tsgo
yarn test:unit # unit tests
yarn twenty plan # preview the metadata diff
```
该计划会精确列出在服务器上将发生的更改,而不会实际应用这些更改——
这是一次很好的最终健全性检查。 见
[Testing](/l/zh/developers/extend/apps/operations/testing) 和
[同步和恢复](/l/zh/developers/extend/apps/operations/sync-and-recovery)。
## 发布
```bash filename="Terminal"
# Public app → npm (default)
yarn twenty app:publish
# Or deploy privately to a specific server's registry
yarn twenty app:publish --private -r <remote>
```
`app:publish` 构建并默认向npm 发布;`--private`上传一个
tarball到20个服务器的私人注册表。 要在一个实例的市场上显示一个已发布的应用
,触发一个目录同步:
```bash filename="Terminal"
yarn twenty dev:catalog-sync -r <remote>
```
详细信息和发布检查列表:
[Publishing](/l/zh/developers/extend/apps/operations/publishing)。
## 您构建了一个应用 :party_popper:
在六章中,你使用了大部分SDK表面:
* **对象、字段和关系** 以模拟数据
* 一个**逻辑函数** 显示为 **AI 工具**,一个 **Workflow 动作** 和 **HTTP 路由**
* 界面**查看、导航、命令和前面组件**
* 用于生成自然语言的 **agent + 技能**
* **市场元数据** 和发布流
已完成的应用位于
[\`软件包/二十个应用/示例/文件生成器'](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator)。
## 下一步
<CardGroup cols={2}>
<Card title="数据参考" icon="database" href="/l/zh/developers/extend/apps/data/overview">
每个字段类型、关系和索引选项。
</Card>
<Card title="逻辑引用" icon="bolt" href="/l/zh/developers/extend/apps/logic/overview">
Cron 和数据库事件触发了密钥价值存储,OAuth 连接。
</Card>
<Card title="布局引用" icon="table-columns" href="/l/zh/developers/extend/apps/layout/overview">
页面布局、仪表板小部件和更多用户界面。
</Card>
<Card title="操作" icon="rocket" href="/l/zh/developers/extend/apps/operations/overview">
CLI, test, remotes, and CI.
</Card>
</CardGroup>