@lark-base-open/base-database-cli 1.3.8 → 1.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/archive.d.ts +16 -2
  3. package/dist/archive.d.ts.map +1 -1
  4. package/dist/archive.js +59 -13
  5. package/dist/archive.js.map +1 -1
  6. package/dist/function-description.d.ts +6 -0
  7. package/dist/function-description.d.ts.map +1 -0
  8. package/dist/function-description.js +29 -0
  9. package/dist/function-description.js.map +1 -0
  10. package/dist/lark-cli-error.d.ts +1 -0
  11. package/dist/lark-cli-error.d.ts.map +1 -1
  12. package/dist/lark-cli-error.js +5 -0
  13. package/dist/lark-cli-error.js.map +1 -1
  14. package/dist/mutation.d.ts +1 -1
  15. package/dist/mutation.d.ts.map +1 -1
  16. package/dist/mutation.js +5 -5
  17. package/dist/mutation.js.map +1 -1
  18. package/dist/project-context.d.ts.map +1 -1
  19. package/dist/project-context.js +42 -6
  20. package/dist/project-context.js.map +1 -1
  21. package/package.json +1 -1
  22. package/skills/lark-base-database/SKILL.md +2 -0
  23. package/skills/lark-base-database/references/ai-function.md +7 -3
  24. package/skills/lark-base-database/references/base-sql-errors.generated.md +482 -40
  25. package/skills/lark-base-database/references/database-function.md +26 -3
  26. package/skills/lark-base-database/references/development.md +2 -0
  27. package/skills/lark-base-database/references/faas.md +3 -1
  28. package/skills/lark-base-database/references/mutations.md +4 -4
  29. package/templates/database-starter/mutations/README.md +1 -1
  30. package/templates/database-starter/page/package-lock.json +4 -4
  31. package/templates/database-starter/page/package.json +1 -1
  32. package/templates/database-starter/page/src/components/floating-button/index.tsx +18 -20
  33. package/templates/database-starter/page/src/components/floating-button/utils/request-edit.test.ts +42 -0
  34. package/templates/database-starter/page/src/components/floating-button/utils/request-edit.ts +21 -0
  35. package/templates/database-starter/page/src/main.test.ts +28 -0
  36. package/templates/database-starter/page/src/main.tsx +2 -0
@@ -96,10 +96,25 @@ Database Function 只支持本文明确列出的只读查询语法和函数,
96
96
  | `ARRAY<T>` | `T[]`;例如 `OPTION[]` 的元素为 `{id, name}` |
97
97
  | nullable field | 原类型或 `null` |
98
98
 
99
+ - 多值字段(含返回多值的公式、lookup)没有成员时返回 `[]`,字段值缺失时返回 `null`;`IS NULL` 只匹配后者。成员不含 `null`,部分来源为空时只返回有效成员。
100
+ - 公式、lookup 返回的数组不与关联记录逐位对应;需要按来源记录对齐时,JOIN 关联表并按 `rec_id` 对应。
101
+ - 查询中用 `ARRAY[...]` 构造的数组保留 `null` 元素。
102
+ - `ARRAY_AGG` 跳过 `null` 输入,全部为 `null` 或没有输入时返回 `null`;不要依赖多个 `ARRAY_AGG` 按下标对应,需要对应时用 JOIN 或保持逐行查询。有序 `ARRAY_AGG(value ORDER BY key)` 还要求 TEXT 或数值输入可证明非 NULL(如 `WHERE value IS NOT NULL` 或非空 `COALESCE`);其余形态限制见[生成参考的 OrderedArrayDomain](base-sql-errors.generated.md)。
103
+ - `CARDINALITY` 对已准入的一维数组计数:空数组为 `0`,整体 NULL 为 `null`,NULL 元素计入长度;`ARRAY_LENGTH(array, 1)` 对空数组返回 `null`。
104
+ - `ARRAY_SORT` 只接受一个可证明的一维载体:无 NULL 成员的常量数组、合法原生标量集合字段或有类型的整体 NULL 数组;元素限数值、TEXT、TIMESTAMP,不接受数组参数或未知派生载体。文本排序遵循 Base 文本规则。
105
+
99
106
  执行时区按显式请求时区、Base 设置、Base 默认 `Asia/Shanghai` 的优先级确定;未设置时区的 Base 也按默认时区解析 TIMESTAMP 参数并渲染返回值。`TO_CHAR` 和时间投影使用同一生效时区。
100
107
 
101
108
  日期表单回填与时间展示按[日期与时区](development.md#日期与时区)处理。
102
109
 
110
+ ## 函数能力描述
111
+
112
+ 站点助手通过 `lark-cli base +site-func-list` 发现 Database Function、Mutation 和 FaaS/Edge,以 `name` + `description` 判断何时调用。三类函数都在各自 `meta.json` 的顶层填写 `description`;`ai/<key>/meta.json` 不在此发现链路中,不适用本节校验。
113
+
114
+ 描述应让助手判断业务用途及调用影响。默认用一句话说明给谁用或何时用、读取或写入什么数据,并明确只读、写入或外部调用等副作用,例如「员工查看本人各周期订阅及转账状态(只读)」。描述要符合真实实现;不能代替权限控制或用重复函数名凑长度。
115
+
116
+ CLI 要求 `description` 为字符串,trim 后非空且至少 8 个 Unicode 字符,与 trim 后的 `name` 不同;原始字符串(含首尾空白)最多 500 个 Unicode 字符。`check` 和 Preview `deploy` 在远端请求前列出所有不合格描述的文件路径,`context` 也会在 warnings 中列出;正式版发布提升的是已校验的 Preview 制品,不再检查本地描述。`pull` 保留历史源码并允许缺少描述;恢复后按实际业务补齐,再运行 `check`。
117
+
103
118
  ## Function 文件与参数
104
119
 
105
120
  ```text
@@ -108,7 +123,7 @@ database-functions/<functionId>/
108
123
  └── meta.json
109
124
  ```
110
125
 
111
- `main.sql` 只包含一条固定 `SELECT`。`meta.json` 包含非空 `name`、可选 `description` 和参数声明。
126
+ `main.sql` 只包含一条固定 `SELECT`。`meta.json` 包含非空 `name`、必填 `description`(见[函数能力描述](#函数能力描述))和参数声明。
112
127
 
113
128
  查询使用位置参数 `$1`、`$2`……;`meta.params` 按相同顺序声明:
114
129
 
@@ -123,7 +138,7 @@ LIMIT $3
123
138
  ```json
124
139
  {
125
140
  "name": "搜索任务",
126
- "description": "按关键词和状态搜索任务",
141
+ "description": "成员按关键词和状态搜索任务列表(只读)",
127
142
  "meta": {
128
143
  "params": [
129
144
  { "kind": "TEXT", "description": "$1,已包含 % 的关键词" },
@@ -153,6 +168,10 @@ LIMIT 30
153
168
 
154
169
  对应参数声明为 `{"kind":"TEXT[]"}`,例如调用 `params: [["重点", "普通"]]`。本例左侧为 `TEXT`,右侧为一维 `TEXT[]` 位置参数;其他形态按[生成参考](base-sql-errors.generated.md)判定。空数组不会选中记录。数组含 `null` 时不保证 PostgreSQL 三值语义,具体结果与预处理规则见[NULL 与数值边界](database-function-limitations.md#null-与数值边界)。成员判断与数组展开的区别及修法以[生成参考](base-sql-errors.generated.md)为准。
155
170
 
171
+ 6.0 多选按名称筛选使用 `(多选字段).name @> $n`,将 `$n` 声明为 `TEXT[]`。数组作为一个参数传入,例如 `params: [["选项名"]]`,不拆成多个标量;空数组不要求任何选项,但仍只匹配多选值非 NULL 的行。整体 NULL 参数或含 NULL 成员的参数数组不匹配任何行。单个非 NULL 名称也可使用 `(多选字段).name @> ARRAY[$n]`,其中 `$n` 声明为 `TEXT`;该写法传 NULL 会被拒绝。
172
+
173
+ 上述多选名称筛选仅用于 6.0;2.0 的准入限制及处理方式见[生成参考](base-sql-errors.generated.md),不能由原生多选集合之间的包含比较推断 `.name` 数组具有相同准入。
174
+
156
175
  无参数 Function 可以省略 `meta` 或 `meta.params`;归档统一补成 `{"params":[]}`。
157
176
 
158
177
  Database Function 的 `inputSchema` 由 CLI 根据参数顺序与 kind 生成固定长度数组 Schema,不在 Database Function 的 `meta.json` 中手写。FaaS 则必须显式声明,见[FaaS 开发](faas.md#faas-开发)。
@@ -165,11 +184,15 @@ Database Function 的 `inputSchema` 由 CLI 根据参数顺序与 kind 生成固
165
184
  - 默认逐列投影并使用 `AS snake_case`,保持输出 Schema 稳定;连接的每一侧保持为一个表或带别名的派生表。常规生成优先简单连接、顶层查询和带别名的派生表,复杂组合依据生成参考及验证结果决定。
166
185
  - 子查询优先作为带别名的 `FROM` 派生表使用,外层通过输出列名引用结果。需要其他形态时先核对业务语义与生成参考,不把默认生成偏好当作支持清单。
167
186
  - 列表使用有界 `LIMIT` 和稳定排序;排序值可能重复时最后按 `rec_id` 排序。不把截断结果的前端分页视为完整分页或全量导出。
187
+ - 文本排序与比较按 Base 文本规则:中文按拼音顺序,区分大小写。需要固定业务顺序时用 `CASE` 映射。
188
+ - 产品写入的空字符串读取为 `null`,筛选空文本用 `IS NULL`,不要写 `= ''`。
168
189
  - 搜索写 `field LIKE $1`,由参数携带 `%`。
169
190
  - 当前访问者使用 `CURRENT_USER_ID()`,不由页面传入用户 ID。权限关系的实现边界按[权限设计](permission-design.md#读路径database-function-就是策略面)。
170
- - UDT 列成员使用 PostgreSQL 复合类型括号语法:无表别名写 `("负责人").id`、`("链接").link`,带表别名写 `(t."负责人").id`。FILE 的成员为 `.token/.name/.mime_type/.size`;逐元素读取后的 range alias 成员写 `u.id`。
191
+ - UDT 列成员使用 PostgreSQL 复合类型括号语法:无表别名写 `("负责人").id`、`("链接").link`,带表别名写 `(t."负责人").id`。FILE 的成员为 `.token/.name/.mime_type/.size`;逐元素读取后的 range alias 成员写 `u.id`。6.0 支持 USER 的 `.id IS NULL` / `.id IS NOT NULL`;2.0 按目标诊断判定。
171
192
  - 集合字段的 PostgreSQL 数组运算符含义为:`@>`(包含)、`<@`(被包含)、`&&`(有交集)。先用 Catalog 的 `sqlType` 区分标量与集合,再按生成参考判断操作是否适用;展开集合前确认业务是否允许改变主表行数。
172
193
  - 时间条件使用 Catalog 中确认为 `TIMESTAMP` 的字段和 `TIMESTAMP` 位置参数;当前时间使用 `CURRENT_TIMESTAMP`。查询结果固定为 Base 执行时区下的 `YYYY-MM-DD HH:mm:ss.SSS` 字符串。`EXTRACT(EPOCH FROM timestamp_expr)` 返回保留小数秒的 `DOUBLE`;明确只需整数秒时再写 `::bigint`。
194
+ - 2.0 支持单参 `ROUND(float8)`,半值取偶;numeric 舍入半值远离零。双参 `ROUND` 的首参须可证明为 numeric,精度须为 -15…15 的静态整数;numeric 仍由 DOUBLE 承载,不保证任意十进制精度。
195
+ - 2.0 支持 TEXT 参数的 `CONCAT_WS`:跳过 NULL 值,NULL 分隔符返回 NULL;`PERCENTILE_CONT(p) WITHIN GROUP (ORDER BY value)` 要求静态 `p` 在 0…1、单个数值排序表达式,按邻值插值;其余形态限制见[生成参考的 PercentileDomain](base-sql-errors.generated.md)。
173
196
  - 窗口、聚合、字符串、日期和数值计算先明确结果契约,再查[目录生成的修法与已验证 SQL](base-sql-errors.generated.md)。默认采用满足前提的形式;例外是没有等价 SQL 或改写改变所需语义,此时统一按[SQL 外实现放在哪](../SKILL.md#sql-外实现放在哪)选择实现位置。
174
197
 
175
198
  字段类型和成员来自 Catalog,输入按本节参数协议声明;适用的字段操作与修复路径查[生成参考](base-sql-errors.generated.md),而不是从字段名称推断。示例演示参数和结果消费,不作为完整能力清单。
@@ -86,6 +86,8 @@ metadata 只声明输入,因此生成文件只约束调用参数。调用结
86
86
 
87
87
  ## Page 调用
88
88
 
89
+ 外链由页面 Runtime 统一接管,并经宿主安全检查后打开。默认直接使用 a 标签,程序化打开使用 `@lark-base-open/base-site-channel/runtime` 的 `openLink(url)`;保留 starter 启动时的 `installLinkGuard()` 接入。页面导航留在 iframe 内,不使用 location 跳出 iframe,也不依赖宿主放开 sandbox。
90
+
89
91
  默认 Page 的 `page/index.html` 需要把 `<title>` 设置为本次生成的应用名称,不留空。
90
92
 
91
93
  默认 Page 必须引用并渲染 `page/src/components/floating-button/index.tsx` 中的 `FloatingButton` 组件。该组件自行解析应用 token,不接受 `projectId`。悬浮控件仅在正式环境向服务端判定有管理权限的访问者提供「编辑应用」入口。
@@ -13,6 +13,8 @@
13
13
 
14
14
  ## FaaS 开发
15
15
 
16
+ `meta.json` 的描述遵循[函数能力描述](database-function.md#函数能力描述)。
17
+
16
18
  第三方 Agent 配置 FaaS 的凭证或环境相关配置时,先读取[环境变量协议](environment-variables.md)。值仅由 FaaS 静态 `process.env.NAME` 或 `process.env['NAME']` 读取,不进入 Page、源码、配置、日志或函数返回值。动态 `process.env[key]`、解构和其他无法静态确定变量名的访问会阻止部署。
17
19
 
18
20
  每个 FaaS 业务动作的目录:
@@ -26,7 +28,7 @@ server-functions/functions/<functionId>/
26
28
  ```json
27
29
  {
28
30
  "name": "创建任务",
29
- "description": "校验输入并创建一条任务记录",
31
+ "description": "成员创建任务时校验输入并写入一条任务记录",
30
32
  "inputSchema": {
31
33
  "$schema": "https://json-schema.org/draft/2020-12/schema",
32
34
  "type": "object",
@@ -17,10 +17,10 @@ CLI 的 `deploy` 自动校验、打包并注册声明,`pull` 恢复 `mutations
17
17
 
18
18
  ## 声明与参数
19
19
 
20
- `meta.json`:
20
+ `meta.json` 的描述遵循[函数能力描述](database-function.md#函数能力描述):
21
21
 
22
22
  ```json
23
- {"name":"创建任务","description":"提交一条待处理任务"}
23
+ {"name":"创建任务","description":"成员提交新任务时写入一条待处理任务记录"}
24
24
  ```
25
25
 
26
26
  Mutation 不声明角色;调用仍需通过应用统一准入。记录 ID 仅限定操作范围,不代表记录归属或行权限校验。
@@ -146,7 +146,7 @@ Mutation 不声明角色;调用仍需通过应用统一准入。记录 ID 仅
146
146
  建立 `mutations/appendTaskAttachments/meta.json`:
147
147
 
148
148
  ```json
149
- {"name":"追加任务附件"}
149
+ {"name":"追加任务附件","description":"成员为指定任务追加已上传的附件,写入任务附件字段"}
150
150
  ```
151
151
 
152
152
  `mutations/appendTaskAttachments/spec.json`(ID 替换为 Catalog 中的真实值):
@@ -200,7 +200,7 @@ Page 检查一次顶层 `code`;成功后从 `data.records` 找到对应 `rec_i
200
200
 
201
201
  ### 移除附件引用
202
202
 
203
- `meta.json` 沿用追加示例,将名称改为“移除任务附件”。
203
+ `meta.json` 沿用追加示例,将名称改为“移除任务附件”,描述改为“成员移除指定任务中的附件,写入任务附件字段”。
204
204
 
205
205
  `mutations/removeTaskAttachments/spec.json`:
206
206
 
@@ -5,7 +5,7 @@
5
5
  `meta.json` 示例:
6
6
 
7
7
  ```json
8
- {"name":"更新标题"}
8
+ {"name":"更新标题","description":"成员修改指定任务的标题,写入任务标题字段"}
9
9
  ```
10
10
 
11
11
  `spec.json` 示例(替换真实 table / field ID):
@@ -8,7 +8,7 @@
8
8
  "name": "page",
9
9
  "version": "0.0.0",
10
10
  "dependencies": {
11
- "@lark-base-open/base-site-channel": "0.1.4",
11
+ "@lark-base-open/base-site-channel": "0.1.5",
12
12
  "@radix-ui/react-slot": "^1.3.0",
13
13
  "class-variance-authority": "^0.7.1",
14
14
  "clsx": "^2.1.1",
@@ -119,9 +119,9 @@
119
119
  }
120
120
  },
121
121
  "node_modules/@lark-base-open/base-site-channel": {
122
- "version": "0.1.4",
123
- "resolved": "https://registry.npmjs.org/@lark-base-open/base-site-channel/-/base-site-channel-0.1.4.tgz",
124
- "integrity": "sha512-QBl60LoxLr2kNjCHxiuXtFxegSrMPA11P0IufjOkoonNEvZz3CfDtFwhO+EbkOZahANMRyxEq3DhdpFgEm5QDQ==",
122
+ "version": "0.1.5",
123
+ "resolved": "https://registry.npmjs.org/@lark-base-open/base-site-channel/-/base-site-channel-0.1.5.tgz",
124
+ "integrity": "sha512-us+Un3OIaDnFQ8QQH9xPbPeSkZiJDxYsUkfq40KYgTGHovZuBb4SiSTH9+C7tPz5SjyAoc0JCQFM14S4XARWVg==",
125
125
  "dependencies": {
126
126
  "lodash-es": "^4.17.21"
127
127
  }
@@ -18,7 +18,7 @@
18
18
  "test": "vitest run"
19
19
  },
20
20
  "dependencies": {
21
- "@lark-base-open/base-site-channel": "0.1.4",
21
+ "@lark-base-open/base-site-channel": "0.1.5",
22
22
  "@radix-ui/react-slot": "^1.3.0",
23
23
  "class-variance-authority": "^0.7.1",
24
24
  "clsx": "^2.1.1",
@@ -11,14 +11,12 @@ import tooltipCaretIcon from './assets/tooltip-caret.svg'
11
11
  import toastInfoIcon from './assets/toast-info.svg'
12
12
  import toastCloseIcon from './assets/toast-close.svg'
13
13
  import { fetchProjectMeta, type ProjectMeta } from './utils/project-meta'
14
- import {
15
- getDatabaseContext,
16
- resolveDatabaseContext,
17
- siteRuntime,
18
- } from '../../lib/database-runtime'
14
+ import { getDatabaseContext } from '../../lib/database-runtime'
15
+ import { requestEditOrOpenManagement } from './utils/request-edit'
19
16
  import { useUiLanguage, type UiLanguage } from '../../lib/ui-language'
20
17
 
21
18
  interface FloatingButtonI18n {
19
+ openFailed: string
22
20
  editSite: string
23
21
  hidePanel: string
24
22
  hiddenConfirmation: string
@@ -31,7 +29,6 @@ interface TooltipPosition {
31
29
  arrowLeft: number
32
30
  }
33
31
 
34
- const BASE_DATABASE_OVERVIEW_URL = 'https://feishu.cn/base/site/detail/overview'
35
32
  const FLOATING_BUTTON_PAGE_GAP = 24
36
33
  const TOOLTIP_VIEWPORT_GAP = 8
37
34
  const HIDDEN_CONFIRMATION_MS = 3000
@@ -45,18 +42,21 @@ const EMPTY_PROJECT_META: ProjectMeta = {
45
42
  }
46
43
  const FLOATING_BUTTON_I18N: Record<UiLanguage, FloatingButtonI18n> = {
47
44
  en: {
45
+ openFailed: 'Unable to open the management page. Please try again.',
48
46
  editSite: 'Edit and manage your apps',
49
47
  hidePanel: 'Hide until next visit',
50
48
  hiddenConfirmation: 'Edit button hidden until you reopen the app',
51
49
  dismissNotification: 'Dismiss notification',
52
50
  },
53
51
  zh: {
52
+ openFailed: '无法打开管理页面,请重试',
54
53
  editSite: '编辑与管理应用',
55
54
  hidePanel: '隐藏面板',
56
55
  hiddenConfirmation: '编辑按钮已隐藏,直至下次重新打开应用',
57
56
  dismissNotification: '关闭提示',
58
57
  },
59
58
  ja: {
59
+ openFailed: '管理ページを開けませんでした。もう一度お試しください。',
60
60
  editSite: 'アプリを編集・管理',
61
61
  hidePanel: '次回のアクセスまで非表示',
62
62
  hiddenConfirmation:
@@ -70,6 +70,7 @@ const FLOATING_BUTTON_I18N: Record<UiLanguage, FloatingButtonI18n> = {
70
70
  */
71
71
  export function FloatingButton() {
72
72
  const [projectMeta, setProjectMeta] = useState<ProjectMeta | null>(null)
73
+ const [linkFailed, setLinkFailed] = useState(false)
73
74
  const [isHidden, setIsHidden] = useState(false)
74
75
  const [showHiddenConfirmation, setShowHiddenConfirmation] = useState(false)
75
76
  const [activeTooltip, setActiveTooltip] = useState<'edit' | 'hide' | null>(
@@ -148,14 +149,10 @@ export function FloatingButton() {
148
149
  }, [])
149
150
 
150
151
  // 编辑按钮走 site.v1 契约:应用发 request-edit,宿主切编辑态后广播 mode-changed。
151
- // 应用被独立访问(无宿主)时事件无人消费,回落为打开 Site 管理页。
152
+ // 切换失败时管理页也经 OpenLink;宿主不可用或安全检查失败时显示错误。
152
153
  const requestEdit = useCallback(() => {
153
- void siteRuntime.requestEdit().catch(async () => {
154
- const { token } = await resolveDatabaseContext()
155
- const url = new URL(BASE_DATABASE_OVERVIEW_URL)
156
- url.searchParams.set('token', token)
157
- openExternalUrl(url.toString())
158
- })
154
+ setLinkFailed(false)
155
+ void requestEditOrOpenManagement().catch(() => setLinkFailed(true))
159
156
  }, [])
160
157
 
161
158
  if (!shouldShowEditButton) {
@@ -194,6 +191,14 @@ export function FloatingButton() {
194
191
  className="group fixed bottom-10 z-40 flex items-center"
195
192
  style={{ right: FLOATING_BUTTON_PAGE_GAP }}
196
193
  >
194
+ {linkFailed ? (
195
+ <div
196
+ role="alert"
197
+ className="absolute bottom-full right-0 mb-2 w-64 rounded bg-white p-3 text-sm text-red-700 shadow"
198
+ >
199
+ {i18n.openFailed}
200
+ </div>
201
+ ) : null}
197
202
  {activeTooltip ? (
198
203
  <div
199
204
  ref={tooltipRef}
@@ -266,11 +271,4 @@ export function FloatingButton() {
266
271
  )
267
272
  }
268
273
 
269
- /** 使用新标签页打开外部链接。
270
- * @param url 待打开的外部链接。
271
- */
272
- function openExternalUrl(url: string): void {
273
- window.open(url, '_blank', 'noopener,noreferrer')
274
- }
275
-
276
274
  export default FloatingButton
@@ -0,0 +1,42 @@
1
+ import { beforeEach, expect, it, vi } from 'vitest'
2
+
3
+ const mocks = vi.hoisted(() => ({
4
+ requestEdit: vi.fn(),
5
+ resolveDatabaseContext: vi.fn(),
6
+ openLink: vi.fn(),
7
+ }))
8
+ vi.mock('@lark-base-open/base-site-channel/runtime', () => ({
9
+ openLink: mocks.openLink,
10
+ }))
11
+ vi.mock('../../../lib/database-runtime', () => ({
12
+ siteRuntime: { requestEdit: mocks.requestEdit },
13
+ resolveDatabaseContext: mocks.resolveDatabaseContext,
14
+ }))
15
+ import { requestEditOrOpenManagement } from './request-edit'
16
+
17
+ beforeEach(() => {
18
+ vi.resetAllMocks()
19
+ mocks.requestEdit.mockResolvedValue(undefined)
20
+ mocks.resolveDatabaseContext.mockResolvedValue({ token: 'site-example' })
21
+ mocks.openLink.mockResolvedValue({ opened: true })
22
+ })
23
+ it('switches edit mode without opening a new page', async () => {
24
+ await requestEditOrOpenManagement()
25
+ expect(mocks.openLink).not.toHaveBeenCalled()
26
+ })
27
+ it('routes management fallback through OpenLink', async () => {
28
+ mocks.requestEdit.mockRejectedValue(new Error('unavailable'))
29
+ await requestEditOrOpenManagement()
30
+ expect(mocks.openLink).toHaveBeenCalledWith(
31
+ 'https://feishu.cn/base/site/detail/overview?token=site-example',
32
+ )
33
+ })
34
+ it.each([false, 'reject'])(
35
+ 'propagates %s to visible component feedback',
36
+ async (outcome) => {
37
+ mocks.requestEdit.mockRejectedValue(new Error('unavailable'))
38
+ if (outcome === false) mocks.openLink.mockResolvedValue({ opened: false })
39
+ else mocks.openLink.mockRejectedValue(new Error('security unavailable'))
40
+ await expect(requestEditOrOpenManagement()).rejects.toThrow()
41
+ },
42
+ )
@@ -0,0 +1,21 @@
1
+ import { openLink } from '@lark-base-open/base-site-channel/runtime'
2
+ import {
3
+ resolveDatabaseContext,
4
+ siteRuntime,
5
+ } from '../../../lib/database-runtime'
6
+
7
+ const BASE_DATABASE_OVERVIEW_URL = 'https://feishu.cn/base/site/detail/overview'
8
+
9
+ /** 优先请求宿主切换编辑态;管理页链接同样交给宿主安全检查。 */
10
+ export async function requestEditOrOpenManagement(): Promise<void> {
11
+ try {
12
+ await siteRuntime.requestEdit()
13
+ return
14
+ } catch {
15
+ const { token } = await resolveDatabaseContext()
16
+ const url = new URL(BASE_DATABASE_OVERVIEW_URL)
17
+ url.searchParams.set('token', token)
18
+ const result = await openLink(url.toString())
19
+ if (!result.opened) throw new Error('Management page was not opened')
20
+ }
21
+ }
@@ -0,0 +1,28 @@
1
+ import { afterEach, expect, it, vi } from 'vitest'
2
+
3
+ const calls = vi.hoisted(() => [] as string[])
4
+ vi.mock('@lark-base-open/base-site-channel/runtime', () => ({
5
+ installLinkGuard: () => {
6
+ calls.push('guard')
7
+ return () => {}
8
+ },
9
+ }))
10
+ vi.mock('react-dom/client', () => ({
11
+ createRoot: () => ({ render: () => calls.push('render') }),
12
+ }))
13
+ vi.mock('./App', () => ({ default: () => null }))
14
+ vi.mock('./components/database-runtime-ready', () => ({
15
+ DatabaseRuntimeReady: () => null,
16
+ }))
17
+ vi.mock('./lib/database-runtime', () => ({
18
+ siteRuntime: { reportLoadFailed: vi.fn() },
19
+ }))
20
+
21
+ afterEach(() => {
22
+ vi.unstubAllGlobals()
23
+ })
24
+ it('installs the link guard before rendering the page', async () => {
25
+ vi.stubGlobal('document', { getElementById: () => ({}) })
26
+ await import('./main')
27
+ expect(calls).toEqual(['guard', 'render'])
28
+ })
@@ -1,3 +1,4 @@
1
+ import { installLinkGuard } from '@lark-base-open/base-site-channel/runtime'
1
2
  import { StrictMode } from 'react'
2
3
  import { createRoot } from 'react-dom/client'
3
4
  import App from './App'
@@ -24,6 +25,7 @@ function bootstrapDatabaseApp(): void {
24
25
  }
25
26
 
26
27
  try {
28
+ installLinkGuard()
27
29
  bootstrapDatabaseApp()
28
30
  } catch (error) {
29
31
  // ready 之前的启动失败必须显式上报,否则宿主只能等到 ready 超时才展示错误态。