@lark-base-open/base-database-cli 1.0.0

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 (149) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +169 -0
  3. package/changelog/CHANGELOG-0.x.md +103 -0
  4. package/config-schema.json +28 -0
  5. package/dist/agent-guide.d.ts +18 -0
  6. package/dist/agent-guide.d.ts.map +1 -0
  7. package/dist/agent-guide.js +30 -0
  8. package/dist/agent-guide.js.map +1 -0
  9. package/dist/archive.d.ts +16 -0
  10. package/dist/archive.d.ts.map +1 -0
  11. package/dist/archive.js +162 -0
  12. package/dist/archive.js.map +1 -0
  13. package/dist/assets.d.ts +4 -0
  14. package/dist/assets.d.ts.map +1 -0
  15. package/dist/assets.js +6 -0
  16. package/dist/assets.js.map +1 -0
  17. package/dist/auth.d.ts +17 -0
  18. package/dist/auth.d.ts.map +1 -0
  19. package/dist/auth.js +63 -0
  20. package/dist/auth.js.map +1 -0
  21. package/dist/cli.d.ts +3 -0
  22. package/dist/cli.d.ts.map +1 -0
  23. package/dist/cli.js +15 -0
  24. package/dist/cli.js.map +1 -0
  25. package/dist/config.d.ts +5 -0
  26. package/dist/config.d.ts.map +1 -0
  27. package/dist/config.js +50 -0
  28. package/dist/config.js.map +1 -0
  29. package/dist/database-skill.d.ts +22 -0
  30. package/dist/database-skill.d.ts.map +1 -0
  31. package/dist/database-skill.js +135 -0
  32. package/dist/database-skill.js.map +1 -0
  33. package/dist/deploy.d.ts +28 -0
  34. package/dist/deploy.d.ts.map +1 -0
  35. package/dist/deploy.js +476 -0
  36. package/dist/deploy.js.map +1 -0
  37. package/dist/environment.d.ts +12 -0
  38. package/dist/environment.d.ts.map +1 -0
  39. package/dist/environment.js +27 -0
  40. package/dist/environment.js.map +1 -0
  41. package/dist/function-schema.d.ts +3 -0
  42. package/dist/function-schema.d.ts.map +1 -0
  43. package/dist/function-schema.js +310 -0
  44. package/dist/function-schema.js.map +1 -0
  45. package/dist/init.d.ts +77 -0
  46. package/dist/init.d.ts.map +1 -0
  47. package/dist/init.js +418 -0
  48. package/dist/init.js.map +1 -0
  49. package/dist/lark-cli.d.ts +8 -0
  50. package/dist/lark-cli.d.ts.map +1 -0
  51. package/dist/lark-cli.js +228 -0
  52. package/dist/lark-cli.js.map +1 -0
  53. package/dist/npm.d.ts +13 -0
  54. package/dist/npm.d.ts.map +1 -0
  55. package/dist/npm.js +93 -0
  56. package/dist/npm.js.map +1 -0
  57. package/dist/page-title.d.ts +3 -0
  58. package/dist/page-title.d.ts.map +1 -0
  59. package/dist/page-title.js +47 -0
  60. package/dist/page-title.js.map +1 -0
  61. package/dist/preview-receipt.d.ts +23 -0
  62. package/dist/preview-receipt.d.ts.map +1 -0
  63. package/dist/preview-receipt.js +133 -0
  64. package/dist/preview-receipt.js.map +1 -0
  65. package/dist/process.d.ts +17 -0
  66. package/dist/process.d.ts.map +1 -0
  67. package/dist/process.js +53 -0
  68. package/dist/process.js.map +1 -0
  69. package/dist/program.d.ts +12 -0
  70. package/dist/program.d.ts.map +1 -0
  71. package/dist/program.js +204 -0
  72. package/dist/program.js.map +1 -0
  73. package/dist/pull.d.ts +18 -0
  74. package/dist/pull.d.ts.map +1 -0
  75. package/dist/pull.js +324 -0
  76. package/dist/pull.js.map +1 -0
  77. package/dist/skill.d.ts +7 -0
  78. package/dist/skill.d.ts.map +1 -0
  79. package/dist/skill.js +28 -0
  80. package/dist/skill.js.map +1 -0
  81. package/dist/types.d.ts +32 -0
  82. package/dist/types.d.ts.map +1 -0
  83. package/dist/types.js +2 -0
  84. package/dist/types.js.map +1 -0
  85. package/dist/workspace-fingerprint.d.ts +8 -0
  86. package/dist/workspace-fingerprint.d.ts.map +1 -0
  87. package/dist/workspace-fingerprint.js +64 -0
  88. package/dist/workspace-fingerprint.js.map +1 -0
  89. package/package.json +54 -0
  90. package/skills/lark-base-database/SKILL.md +307 -0
  91. package/skills/lark-base-database/references/base-capability-routing.md +81 -0
  92. package/skills/lark-base-database/references/base-sql-unsupported.md +42 -0
  93. package/skills/lark-base-database/references/database-capability-skill.md +24 -0
  94. package/skills/lark-base-database/references/development.md +642 -0
  95. package/skills/lark-base-database/references/file-transfer.md +275 -0
  96. package/skills/lark-base-database/references/permission-design.md +138 -0
  97. package/skills/lark-base-database/references/release.md +81 -0
  98. package/skills/lark-base-database/references/sql-function.md +276 -0
  99. package/skills/lark-base-database/references/style-guide.md +88 -0
  100. package/templates/database-capability/SKILL.md.tmpl +41 -0
  101. package/templates/database-starter/README.md +11 -0
  102. package/templates/database-starter/gitignore +3 -0
  103. package/templates/database-starter/page/.prettierignore +4 -0
  104. package/templates/database-starter/page/.prettierrc.json +7 -0
  105. package/templates/database-starter/page/README.md +29 -0
  106. package/templates/database-starter/page/index.html +24 -0
  107. package/templates/database-starter/page/package-lock.json +2675 -0
  108. package/templates/database-starter/page/package.json +44 -0
  109. package/templates/database-starter/page/public/favicon.png +0 -0
  110. package/templates/database-starter/page/src/App.tsx +18 -0
  111. package/templates/database-starter/page/src/components/UserSelectorEditor/index.tsx +267 -0
  112. package/templates/database-starter/page/src/components/UserSelectorPanel/index.css +32 -0
  113. package/templates/database-starter/page/src/components/UserSelectorPanel/index.test.ts +24 -0
  114. package/templates/database-starter/page/src/components/UserSelectorPanel/index.tsx +478 -0
  115. package/templates/database-starter/page/src/components/UserSelectorPanel/user-search.test.ts +175 -0
  116. package/templates/database-starter/page/src/components/UserSelectorPanel/user-search.ts +201 -0
  117. package/templates/database-starter/page/src/components/database-runtime-ready.tsx +21 -0
  118. package/templates/database-starter/page/src/components/database-user-menu/index.tsx +238 -0
  119. package/templates/database-starter/page/src/components/feedback/page-state.tsx +66 -0
  120. package/templates/database-starter/page/src/components/floating-button/chatbot-entry.test.ts +23 -0
  121. package/templates/database-starter/page/src/components/floating-button/chatbot-entry.tsx +873 -0
  122. package/templates/database-starter/page/src/components/floating-button/index.tsx +196 -0
  123. package/templates/database-starter/page/src/components/floating-button/local-chat-panel.test.ts +25 -0
  124. package/templates/database-starter/page/src/components/floating-button/local-chat-panel.tsx +197 -0
  125. package/templates/database-starter/page/src/components/floating-button/utils/current-user.ts +56 -0
  126. package/templates/database-starter/page/src/components/floating-button/utils/env.ts +18 -0
  127. package/templates/database-starter/page/src/components/floating-button/utils/project-meta.test.ts +79 -0
  128. package/templates/database-starter/page/src/components/floating-button/utils/project-meta.ts +252 -0
  129. package/templates/database-starter/page/src/components/ui/button.tsx +56 -0
  130. package/templates/database-starter/page/src/index.css +67 -0
  131. package/templates/database-starter/page/src/lib/cn.test.ts +9 -0
  132. package/templates/database-starter/page/src/lib/cn.ts +7 -0
  133. package/templates/database-starter/page/src/lib/database-runtime-attachment.test.ts +168 -0
  134. package/templates/database-starter/page/src/lib/database-runtime-context.test.ts +111 -0
  135. package/templates/database-starter/page/src/lib/database-runtime.test.ts +103 -0
  136. package/templates/database-starter/page/src/lib/database-runtime.ts +389 -0
  137. package/templates/database-starter/page/src/main.tsx +34 -0
  138. package/templates/database-starter/page/src/vite-env.d.ts +1 -0
  139. package/templates/database-starter/page/tsconfig.json +23 -0
  140. package/templates/database-starter/page/vite.config.ts +13 -0
  141. package/templates/database-starter/prettierignore +1 -0
  142. package/templates/database-starter/server-functions/.prettierignore +3 -0
  143. package/templates/database-starter/server-functions/.prettierrc.json +7 -0
  144. package/templates/database-starter/server-functions/README.md +11 -0
  145. package/templates/database-starter/server-functions/package-lock.json +1687 -0
  146. package/templates/database-starter/server-functions/package.json +22 -0
  147. package/templates/database-starter/server-functions/scripts/run-tsc.mjs +50 -0
  148. package/templates/database-starter/server-functions/tsconfig.json +20 -0
  149. package/templates/database-starter/sql/README.md +8 -0
@@ -0,0 +1,252 @@
1
+ import {
2
+ buildDatabaseInvokeGetPath,
3
+ resolveDatabaseContext,
4
+ fetch as databaseFetch,
5
+ type DatabaseContext,
6
+ } from '../../../lib/database-runtime'
7
+
8
+ interface ProjectChat {
9
+ id: string
10
+ name: string
11
+ iconInfo: string
12
+ }
13
+
14
+ export interface ProjectBaseBinding {
15
+ baseToken: string
16
+ tableIds: string[]
17
+ }
18
+
19
+ export interface ProjectMeta {
20
+ baseBindings: ProjectBaseBinding[]
21
+ chatList: ProjectChat[]
22
+ name: string
23
+ description: string
24
+ isOwner: boolean
25
+ /** 应用所属组织(企业)名,浮层菜单展示用;服务端未下发时为空串。 */
26
+ organization: string
27
+ }
28
+
29
+ const projectMetaCache = new Map<string, Promise<ProjectMeta>>()
30
+
31
+ /** 判断输入是否为普通对象。
32
+ * @param value 待判断的数据。
33
+ * @returns 是普通对象时返回 true。
34
+ */
35
+ function isRecord(value: unknown): value is Record<string, unknown> {
36
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
37
+ }
38
+
39
+ /** 读取可选字符串字段。
40
+ * @param record 数据对象。
41
+ * @param key 字段名。
42
+ * @returns 字段字符串值;缺失或类型不匹配时返回空字符串。
43
+ */
44
+ function readOptionalString(
45
+ record: Record<string, unknown>,
46
+ key: string,
47
+ ): string {
48
+ const value = record[key]
49
+ return typeof value === 'string' ? value.trim() : ''
50
+ }
51
+
52
+ /** 读取字符串数组字段。
53
+ * @param value 待解析的数据。
54
+ * @returns 仅包含字符串项的数组。
55
+ */
56
+ function readStringArray(value: unknown): string[] {
57
+ return Array.isArray(value)
58
+ ? value.filter((item): item is string => typeof item === 'string')
59
+ : []
60
+ }
61
+
62
+ /** 构造空项目 meta。
63
+ * @returns chatList 为空的项目 meta。
64
+ */
65
+ function createEmptyProjectMeta(): ProjectMeta {
66
+ return {
67
+ baseBindings: [],
68
+ chatList: [],
69
+ name: '',
70
+ description: '',
71
+ isOwner: false,
72
+ organization: '',
73
+ }
74
+ }
75
+
76
+ /** 获取已有网页描述 meta 标签。
77
+ * @returns 已存在的 description meta 标签;不存在时返回 null。
78
+ */
79
+ function getExistingDescriptionMeta(): HTMLMetaElement | null {
80
+ const existingMeta = document.querySelector('meta[name="description"]')
81
+ if (existingMeta instanceof HTMLMetaElement) {
82
+ return existingMeta
83
+ }
84
+
85
+ return null
86
+ }
87
+
88
+ /** 将项目元信息同步到网页标题和描述。
89
+ * @param meta 项目元信息。
90
+ */
91
+ function applyDocumentMeta(meta: ProjectMeta): void {
92
+ if (meta.name) {
93
+ document.title = meta.name
94
+ }
95
+
96
+ if (meta.description) {
97
+ const descriptionMeta = getExistingDescriptionMeta()
98
+ if (descriptionMeta) {
99
+ descriptionMeta.content = meta.description
100
+ }
101
+ }
102
+ }
103
+
104
+ /** 解析 Site 绑定的 Base 列表。
105
+ * @param rawBaseBindings 接口返回的 baseBindings 字段。
106
+ * @returns 可用于页面跳转的 Base 绑定列表。
107
+ */
108
+ function parseProjectBaseBindings(
109
+ rawBaseBindings: unknown,
110
+ ): ProjectBaseBinding[] {
111
+ if (!Array.isArray(rawBaseBindings)) {
112
+ return []
113
+ }
114
+
115
+ return rawBaseBindings.reduce<ProjectBaseBinding[]>((bindings, binding) => {
116
+ if (!isRecord(binding)) {
117
+ return bindings
118
+ }
119
+
120
+ const baseToken = readOptionalString(binding, 'baseToken')
121
+ if (!baseToken) {
122
+ return bindings
123
+ }
124
+
125
+ bindings.push({
126
+ baseToken,
127
+ tableIds: readStringArray(binding.tableIds),
128
+ })
129
+ return bindings
130
+ }, [])
131
+ }
132
+
133
+ /** 解析 Site 绑定的 chatbot 列表。
134
+ *
135
+ * 接口的 chatbot 字段全部可选:缺 id 的条目无法跳转,直接丢弃;名称与图标缺失时
136
+ * 留空交由展示层兜底。单个 chatbot 字段不全不能影响整个项目 meta 的解析。
137
+ *
138
+ * @param rawChatbots 接口返回的 chatbots 字段。
139
+ * @returns 可用于展示的 chatbot 列表。
140
+ */
141
+ function parseProjectChats(rawChatbots: unknown): ProjectChat[] {
142
+ if (!Array.isArray(rawChatbots)) {
143
+ return []
144
+ }
145
+
146
+ return rawChatbots.reduce<ProjectChat[]>((chats, chatbot) => {
147
+ if (!isRecord(chatbot)) {
148
+ return chats
149
+ }
150
+
151
+ const id = readOptionalString(chatbot, 'id')
152
+ if (!id) {
153
+ return chats
154
+ }
155
+
156
+ chats.push({
157
+ id,
158
+ name: readOptionalString(chatbot, 'name'),
159
+ iconInfo: readOptionalString(chatbot, 'iconInfo'),
160
+ })
161
+ return chats
162
+ }, [])
163
+ }
164
+
165
+ /** 将资源接口响应转换为页面需要的项目 meta。
166
+ * @param rawResponse 接口原始响应。
167
+ * @returns 提取后的应用名称、描述和 chatbot 列表;接口或结构异常时返回空数据。
168
+ */
169
+ export function parseProjectMetaResponse(rawResponse: unknown): ProjectMeta {
170
+ if (!isRecord(rawResponse)) {
171
+ return createEmptyProjectMeta()
172
+ }
173
+
174
+ if (rawResponse.code !== 0) {
175
+ return createEmptyProjectMeta()
176
+ }
177
+
178
+ const data = rawResponse.data
179
+ if (!isRecord(data)) {
180
+ return createEmptyProjectMeta()
181
+ }
182
+
183
+ const site = data.site
184
+ if (!isRecord(site)) {
185
+ return createEmptyProjectMeta()
186
+ }
187
+
188
+ return {
189
+ baseBindings: parseProjectBaseBindings(site.baseBindings),
190
+ chatList: parseProjectChats(site.chatbots),
191
+ description: readOptionalString(site, 'description'),
192
+ name: readOptionalString(site, 'name'),
193
+ isOwner: data.isOwner === true,
194
+ organization: readOptionalString(site, 'organization'),
195
+ }
196
+ }
197
+
198
+ /** 请求项目资源元信息。
199
+ *
200
+ * 走统一网关的 GetInvokeProject,由父页面以同源身份代发;子页面不直连业务域名。
201
+ *
202
+ * @param context 当前应用上下文。
203
+ * @returns 解析后的项目 meta。
204
+ */
205
+ async function requestProjectMeta(
206
+ context: DatabaseContext,
207
+ ): Promise<ProjectMeta> {
208
+ try {
209
+ const response = await databaseFetch(
210
+ buildDatabaseInvokeGetPath(context, true),
211
+ {
212
+ method: 'GET',
213
+ },
214
+ )
215
+
216
+ if (!response.ok) {
217
+ return createEmptyProjectMeta()
218
+ }
219
+
220
+ const rawResponse: unknown = await response.json()
221
+ const projectMeta = parseProjectMetaResponse(rawResponse)
222
+ applyDocumentMeta(projectMeta)
223
+ return projectMeta
224
+ } catch {
225
+ return createEmptyProjectMeta()
226
+ }
227
+ }
228
+
229
+ /** 获取当前项目资源元信息,按应用 token 与环境复用缓存。
230
+ * @returns 结构化项目 meta。
231
+ */
232
+ export async function fetchProjectMeta(): Promise<ProjectMeta> {
233
+ const context = await resolveDatabaseContext()
234
+
235
+ if (!context.token) {
236
+ return createEmptyProjectMeta()
237
+ }
238
+
239
+ const cacheKey = `${context.env}:${context.token}`
240
+ const cachedMeta = projectMetaCache.get(cacheKey)
241
+ if (cachedMeta) {
242
+ return cachedMeta
243
+ }
244
+
245
+ const metaRequest = requestProjectMeta(context).catch((error) => {
246
+ projectMetaCache.delete(cacheKey)
247
+ throw error
248
+ })
249
+
250
+ projectMetaCache.set(cacheKey, metaRequest)
251
+ return metaRequest
252
+ }
@@ -0,0 +1,56 @@
1
+ import { Slot } from '@radix-ui/react-slot'
2
+ import { cva, type VariantProps } from 'class-variance-authority'
3
+ import type { ButtonHTMLAttributes } from 'react'
4
+ import { cn } from '../../lib/cn'
5
+
6
+ const buttonVariants = cva(
7
+ 'inline-flex min-h-10 items-center justify-center gap-2 rounded-lg px-4 text-sm font-semibold transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
8
+ {
9
+ variants: {
10
+ variant: {
11
+ primary:
12
+ 'bg-primary text-primary-foreground hover:bg-primary/90 active:bg-primary/80',
13
+ secondary:
14
+ 'bg-secondary text-secondary-foreground hover:bg-secondary/75 active:bg-secondary/60',
15
+ danger:
16
+ 'bg-danger text-danger-foreground hover:bg-danger/90 active:bg-danger/80',
17
+ ghost: 'hover:bg-secondary active:bg-secondary/75',
18
+ },
19
+ size: {
20
+ sm: 'min-h-9 px-3',
21
+ md: 'min-h-10 px-4',
22
+ lg: 'min-h-11 px-5 text-base',
23
+ icon: 'size-10 px-0',
24
+ },
25
+ },
26
+ defaultVariants: {
27
+ variant: 'primary',
28
+ size: 'md',
29
+ },
30
+ },
31
+ )
32
+
33
+ interface ButtonProps
34
+ extends ButtonHTMLAttributes<HTMLButtonElement>,
35
+ VariantProps<typeof buttonVariants> {
36
+ asChild?: boolean
37
+ }
38
+
39
+ export function Button({
40
+ asChild = false,
41
+ className,
42
+ size,
43
+ type,
44
+ variant,
45
+ ...props
46
+ }: ButtonProps) {
47
+ const Component = asChild ? Slot : 'button'
48
+
49
+ return (
50
+ <Component
51
+ className={cn(buttonVariants({ size, variant }), className)}
52
+ type={asChild ? undefined : (type ?? 'button')}
53
+ {...props}
54
+ />
55
+ )
56
+ }
@@ -0,0 +1,67 @@
1
+ @import 'tailwindcss';
2
+
3
+ :root {
4
+ color-scheme: light;
5
+ --background: #f6f7f9;
6
+ --foreground: #172033;
7
+ --card: #ffffff;
8
+ --card-foreground: #172033;
9
+ --primary: #245bdb;
10
+ --primary-foreground: #ffffff;
11
+ --secondary: #eef2f8;
12
+ --secondary-foreground: #25324a;
13
+ --muted: #eef1f5;
14
+ --muted-foreground: #667085;
15
+ --border: #dfe3ea;
16
+ --danger: #c9362b;
17
+ --danger-foreground: #ffffff;
18
+ --ring: #4c7ff0;
19
+ font-family:
20
+ -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica,
21
+ Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
22
+ line-height: 1.5;
23
+ font-weight: 400;
24
+ font-synthesis: none;
25
+ text-rendering: optimizeLegibility;
26
+ -webkit-font-smoothing: antialiased;
27
+ -moz-osx-font-smoothing: grayscale;
28
+ }
29
+
30
+ @theme inline {
31
+ --color-background: var(--background);
32
+ --color-foreground: var(--foreground);
33
+ --color-card: var(--card);
34
+ --color-card-foreground: var(--card-foreground);
35
+ --color-primary: var(--primary);
36
+ --color-primary-foreground: var(--primary-foreground);
37
+ --color-secondary: var(--secondary);
38
+ --color-secondary-foreground: var(--secondary-foreground);
39
+ --color-muted: var(--muted);
40
+ --color-muted-foreground: var(--muted-foreground);
41
+ --color-border: var(--border);
42
+ --color-danger: var(--danger);
43
+ --color-danger-foreground: var(--danger-foreground);
44
+ --color-ring: var(--ring);
45
+ }
46
+
47
+ * {
48
+ box-sizing: border-box;
49
+ }
50
+
51
+ body {
52
+ min-width: 320px;
53
+ margin: 0;
54
+ background: var(--background);
55
+ color: var(--foreground);
56
+ }
57
+
58
+ button,
59
+ input,
60
+ textarea,
61
+ select {
62
+ font: inherit;
63
+ }
64
+
65
+ ::selection {
66
+ background: color-mix(in srgb, var(--primary) 20%, transparent);
67
+ }
@@ -0,0 +1,9 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { cn } from './cn'
4
+
5
+ describe('cn', () => {
6
+ it('merges conditional classes and resolves Tailwind conflicts', () => {
7
+ expect(cn('px-2', { hidden: false }, 'px-4')).toBe('px-4')
8
+ })
9
+ })
@@ -0,0 +1,7 @@
1
+ import { clsx, type ClassValue } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ /** 合并条件 class,并解决 Tailwind utility 冲突。 */
5
+ export function cn(...inputs: ClassValue[]) {
6
+ return twMerge(clsx(inputs))
7
+ }
@@ -0,0 +1,168 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ const getRuntimeContext = vi.fn()
4
+ const siteRuntimeFetch = vi.fn()
5
+
6
+ vi.mock('@lark-base-open/base-site-channel/runtime', () => ({
7
+ fetch: siteRuntimeFetch,
8
+ invokeHttp: vi.fn(),
9
+ siteRuntime: { getRuntimeContext },
10
+ }))
11
+
12
+ const attachmentRef = {
13
+ fieldId: 'fldPhotos',
14
+ fileToken: 'boxcnFileToken',
15
+ recordId: 'recTicket',
16
+ tableId: 'tblTickets',
17
+ }
18
+
19
+ /** 重新加载模块,清掉 resolveDatabaseContext 的会话级缓存。 */
20
+ async function loadDatabaseRuntime() {
21
+ vi.resetModules()
22
+ return import('./database-runtime')
23
+ }
24
+
25
+ beforeEach(() => {
26
+ getRuntimeContext.mockReset()
27
+ siteRuntimeFetch.mockReset()
28
+ vi.stubGlobal('location', {
29
+ ancestorOrigins: ['https://ancestor.example'],
30
+ pathname: '/app/pathToken/',
31
+ })
32
+ vi.stubGlobal('document', {
33
+ referrer: 'https://referrer.example/parent/path',
34
+ })
35
+ })
36
+
37
+ afterEach(() => {
38
+ vi.unstubAllGlobals()
39
+ })
40
+
41
+ describe('buildAttachmentCoverUrl', () => {
42
+ it('uses the ancestor origin before the referrer', async () => {
43
+ getRuntimeContext.mockResolvedValue({ siteToken: 'hostToken' })
44
+
45
+ const { buildAttachmentCoverUrl, resolveDatabaseContext } =
46
+ await loadDatabaseRuntime()
47
+ const context = await resolveDatabaseContext()
48
+
49
+ expect(buildAttachmentCoverUrl(context, attachmentRef)).toMatch(
50
+ /^https:\/\/ancestor\.example\/space\/api\/box\/stream\/download\/v2\/cover\//,
51
+ )
52
+ })
53
+
54
+ it('falls back to the referrer origin when ancestorOrigins is unavailable', async () => {
55
+ vi.stubGlobal('location', { pathname: '/app/pathToken/' })
56
+ getRuntimeContext.mockResolvedValue({ siteToken: 'hostToken' })
57
+
58
+ const { buildAttachmentCoverUrl, resolveDatabaseContext } =
59
+ await loadDatabaseRuntime()
60
+ const context = await resolveDatabaseContext()
61
+
62
+ expect(buildAttachmentCoverUrl(context, attachmentRef)).toMatch(
63
+ /^https:\/\/referrer\.example\/space\/api\/box\/stream\/download\/v2\/cover\//,
64
+ )
65
+ })
66
+
67
+ it('snaps the requested size up and defaults to 360', async () => {
68
+ getRuntimeContext.mockResolvedValue({})
69
+ const { buildAttachmentCoverUrl } = await loadDatabaseRuntime()
70
+ const context = {
71
+ env: 'prod',
72
+ gatewayOrigin: 'https://gateway.example',
73
+ token: 'siteToken',
74
+ } as const
75
+
76
+ const requested = new URL(
77
+ buildAttachmentCoverUrl(context, attachmentRef, 480),
78
+ )
79
+ const defaulted = new URL(buildAttachmentCoverUrl(context, attachmentRef))
80
+
81
+ expect(requested.searchParams.get('width')).toBe('720')
82
+ expect(requested.searchParams.get('height')).toBe('720')
83
+ expect(defaulted.searchParams.get('width')).toBe('360')
84
+ expect(defaulted.searchParams.get('height')).toBe('360')
85
+ })
86
+
87
+ it('returns an empty string when the gateway origin is missing', async () => {
88
+ getRuntimeContext.mockResolvedValue({})
89
+ const { buildAttachmentCoverUrl } = await loadDatabaseRuntime()
90
+
91
+ expect(
92
+ buildAttachmentCoverUrl(
93
+ { env: 'prod', gatewayOrigin: '', token: 'siteToken' },
94
+ attachmentRef,
95
+ ),
96
+ ).toBe('')
97
+ })
98
+
99
+ it('puts the complete attachment location and site token in extra', async () => {
100
+ getRuntimeContext.mockResolvedValue({})
101
+ const { buildAttachmentCoverUrl } = await loadDatabaseRuntime()
102
+ const url = new URL(
103
+ buildAttachmentCoverUrl(
104
+ {
105
+ env: 'prod',
106
+ gatewayOrigin: 'https://gateway.example',
107
+ token: 'siteToken',
108
+ },
109
+ attachmentRef,
110
+ ),
111
+ )
112
+ const extra = JSON.parse(String(url.searchParams.get('extra'))) as unknown
113
+
114
+ expect(extra).toEqual({
115
+ bitablePerm: {
116
+ tableId: 'tblTickets',
117
+ attachments: {
118
+ fldPhotos: { recTicket: ['boxcnFileToken'] },
119
+ },
120
+ baseAppToken: 'siteToken',
121
+ },
122
+ })
123
+ })
124
+ })
125
+
126
+ describe('fetchAttachmentDownloadUrl', () => {
127
+ it('uses the bridge path and returns the plaintext self-authenticating URL', async () => {
128
+ siteRuntimeFetch.mockResolvedValue({
129
+ json: () =>
130
+ Promise.resolve({
131
+ data: { url: 'https://download.example/authcode/?code=signed' },
132
+ }),
133
+ ok: true,
134
+ })
135
+ const { fetchAttachmentDownloadUrl } = await loadDatabaseRuntime()
136
+ const context = {
137
+ env: 'prod',
138
+ gatewayOrigin: 'https://gateway.example',
139
+ token: 'siteToken',
140
+ } as const
141
+
142
+ await expect(
143
+ fetchAttachmentDownloadUrl(context, attachmentRef),
144
+ ).resolves.toBe('https://download.example/authcode/?code=signed')
145
+ expect(siteRuntimeFetch).toHaveBeenCalledWith(
146
+ '/space/api/box/file/temporary_download_url',
147
+ {
148
+ body: expect.any(String),
149
+ headers: { 'content-type': 'application/json' },
150
+ method: 'POST',
151
+ },
152
+ )
153
+
154
+ const request = siteRuntimeFetch.mock.calls[0]?.[1] as { body: string }
155
+ expect(JSON.parse(request.body)).toEqual({
156
+ extra: JSON.stringify({
157
+ bitablePerm: {
158
+ tableId: 'tblTickets',
159
+ attachments: {
160
+ fldPhotos: { recTicket: ['boxcnFileToken'] },
161
+ },
162
+ baseAppToken: 'siteToken',
163
+ },
164
+ }),
165
+ file_token: 'boxcnFileToken',
166
+ })
167
+ })
168
+ })
@@ -0,0 +1,111 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ const getRuntimeContext = vi.fn()
4
+
5
+ vi.mock('@lark-base-open/base-site-channel/runtime', () => ({
6
+ fetch: vi.fn(),
7
+ invokeHttp: vi.fn(),
8
+ siteRuntime: { getRuntimeContext },
9
+ }))
10
+
11
+ /** 重新加载模块,清掉 resolveDatabaseContext 的会话级缓存。 */
12
+ async function loadDatabaseRuntime() {
13
+ vi.resetModules()
14
+ return import('./database-runtime')
15
+ }
16
+
17
+ beforeEach(() => {
18
+ getRuntimeContext.mockReset()
19
+ vi.spyOn(console, 'warn').mockImplementation(() => undefined)
20
+ vi.spyOn(console, 'error').mockImplementation(() => undefined)
21
+ vi.stubGlobal('location', { pathname: '/app/pathToken/' })
22
+ })
23
+
24
+ afterEach(() => {
25
+ vi.unstubAllGlobals()
26
+ vi.restoreAllMocks()
27
+ })
28
+
29
+ describe('resolveDatabaseContext', () => {
30
+ it('prefers the host provided token and env over the artifact path', async () => {
31
+ getRuntimeContext.mockResolvedValue({
32
+ colorScheme: 'light',
33
+ env: 'preview',
34
+ locale: 'zh-CN',
35
+ mode: 'reading',
36
+ protocolVersion: 'site.v1',
37
+ siteToken: 'hostToken',
38
+ })
39
+
40
+ const { resolveDatabaseContext } = await loadDatabaseRuntime()
41
+ expect(await resolveDatabaseContext()).toEqual({
42
+ env: 'preview',
43
+ gatewayOrigin: '',
44
+ token: 'hostToken',
45
+ })
46
+ })
47
+
48
+ it('falls back to the artifact path when the host omits the fields', async () => {
49
+ getRuntimeContext.mockResolvedValue({
50
+ colorScheme: 'light',
51
+ locale: 'zh-CN',
52
+ mode: 'reading',
53
+ protocolVersion: 'site.v1',
54
+ })
55
+
56
+ const { resolveDatabaseContext } = await loadDatabaseRuntime()
57
+ expect(await resolveDatabaseContext()).toEqual({
58
+ env: 'prod',
59
+ gatewayOrigin: '',
60
+ token: 'pathToken',
61
+ })
62
+ })
63
+
64
+ it('falls back to the artifact path when the host call fails', async () => {
65
+ getRuntimeContext.mockRejectedValue(new Error('no host'))
66
+
67
+ const { resolveDatabaseContext } = await loadDatabaseRuntime()
68
+ expect(await resolveDatabaseContext()).toEqual({
69
+ env: 'prod',
70
+ gatewayOrigin: '',
71
+ token: 'pathToken',
72
+ })
73
+ expect(console.warn).toHaveBeenCalled()
74
+ })
75
+
76
+ it('ignores an unrecognised env but keeps the host token', async () => {
77
+ getRuntimeContext.mockResolvedValue({
78
+ env: 'staging',
79
+ siteToken: 'hostToken',
80
+ })
81
+
82
+ const { resolveDatabaseContext } = await loadDatabaseRuntime()
83
+ expect(await resolveDatabaseContext()).toEqual({
84
+ env: 'prod',
85
+ gatewayOrigin: '',
86
+ token: 'hostToken',
87
+ })
88
+ })
89
+
90
+ it('reports loudly when neither source yields a token', async () => {
91
+ vi.stubGlobal('location', { pathname: '/' })
92
+ getRuntimeContext.mockResolvedValue({})
93
+
94
+ const { resolveDatabaseContext } = await loadDatabaseRuntime()
95
+ expect(await resolveDatabaseContext()).toEqual({
96
+ env: 'prod',
97
+ gatewayOrigin: '',
98
+ token: '',
99
+ })
100
+ expect(console.error).toHaveBeenCalled()
101
+ })
102
+
103
+ it('caches the resolved context within a session', async () => {
104
+ getRuntimeContext.mockResolvedValue({ siteToken: 'hostToken' })
105
+
106
+ const { resolveDatabaseContext } = await loadDatabaseRuntime()
107
+ await resolveDatabaseContext()
108
+ await resolveDatabaseContext()
109
+ expect(getRuntimeContext).toHaveBeenCalledTimes(1)
110
+ })
111
+ })