@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.
- package/CHANGELOG.md +11 -0
- package/README.md +169 -0
- package/changelog/CHANGELOG-0.x.md +103 -0
- package/config-schema.json +28 -0
- package/dist/agent-guide.d.ts +18 -0
- package/dist/agent-guide.d.ts.map +1 -0
- package/dist/agent-guide.js +30 -0
- package/dist/agent-guide.js.map +1 -0
- package/dist/archive.d.ts +16 -0
- package/dist/archive.d.ts.map +1 -0
- package/dist/archive.js +162 -0
- package/dist/archive.js.map +1 -0
- package/dist/assets.d.ts +4 -0
- package/dist/assets.d.ts.map +1 -0
- package/dist/assets.js +6 -0
- package/dist/assets.js.map +1 -0
- package/dist/auth.d.ts +17 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +63 -0
- package/dist/auth.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +15 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +50 -0
- package/dist/config.js.map +1 -0
- package/dist/database-skill.d.ts +22 -0
- package/dist/database-skill.d.ts.map +1 -0
- package/dist/database-skill.js +135 -0
- package/dist/database-skill.js.map +1 -0
- package/dist/deploy.d.ts +28 -0
- package/dist/deploy.d.ts.map +1 -0
- package/dist/deploy.js +476 -0
- package/dist/deploy.js.map +1 -0
- package/dist/environment.d.ts +12 -0
- package/dist/environment.d.ts.map +1 -0
- package/dist/environment.js +27 -0
- package/dist/environment.js.map +1 -0
- package/dist/function-schema.d.ts +3 -0
- package/dist/function-schema.d.ts.map +1 -0
- package/dist/function-schema.js +310 -0
- package/dist/function-schema.js.map +1 -0
- package/dist/init.d.ts +77 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +418 -0
- package/dist/init.js.map +1 -0
- package/dist/lark-cli.d.ts +8 -0
- package/dist/lark-cli.d.ts.map +1 -0
- package/dist/lark-cli.js +228 -0
- package/dist/lark-cli.js.map +1 -0
- package/dist/npm.d.ts +13 -0
- package/dist/npm.d.ts.map +1 -0
- package/dist/npm.js +93 -0
- package/dist/npm.js.map +1 -0
- package/dist/page-title.d.ts +3 -0
- package/dist/page-title.d.ts.map +1 -0
- package/dist/page-title.js +47 -0
- package/dist/page-title.js.map +1 -0
- package/dist/preview-receipt.d.ts +23 -0
- package/dist/preview-receipt.d.ts.map +1 -0
- package/dist/preview-receipt.js +133 -0
- package/dist/preview-receipt.js.map +1 -0
- package/dist/process.d.ts +17 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.js +53 -0
- package/dist/process.js.map +1 -0
- package/dist/program.d.ts +12 -0
- package/dist/program.d.ts.map +1 -0
- package/dist/program.js +204 -0
- package/dist/program.js.map +1 -0
- package/dist/pull.d.ts +18 -0
- package/dist/pull.d.ts.map +1 -0
- package/dist/pull.js +324 -0
- package/dist/pull.js.map +1 -0
- package/dist/skill.d.ts +7 -0
- package/dist/skill.d.ts.map +1 -0
- package/dist/skill.js +28 -0
- package/dist/skill.js.map +1 -0
- package/dist/types.d.ts +32 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/workspace-fingerprint.d.ts +8 -0
- package/dist/workspace-fingerprint.d.ts.map +1 -0
- package/dist/workspace-fingerprint.js +64 -0
- package/dist/workspace-fingerprint.js.map +1 -0
- package/package.json +54 -0
- package/skills/lark-base-database/SKILL.md +307 -0
- package/skills/lark-base-database/references/base-capability-routing.md +81 -0
- package/skills/lark-base-database/references/base-sql-unsupported.md +42 -0
- package/skills/lark-base-database/references/database-capability-skill.md +24 -0
- package/skills/lark-base-database/references/development.md +642 -0
- package/skills/lark-base-database/references/file-transfer.md +275 -0
- package/skills/lark-base-database/references/permission-design.md +138 -0
- package/skills/lark-base-database/references/release.md +81 -0
- package/skills/lark-base-database/references/sql-function.md +276 -0
- package/skills/lark-base-database/references/style-guide.md +88 -0
- package/templates/database-capability/SKILL.md.tmpl +41 -0
- package/templates/database-starter/README.md +11 -0
- package/templates/database-starter/gitignore +3 -0
- package/templates/database-starter/page/.prettierignore +4 -0
- package/templates/database-starter/page/.prettierrc.json +7 -0
- package/templates/database-starter/page/README.md +29 -0
- package/templates/database-starter/page/index.html +24 -0
- package/templates/database-starter/page/package-lock.json +2675 -0
- package/templates/database-starter/page/package.json +44 -0
- package/templates/database-starter/page/public/favicon.png +0 -0
- package/templates/database-starter/page/src/App.tsx +18 -0
- package/templates/database-starter/page/src/components/UserSelectorEditor/index.tsx +267 -0
- package/templates/database-starter/page/src/components/UserSelectorPanel/index.css +32 -0
- package/templates/database-starter/page/src/components/UserSelectorPanel/index.test.ts +24 -0
- package/templates/database-starter/page/src/components/UserSelectorPanel/index.tsx +478 -0
- package/templates/database-starter/page/src/components/UserSelectorPanel/user-search.test.ts +175 -0
- package/templates/database-starter/page/src/components/UserSelectorPanel/user-search.ts +201 -0
- package/templates/database-starter/page/src/components/database-runtime-ready.tsx +21 -0
- package/templates/database-starter/page/src/components/database-user-menu/index.tsx +238 -0
- package/templates/database-starter/page/src/components/feedback/page-state.tsx +66 -0
- package/templates/database-starter/page/src/components/floating-button/chatbot-entry.test.ts +23 -0
- package/templates/database-starter/page/src/components/floating-button/chatbot-entry.tsx +873 -0
- package/templates/database-starter/page/src/components/floating-button/index.tsx +196 -0
- package/templates/database-starter/page/src/components/floating-button/local-chat-panel.test.ts +25 -0
- package/templates/database-starter/page/src/components/floating-button/local-chat-panel.tsx +197 -0
- package/templates/database-starter/page/src/components/floating-button/utils/current-user.ts +56 -0
- package/templates/database-starter/page/src/components/floating-button/utils/env.ts +18 -0
- package/templates/database-starter/page/src/components/floating-button/utils/project-meta.test.ts +79 -0
- package/templates/database-starter/page/src/components/floating-button/utils/project-meta.ts +252 -0
- package/templates/database-starter/page/src/components/ui/button.tsx +56 -0
- package/templates/database-starter/page/src/index.css +67 -0
- package/templates/database-starter/page/src/lib/cn.test.ts +9 -0
- package/templates/database-starter/page/src/lib/cn.ts +7 -0
- package/templates/database-starter/page/src/lib/database-runtime-attachment.test.ts +168 -0
- package/templates/database-starter/page/src/lib/database-runtime-context.test.ts +111 -0
- package/templates/database-starter/page/src/lib/database-runtime.test.ts +103 -0
- package/templates/database-starter/page/src/lib/database-runtime.ts +389 -0
- package/templates/database-starter/page/src/main.tsx +34 -0
- package/templates/database-starter/page/src/vite-env.d.ts +1 -0
- package/templates/database-starter/page/tsconfig.json +23 -0
- package/templates/database-starter/page/vite.config.ts +13 -0
- package/templates/database-starter/prettierignore +1 -0
- package/templates/database-starter/server-functions/.prettierignore +3 -0
- package/templates/database-starter/server-functions/.prettierrc.json +7 -0
- package/templates/database-starter/server-functions/README.md +11 -0
- package/templates/database-starter/server-functions/package-lock.json +1687 -0
- package/templates/database-starter/server-functions/package.json +22 -0
- package/templates/database-starter/server-functions/scripts/run-tsc.mjs +50 -0
- package/templates/database-starter/server-functions/tsconfig.json +20 -0
- 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
|
+
})
|