@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,478 @@
1
+ import React, {
2
+ useCallback,
3
+ useEffect,
4
+ useId,
5
+ useRef,
6
+ useState,
7
+ type KeyboardEventHandler,
8
+ } from 'react'
9
+
10
+ import { cn } from '../../lib/cn'
11
+ import './index.css'
12
+ import { requestUsers, type SearchUsersData, type User } from './user-search'
13
+
14
+ export type {
15
+ SearchUsers,
16
+ SearchUsersData,
17
+ SearchUsersResponse,
18
+ User,
19
+ UserAvatar,
20
+ } from './user-search'
21
+
22
+ const SEARCH_DEBOUNCE_MS = 400
23
+ const SEARCH_CACHE_TTL_MS = 30_000
24
+ const RECORD_CREATOR_AVATAR_URL =
25
+ 'https://lf-package-cn.feishucdn.com/obj/feishu-static/ccm/static_resource/scm_upload/base_filter_current_user.png'
26
+
27
+ interface SearchUsersCacheEntry {
28
+ expiresAt: number
29
+ request: Promise<SearchUsersData>
30
+ }
31
+
32
+ const searchUsersCache = new Map<string, SearchUsersCacheEntry>()
33
+
34
+ export type UserSelectorAction = 'Select' | 'Remove' | 'Clear'
35
+ export type UserSelectorMode = 'multiple'
36
+ export type UserSelectorSearchAction = 'Input' | 'AutoClear'
37
+ export type UserSelectorValue = User | User[]
38
+
39
+ export interface UserSelectorPanelProps {
40
+ value?: UserSelectorValue | null
41
+ defaultValue?: UserSelectorValue
42
+ onChange?: (
43
+ value: UserSelectorValue | undefined,
44
+ meta: { option: User | undefined; action: UserSelectorAction },
45
+ ) => void
46
+ onSelect?: (user: User, meta: { option: User }) => void
47
+ onClose?: () => void
48
+ onSelectRecordCreator?: () => void
49
+ getUserDescription?: (user: User) => string | undefined
50
+ onSearch?: (value: string, meta: { action: UserSelectorSearchAction }) => void
51
+ loading?: boolean
52
+ mode?: UserSelectorMode
53
+ onKeyDown?: KeyboardEventHandler<HTMLInputElement>
54
+ }
55
+
56
+ /**
57
+ * 管理成员搜索与选择状态,可独立渲染或由 UserSelectorEditor 组合。
58
+ */
59
+ export default function UserSelectorPanel(
60
+ props: UserSelectorPanelProps,
61
+ ): React.JSX.Element {
62
+ const {
63
+ value: controlledValue,
64
+ defaultValue,
65
+ onChange,
66
+ onSelect,
67
+ onClose,
68
+ onSelectRecordCreator,
69
+ getUserDescription,
70
+ onSearch,
71
+ loading: externalLoading = false,
72
+ mode,
73
+ onKeyDown,
74
+ } = props
75
+ const isValueControlled = Object.prototype.hasOwnProperty.call(props, 'value')
76
+ const [innerValue, setInnerValue] = useState(defaultValue)
77
+ const [query, setQuery] = useState('')
78
+ const [users, setUsers] = useState<User[]>([])
79
+ const [requestLoading, setRequestLoading] = useState(false)
80
+ const [error, setError] = useState('')
81
+ const [isComposing, setIsComposing] = useState(false)
82
+ const [activeIndex, setActiveIndex] = useState(-1)
83
+ const inputRef = useRef<HTMLInputElement>(null)
84
+ const requestSequenceRef = useRef(0)
85
+ const generatedListboxId = useId()
86
+ const value = isValueControlled ? (controlledValue ?? undefined) : innerValue
87
+ const selectedUsers = Array.isArray(value) ? value : value ? [value] : []
88
+ const selectedUsersRef = useRef(selectedUsers)
89
+ selectedUsersRef.current = selectedUsers
90
+ const isMultiple = mode === 'multiple'
91
+ const loading = externalLoading || requestLoading
92
+ const optionCount = users.length + (onSelectRecordCreator ? 1 : 0)
93
+ const activeOptionId =
94
+ activeIndex >= 0 ? `${generatedListboxId}-option-${activeIndex}` : undefined
95
+ const isEmpty = !loading && optionCount === 0
96
+
97
+ const getSearchResult = useCallback((keyword: string) => {
98
+ const cacheKey = keyword.trim()
99
+ const cachedResult = searchUsersCache.get(cacheKey)
100
+ if (cachedResult && cachedResult.expiresAt > Date.now()) {
101
+ return cachedResult.request
102
+ }
103
+ searchUsersCache.delete(cacheKey)
104
+
105
+ const request = requestUsers(cacheKey)
106
+ .then(normalizeSearchUsersResponse)
107
+ .catch((error: unknown) => {
108
+ if (searchUsersCache.get(cacheKey)?.request === request) {
109
+ searchUsersCache.delete(cacheKey)
110
+ }
111
+ throw error
112
+ })
113
+ searchUsersCache.set(cacheKey, {
114
+ expiresAt: Date.now() + SEARCH_CACHE_TTL_MS,
115
+ request,
116
+ })
117
+ return request
118
+ }, [])
119
+
120
+ const updateQuery = useCallback(
121
+ (nextQuery: string, action: UserSelectorSearchAction) => {
122
+ setQuery(nextQuery)
123
+ onSearch?.(nextQuery, { action })
124
+ },
125
+ [onSearch],
126
+ )
127
+
128
+ const runSearch = useCallback(
129
+ async (keyword: string) => {
130
+ const requestSequence = ++requestSequenceRef.current
131
+ setRequestLoading(true)
132
+ setError('')
133
+ setUsers([])
134
+
135
+ try {
136
+ const data = await getSearchResult(keyword)
137
+ if (requestSequence !== requestSequenceRef.current) return
138
+
139
+ const missingSelectedUsers = selectedUsersRef.current.filter(
140
+ (selectedUser) =>
141
+ !data.users.some((user) => user.user_id === selectedUser.user_id),
142
+ )
143
+ const responseUsers =
144
+ missingSelectedUsers.length > 0
145
+ ? [...data.users, ...missingSelectedUsers]
146
+ : data.users
147
+
148
+ setUsers(responseUsers)
149
+ } catch (searchError) {
150
+ if (requestSequence !== requestSequenceRef.current) return
151
+ setError(
152
+ searchError instanceof Error ? searchError.message : '搜索成员失败',
153
+ )
154
+ setUsers([])
155
+ } finally {
156
+ if (requestSequence === requestSequenceRef.current) {
157
+ setRequestLoading(false)
158
+ }
159
+ }
160
+ },
161
+ [getSearchResult],
162
+ )
163
+
164
+ useEffect(() => {
165
+ inputRef.current?.focus()
166
+ }, [])
167
+
168
+ useEffect(() => {
169
+ requestSequenceRef.current += 1
170
+ setActiveIndex(-1)
171
+ if (isComposing) {
172
+ setRequestLoading(false)
173
+ return
174
+ }
175
+
176
+ setRequestLoading(true)
177
+ const timer = window.setTimeout(
178
+ () => {
179
+ void runSearch(query)
180
+ },
181
+ query.trim() ? SEARCH_DEBOUNCE_MS : 0,
182
+ )
183
+
184
+ return () => {
185
+ window.clearTimeout(timer)
186
+ requestSequenceRef.current += 1
187
+ }
188
+ }, [isComposing, query, runSearch])
189
+
190
+ const closePanel = () => {
191
+ requestSequenceRef.current += 1
192
+ updateQuery('', 'AutoClear')
193
+ onClose?.()
194
+ }
195
+
196
+ const selectUser = (user: User) => {
197
+ if (isMultiple) {
198
+ const isSelected = selectedUsers.some(
199
+ (selectedUser) => selectedUser.user_id === user.user_id,
200
+ )
201
+ const nextValue = isSelected
202
+ ? selectedUsers.filter(
203
+ (selectedUser) => selectedUser.user_id !== user.user_id,
204
+ )
205
+ : [...selectedUsers, user]
206
+
207
+ if (!isValueControlled) setInnerValue(nextValue)
208
+ onChange?.(nextValue, {
209
+ option: user,
210
+ action: isSelected ? 'Remove' : 'Select',
211
+ })
212
+ if (!isSelected) onSelect?.(user, { option: user })
213
+ updateQuery('', 'AutoClear')
214
+ return
215
+ }
216
+
217
+ const isSelected = selectedUsers.some(
218
+ (selectedUser) => selectedUser.user_id === user.user_id,
219
+ )
220
+ const nextValue = isSelected ? undefined : user
221
+
222
+ if (!isValueControlled) setInnerValue(nextValue)
223
+ onChange?.(nextValue, {
224
+ option: user,
225
+ action: isSelected ? 'Remove' : 'Select',
226
+ })
227
+ if (!isSelected) onSelect?.(user, { option: user })
228
+ closePanel()
229
+ }
230
+
231
+ const selectActiveOption = () => {
232
+ if (activeIndex < 0) return
233
+
234
+ if (onSelectRecordCreator && activeIndex === 0) {
235
+ onSelectRecordCreator()
236
+ closePanel()
237
+ return
238
+ }
239
+
240
+ const userIndex = activeIndex - (onSelectRecordCreator ? 1 : 0)
241
+ const user = users[userIndex]
242
+ if (user) selectUser(user)
243
+ }
244
+
245
+ const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
246
+ onKeyDown?.(event)
247
+ if (event.defaultPrevented) return
248
+ if (isKeyboardEventComposing(event)) return
249
+
250
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
251
+ event.preventDefault()
252
+ if (optionCount === 0) return
253
+ const direction = event.key === 'ArrowDown' ? 1 : -1
254
+ setActiveIndex((index) => {
255
+ if (index < 0) return direction > 0 ? 0 : optionCount - 1
256
+ return (index + direction + optionCount) % optionCount
257
+ })
258
+ return
259
+ }
260
+
261
+ if (event.key === 'Enter' && optionCount > 0 && activeIndex >= 0) {
262
+ event.preventDefault()
263
+ selectActiveOption()
264
+ }
265
+ }
266
+
267
+ const renderAvatar = (user: User) => {
268
+ const avatarUrl = user.avatar.avatar_72 || user.avatar.avatar_240
269
+
270
+ return (
271
+ <span className="relative mr-2 flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#e8f0fe] text-xs leading-none font-medium text-[#1456f0]">
272
+ {user.name.slice(0, 2)}
273
+ {avatarUrl ? (
274
+ <img
275
+ alt=""
276
+ className="absolute inset-0 size-full object-cover"
277
+ onError={(event) => {
278
+ event.currentTarget.hidden = true
279
+ }}
280
+ src={avatarUrl}
281
+ />
282
+ ) : null}
283
+ </span>
284
+ )
285
+ }
286
+
287
+ return (
288
+ <div
289
+ className={cn(
290
+ 'flex w-full flex-col overflow-hidden rounded-md border border-[#dee0e3] bg-white shadow-[0_8px_16px_rgb(31_35_41_/_10%)]',
291
+ isEmpty && 'py-0.5',
292
+ )}
293
+ style={{
294
+ height: isEmpty ? undefined : 314,
295
+ }}
296
+ >
297
+ <input
298
+ aria-activedescendant={activeOptionId}
299
+ aria-autocomplete="list"
300
+ aria-controls={generatedListboxId}
301
+ className={cn(
302
+ 'h-9 w-full shrink-0 border-0 border-b border-[rgb(31_35_41_/_15%)] px-3 !text-[14px] !leading-[normal] text-[#1f2329] outline-none placeholder:!text-[#8f959e]',
303
+ isEmpty && 'h-8 border-b-0 px-[11px]',
304
+ )}
305
+ maxLength={50}
306
+ id={`${generatedListboxId}-search`}
307
+ name="user-search"
308
+ onChange={(event) => updateQuery(event.target.value, 'Input')}
309
+ onCompositionEnd={(event) => {
310
+ setIsComposing(false)
311
+ updateQuery(event.currentTarget.value, 'Input')
312
+ }}
313
+ onCompositionStart={() => setIsComposing(true)}
314
+ onKeyDown={handleKeyDown}
315
+ placeholder="搜索成员"
316
+ ref={inputRef}
317
+ role="searchbox"
318
+ value={query}
319
+ />
320
+
321
+ <div
322
+ aria-label="成员列表"
323
+ aria-multiselectable={isMultiple || undefined}
324
+ className={cn(
325
+ 'flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto px-1 py-1 [scrollbar-color:#bbbfc4_transparent] [scrollbar-width:thin] [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:border [&::-webkit-scrollbar-thumb]:border-transparent [&::-webkit-scrollbar-thumb]:bg-[#bbbfc4] [&::-webkit-scrollbar-thumb]:bg-clip-padding [&::-webkit-scrollbar]:w-2',
326
+ isEmpty && 'relative flex-none gap-0 overflow-hidden px-0 py-0',
327
+ isEmpty &&
328
+ "pt-[7px] before:absolute before:top-[3px] before:right-0 before:left-0 before:h-px before:bg-[rgb(31_35_41_/_15%)] before:content-['']",
329
+ )}
330
+ id={generatedListboxId}
331
+ role="listbox"
332
+ >
333
+ {onSelectRecordCreator ? (
334
+ <button
335
+ aria-selected={false}
336
+ className={cn(
337
+ 'flex h-12 w-full shrink-0 items-center rounded border-0 bg-transparent px-2 text-left text-sm leading-[22px] text-[#1f2329] hover:!bg-[rgb(31_35_41_/_8%)]',
338
+ activeIndex === 0 && '!bg-[rgb(31_35_41_/_8%)]',
339
+ )}
340
+ id={`${generatedListboxId}-option-0`}
341
+ onClick={() => {
342
+ onSelectRecordCreator()
343
+ closePanel()
344
+ }}
345
+ onMouseEnter={() => setActiveIndex(0)}
346
+ role="option"
347
+ type="button"
348
+ >
349
+ <img
350
+ alt=""
351
+ className="mr-2 size-8 shrink-0 rounded-full object-cover"
352
+ src={RECORD_CREATOR_AVATAR_URL}
353
+ />
354
+ <span className="min-w-0 flex-1 truncate">添加此记录的人员</span>
355
+ <span aria-hidden="true" className="ml-[5px] size-3 shrink-0" />
356
+ </button>
357
+ ) : null}
358
+
359
+ {users.map((user, index) => {
360
+ const optionIndex = index + (onSelectRecordCreator ? 1 : 0)
361
+ const selected = selectedUsers.some(
362
+ (selectedUser) => selectedUser.user_id === user.user_id,
363
+ )
364
+ const description =
365
+ getUserDescription?.(user) || user.description || user.user_id
366
+
367
+ return (
368
+ <button
369
+ aria-selected={selected}
370
+ className={cn(
371
+ 'flex h-12 w-full shrink-0 items-center rounded border-0 bg-transparent px-2 text-left text-[#1f2329] hover:!bg-[rgb(31_35_41_/_8%)]',
372
+ activeIndex === optionIndex && '!bg-[rgb(31_35_41_/_8%)]',
373
+ )}
374
+ id={`${generatedListboxId}-option-${optionIndex}`}
375
+ key={user.user_id}
376
+ onClick={() => selectUser(user)}
377
+ onMouseEnter={() => setActiveIndex(optionIndex)}
378
+ role="option"
379
+ type="button"
380
+ >
381
+ {renderAvatar(user)}
382
+ <span className="flex min-w-0 flex-1 flex-col justify-center overflow-hidden">
383
+ <span className="block truncate text-sm leading-[22px] text-[#1f2329]">
384
+ {user.name}
385
+ </span>
386
+ <span className="block truncate text-xs leading-5 text-[#8f959e]">
387
+ {description}
388
+ </span>
389
+ </span>
390
+ <span
391
+ aria-hidden="true"
392
+ className="ml-[5px] flex h-5 w-3 shrink-0 items-center text-[#1456f0]"
393
+ >
394
+ {selected ? (
395
+ <svg className="block size-3" fill="none" viewBox="0 0 24 24">
396
+ <path
397
+ d="M4 11.293a1 1 0 0 1 1.414 0l4.072 4.07 9.07-9.07a1 1 0 0 1 1.415 0l.706.707a1 1 0 0 1 0 1.414L10.193 18.9a1 1 0 0 1-1.415 0l-5.485-5.485a1 1 0 0 1 0-1.414L4 11.293Z"
398
+ fill="currentColor"
399
+ />
400
+ </svg>
401
+ ) : null}
402
+ </span>
403
+ </button>
404
+ )
405
+ })}
406
+
407
+ {loading && users.length === 0 ? (
408
+ <div
409
+ className="flex min-h-0 flex-1 items-center justify-center"
410
+ role="status"
411
+ >
412
+ <span className="user-selector-loading-spinner flex size-6 text-[#0066ff]">
413
+ <svg
414
+ aria-hidden="true"
415
+ className="size-full"
416
+ fill="none"
417
+ viewBox="0 0 100 100"
418
+ >
419
+ <circle
420
+ cx="50"
421
+ cy="50"
422
+ r="35"
423
+ stroke="currentColor"
424
+ strokeLinecap="round"
425
+ strokeMiterlimit="10"
426
+ strokeWidth="11"
427
+ />
428
+ </svg>
429
+ </span>
430
+ <span className="sr-only">加载中...</span>
431
+ </div>
432
+ ) : null}
433
+ {isEmpty ? (
434
+ <div className="flex h-[54px] w-full items-center px-[3px] py-px">
435
+ <div className="flex h-[52px] w-full min-w-0 items-center overflow-hidden rounded py-1 pr-7 pl-2 text-sm leading-[22px] font-normal text-ellipsis whitespace-nowrap text-[#1f2329] [font-family:'PingFang_SC',system-ui,sans-serif]">
436
+ {error
437
+ ? error
438
+ : query.trim()
439
+ ? '没有匹配结果,换个关键词试试吧'
440
+ : '暂无推荐成员'}
441
+ </div>
442
+ </div>
443
+ ) : null}
444
+ </div>
445
+ </div>
446
+ )
447
+ }
448
+
449
+ function normalizeSearchUsersResponse(value: unknown): SearchUsersData {
450
+ if (!isRecord(value) || typeof value.code !== 'number') {
451
+ throw new Error('搜索用户返回结构不正确')
452
+ }
453
+
454
+ if (value.code !== 0) {
455
+ throw new Error(
456
+ typeof value.msg === 'string' && value.msg ? value.msg : '搜索成员失败',
457
+ )
458
+ }
459
+
460
+ if (!isRecord(value.data) || !Array.isArray(value.data.users)) {
461
+ throw new Error('搜索用户返回结构不正确')
462
+ }
463
+
464
+ return {
465
+ users: value.data.users as User[],
466
+ }
467
+ }
468
+
469
+ function isRecord(value: unknown): value is Record<string, unknown> {
470
+ return typeof value === 'object' && value !== null
471
+ }
472
+
473
+ export function isKeyboardEventComposing(event: {
474
+ keyCode?: number
475
+ nativeEvent?: { isComposing?: boolean }
476
+ }): boolean {
477
+ return event.nativeEvent?.isComposing === true || event.keyCode === 229
478
+ }
@@ -0,0 +1,175 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ const projectMeta = vi.hoisted(() => ({ fetchProjectMeta: vi.fn() }))
4
+ const runtime = vi.hoisted(() => ({ fetch: vi.fn() }))
5
+
6
+ vi.mock('../floating-button/utils/project-meta', () => projectMeta)
7
+ vi.mock('../../lib/database-runtime', () => runtime)
8
+
9
+ import { normalizeRecommendUsersResponse, requestUsers } from './user-search'
10
+
11
+ const recommendPayload = {
12
+ entities: {
13
+ users: {
14
+ '6882577547121393684': {
15
+ avatar_url: 'https://example.com/ada.png',
16
+ cn_name: '艾达',
17
+ department: '研究部',
18
+ en_name: 'Ada',
19
+ id: '6882577547121393684',
20
+ i18n_department: { en_us: 'Research', zh_cn: '研究部' },
21
+ is_external: false,
22
+ },
23
+ '6882577547121393685': {
24
+ avatar_url: 'https://example.com/grace.png',
25
+ cn_name: '格蕾丝',
26
+ department: '外部组织',
27
+ en_name: 'Grace',
28
+ id: '6882577547121393685',
29
+ i18n_tenant: { en_us: 'External tenant', zh_cn: '外部租户' },
30
+ is_external: true,
31
+ },
32
+ },
33
+ },
34
+ result_list: [
35
+ { token: '6882577547121393685', type: 0 },
36
+ { token: 'ignored-chat', type: 1 },
37
+ { token: '6882577547121393684', type: 0 },
38
+ ],
39
+ }
40
+
41
+ beforeEach(() => {
42
+ runtime.fetch.mockReset()
43
+ vi.stubGlobal('document', {
44
+ documentElement: { lang: 'zh-CN' },
45
+ })
46
+ projectMeta.fetchProjectMeta.mockReset()
47
+ projectMeta.fetchProjectMeta.mockResolvedValue({
48
+ baseBindings: [{ baseToken: 'bascnDemo', tableIds: ['tblDemo'] }],
49
+ })
50
+ })
51
+
52
+ describe('requestUsers', () => {
53
+ it('uses the same Channel Runtime fetch path as GetInvokeProject', async () => {
54
+ runtime.fetch.mockResolvedValue(
55
+ new Response(JSON.stringify(recommendPayload), { status: 200 }),
56
+ )
57
+
58
+ const response = await requestUsers('')
59
+
60
+ expect(response.data.users.map((user) => user.user_id)).toEqual([
61
+ '6882577547121393685',
62
+ '6882577547121393684',
63
+ ])
64
+ expect(runtime.fetch).toHaveBeenCalledTimes(1)
65
+ const [url, init] = runtime.fetch.mock.calls[0] ?? []
66
+ expect(url).toBe('/space/api/mention/recommend.v2/')
67
+ expect(init).toMatchObject({
68
+ headers: {
69
+ 'content-type': 'application/x-www-form-urlencoded',
70
+ },
71
+ method: 'POST',
72
+ })
73
+ expect(Object.fromEntries(new URLSearchParams(init.body))).toEqual({
74
+ config_source: '0',
75
+ obj_type: '8',
76
+ source: '4',
77
+ token: 'bascnDemo',
78
+ type: '0',
79
+ user_type: '0',
80
+ content: '',
81
+ })
82
+ })
83
+
84
+ it('trims the keyword and limits it to 50 characters', async () => {
85
+ runtime.fetch.mockResolvedValue(
86
+ new Response(JSON.stringify(recommendPayload), { status: 200 }),
87
+ )
88
+
89
+ await requestUsers(` ${'a'.repeat(60)} `)
90
+
91
+ const [, init] = runtime.fetch.mock.calls[0] ?? []
92
+ const body = new URLSearchParams(init.body)
93
+ expect(body.get('content')).toBe('a'.repeat(50))
94
+ })
95
+
96
+ it('fails before transport when the Site has no bound Base', async () => {
97
+ projectMeta.fetchProjectMeta.mockResolvedValue({ baseBindings: [] })
98
+
99
+ await expect(requestUsers('ada')).rejects.toThrow('应用未绑定 Base')
100
+ expect(runtime.fetch).not.toHaveBeenCalled()
101
+ })
102
+
103
+ it('reports an HTTP failure without attempting to parse the body', async () => {
104
+ runtime.fetch.mockResolvedValue(new Response('', { status: 503 }))
105
+
106
+ await expect(requestUsers('ada')).rejects.toThrow('HTTP 状态码:503')
107
+ })
108
+ })
109
+
110
+ describe('normalizeRecommendUsersResponse', () => {
111
+ it('keeps recommendation order and maps localized names and descriptions', () => {
112
+ const result = normalizeRecommendUsersResponse(recommendPayload, 'en-US')
113
+
114
+ expect(result.users).toEqual([
115
+ expect.objectContaining({
116
+ description: 'External tenant',
117
+ is_external: true,
118
+ name: 'Grace',
119
+ user_id: '6882577547121393685',
120
+ }),
121
+ expect.objectContaining({
122
+ description: 'Research',
123
+ is_external: false,
124
+ name: 'Ada',
125
+ user_id: '6882577547121393684',
126
+ }),
127
+ ])
128
+ expect(result.users[0]?.avatar.avatar_72).toBe(
129
+ 'https://example.com/grace.png',
130
+ )
131
+ expect(result.users[0]?.open_id).toBeUndefined()
132
+ })
133
+
134
+ it('prefers an explicit internal user_id and only preserves a real open_id', () => {
135
+ const result = normalizeRecommendUsersResponse(
136
+ {
137
+ entities: {
138
+ users: {
139
+ token_ada: {
140
+ id: 'legacy-id',
141
+ user_id: '6882577547121393684',
142
+ open_id: 'ou_ada',
143
+ name: 'Ada',
144
+ },
145
+ },
146
+ },
147
+ result_list: [{ token: 'token_ada', type: 0 }],
148
+ },
149
+ 'en-US',
150
+ )
151
+
152
+ expect(result.users).toEqual([
153
+ expect.objectContaining({
154
+ name: 'Ada',
155
+ open_id: 'ou_ada',
156
+ user_id: '6882577547121393684',
157
+ }),
158
+ ])
159
+ })
160
+
161
+ it('accepts a common response envelope', () => {
162
+ expect(
163
+ normalizeRecommendUsersResponse(
164
+ { code: 0, data: recommendPayload, msg: 'ok' },
165
+ 'zh-CN',
166
+ ).users.map((user) => user.name),
167
+ ).toEqual(['格蕾丝', '艾达'])
168
+ })
169
+
170
+ it('surfaces backend business errors', () => {
171
+ expect(() =>
172
+ normalizeRecommendUsersResponse({ code: 123, msg: '无权限' }),
173
+ ).toThrow('无权限')
174
+ })
175
+ })