@baishuyun/ai-design 2.1.0-alpha.2 → 2.1.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 (72) hide show
  1. package/.storybook/preview.ts +2 -0
  2. package/CHANGELOG.md +189 -20
  3. package/package.json +8 -9
  4. package/scripts/post-build.cjs +32 -0
  5. package/src/components/agent-drawer.tsx +119 -0
  6. package/src/components/agent-helper/agent-entry-card/index.tsx +3 -8
  7. package/src/components/agent-helper/agent-helper-card/index.tsx +3 -3
  8. package/src/components/agent-helper/app-select/index.tsx +119 -84
  9. package/src/components/agent-helper/condition-input/index.tsx +18 -21
  10. package/src/components/agent-helper/condition-item/index.tsx +1 -0
  11. package/src/components/agent-helper/field-edit-preference-select/index.tsx +31 -4
  12. package/src/components/agent-helper/field-value-input/dept-select.tsx +447 -0
  13. package/src/components/agent-helper/field-value-input/index.tsx +3 -2
  14. package/src/components/agent-helper/field-value-input/picker-inputs.tsx +20 -18
  15. package/src/components/agent-helper/form-select/index.tsx +12 -25
  16. package/src/components/agent-helper/relational-operator-select/index.tsx +37 -23
  17. package/src/components/agent-helper/table/index.tsx +6 -2
  18. package/src/components/agent-helper/team-wide-form-select/data-adapter.ts +109 -0
  19. package/src/components/agent-helper/team-wide-form-select/index.tsx +289 -0
  20. package/src/components/agent-helper/team-wide-form-select/tree-panel.tsx +180 -0
  21. package/src/components/agent-helper/team-wide-form-select/types.ts +36 -0
  22. package/src/components/agent-helper/team-wide-form-select/use-form-tree.ts +108 -0
  23. package/src/components/agent-helper/trigger-select/index.tsx +1 -1
  24. package/src/components/agent-select.tsx +115 -35
  25. package/src/components/bot-avatar-name.tsx +1 -1
  26. package/src/components/bs-entry-select/build-tree.ts +0 -6
  27. package/src/components/bs-entry-select/entry-item.tsx +7 -11
  28. package/src/components/bs-entry-select/other-app-section.tsx +2 -4
  29. package/src/components/bs-entry-select/scroll-slider.tsx +6 -11
  30. package/src/components/bs-icons.tsx +752 -206
  31. package/src/components/card.tsx +55 -10
  32. package/src/components/circle-checker.tsx +18 -3
  33. package/src/components/collapsible-txt-msg.tsx +18 -13
  34. package/src/components/fake-bot-msg.tsx +18 -7
  35. package/src/components/field-checker-list-msg.tsx +4 -3
  36. package/src/components/fields-design-info-table.tsx +9 -26
  37. package/src/components/fields-portal.tsx +2 -2
  38. package/src/components/fields-previewer.tsx +1 -0
  39. package/src/components/font-icon.tsx +20 -9
  40. package/src/components/form-info-editor.tsx +21 -6
  41. package/src/components/legacy-slot.tsx +11 -16
  42. package/src/components/memoized-markdown.tsx +32 -19
  43. package/src/components/message-content.tsx +7 -3
  44. package/src/components/output-config-editor.tsx +39 -25
  45. package/src/components/primary-confirm-btn.tsx +6 -1
  46. package/src/components/scroll-to-bottom-btn.tsx +3 -2
  47. package/src/components/shadow-content.tsx +19 -0
  48. package/src/components/shadow-portal.tsx +23 -0
  49. package/src/components/ui/dialog.tsx +4 -1
  50. package/src/components/ui/drawer.tsx +3 -1
  51. package/src/components/ui/enhanced-drawer.tsx +134 -0
  52. package/src/components/ui/popover.tsx +3 -2
  53. package/src/components/ui/select.tsx +3 -2
  54. package/src/components/ui/tooltip.tsx +3 -2
  55. package/src/components/user-txt-msg-bubble.tsx +41 -19
  56. package/src/components/warning-msg.tsx +29 -12
  57. package/src/const/shadow.ts +17 -0
  58. package/src/const/ui.ts +2 -2
  59. package/src/hooks/use-column-appear.ts +1 -1
  60. package/src/hooks/use-portal-container.ts +6 -3
  61. package/src/hooks/use-shadow-boundary.ts +67 -0
  62. package/src/hooks/use-shadow.ts +24 -7
  63. package/src/index.ts +21 -0
  64. package/src/stories/AppSelect.stories.tsx +44 -0
  65. package/src/stories/FieldValueInput.stories.tsx +6 -3
  66. package/src/stories/FormSelect.stories.tsx +40 -0
  67. package/src/stories/IconfontPicker.stories.tsx +106 -0
  68. package/src/stories/RelationalOperatorSelect.stories.tsx +27 -0
  69. package/src/stories/TeamWideFormSelect.stories.tsx +362 -0
  70. package/src/stories/UserTextMsgBubble.stories.ts +28 -0
  71. package/src/style.css +28 -1
  72. package/style.css +172 -130
@@ -0,0 +1,447 @@
1
+ import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
2
+ import type { IDeptOption } from '@baishuyun/types';
3
+ import { Popover, PopoverTrigger, PopoverContent } from '../../ui/popover';
4
+ import { FieldWrapper, type FieldValueInputProps } from './index';
5
+ import { usePortalContainer } from './picker-inputs';
6
+ import { DropdownChevron, ClearIcon, CloseIcon, CheckIcon } from './icons';
7
+ import { SearchIcon } from '../../bs-icons';
8
+ import { ClearAllIcon } from '../../icons/clear-all-icon';
9
+ import { cn } from '../../../lib/utils';
10
+ import { transCls } from '../../../const/ui';
11
+
12
+ // ── Types ───────────────────────────────────────────────────────────
13
+
14
+ export type DeptSelectKind = 'dept' | 'deptgroup';
15
+
16
+ export interface DeptSelectProps extends FieldValueInputProps {
17
+ kind: DeptSelectKind;
18
+ multiple?: boolean;
19
+ }
20
+
21
+ interface DeptFlatNode {
22
+ id: string;
23
+ name: string;
24
+ parentId?: string;
25
+ }
26
+
27
+ interface DeptTreeNode extends DeptFlatNode {
28
+ children?: DeptTreeNode[];
29
+ }
30
+
31
+ // ── Tree utilities ──────────────────────────────────────────────────
32
+
33
+ /** 将服务返回的选项统一拍平:嵌套 children 与扁平 parentId 两种结构都支持 */
34
+ function flattenOptions(options: IDeptOption[], inheritedParentId?: string): DeptFlatNode[] {
35
+ const out: DeptFlatNode[] = [];
36
+ options.forEach((opt) => {
37
+ out.push({ id: opt.id, name: opt.name, parentId: opt.parentId ?? inheritedParentId });
38
+ if (opt.children?.length) {
39
+ out.push(...flattenOptions(opt.children, opt.id));
40
+ }
41
+ });
42
+ return out;
43
+ }
44
+
45
+ /** 由扁平列表构建部门树;parentId 缺失或找不到父节点时作为根节点 */
46
+ function buildTree(flat: DeptFlatNode[]): DeptTreeNode[] {
47
+ const nodeMap = new Map<string, DeptTreeNode>();
48
+ flat.forEach((f) => nodeMap.set(f.id, { ...f }));
49
+ const roots: DeptTreeNode[] = [];
50
+ flat.forEach((f) => {
51
+ const node = nodeMap.get(f.id)!;
52
+ const parent = f.parentId ? nodeMap.get(f.parentId) : undefined;
53
+ if (parent && parent.id !== node.id) {
54
+ parent.children = parent.children ?? [];
55
+ parent.children.push(node);
56
+ } else {
57
+ roots.push(node);
58
+ }
59
+ });
60
+ return roots;
61
+ }
62
+
63
+ // ── Icons ───────────────────────────────────────────────────────────
64
+
65
+ const TreeChevron = ({ expanded }: { expanded: boolean }) => (
66
+ <svg
67
+ width="14" height="14" viewBox="0 0 14 14" fill="none"
68
+ className="shrink-0 transition-transform duration-200"
69
+ style={{ transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
70
+ >
71
+ <path d="M5 3L9.5 7L5 11" stroke="#999999" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
72
+ </svg>
73
+ );
74
+
75
+ // ── DeptSelect ──────────────────────────────────────────────────────
76
+ //
77
+ // 等价于旧版 BsyDeptCheckBox:
78
+ // - 打开时一次性加载完整部门列表并缓存,构建组织架构树(可展开/收起)
79
+ // - 搜索时对整棵树按名称过滤,展示扁平结果;清空搜索恢复树形展示
80
+ // - 多选支持「全选」(搜索时为「全选搜索结果」),触发器内以标签展示已选项
81
+ // - 数据无 parentId 时(如部门组)退化为扁平列表
82
+
83
+ export function DeptSelect({
84
+ field,
85
+ value,
86
+ onChange,
87
+ disabled,
88
+ service,
89
+ kind,
90
+ multiple = false,
91
+ showLabel,
92
+ showDescription,
93
+ }: DeptSelectProps) {
94
+ const [open, setOpen] = useState(false);
95
+ const [keyword, setKeyword] = useState('');
96
+ const [loading, setLoading] = useState(false);
97
+ const [tree, setTree] = useState<DeptTreeNode[]>([]);
98
+ const [flatList, setFlatList] = useState<DeptFlatNode[]>([]);
99
+ const [nameMap, setNameMap] = useState<Record<string, string>>({});
100
+ const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
101
+ const [isHovering, setIsHovering] = useState(false);
102
+ const loadedRef = useRef(false);
103
+ const { portalContainer, setTriggerRef } = usePortalContainer('data-bs-field-value-picker-portal');
104
+
105
+ const fieldName = field.widget?.widgetName || '';
106
+ const searchFn = kind === 'dept' ? service?.searchDepts : service?.searchDeptGroups;
107
+
108
+ const selectedIds: string[] = multiple ? (Array.isArray(value) ? value.map(String) : []) : [];
109
+ const selectedIdSet = new Set(selectedIds);
110
+ const singleId = !multiple && typeof value === 'string' ? value : '';
111
+
112
+ const loadOptions = useCallback(async () => {
113
+ if (!searchFn || loadedRef.current) return;
114
+ loadedRef.current = true;
115
+ setLoading(true);
116
+ try {
117
+ const list = await searchFn('', { field: fieldName });
118
+ const flat = flattenOptions(list);
119
+ const nextTree = buildTree(flat);
120
+ setFlatList(flat);
121
+ setTree(nextTree);
122
+ setNameMap((prev) => {
123
+ const next = { ...prev };
124
+ flat.forEach((f) => {
125
+ next[f.id] = f.name;
126
+ });
127
+ return next;
128
+ });
129
+ // 与旧组件一致:默认展开第一个根节点
130
+ const firstWithChildren = nextTree.find((n) => n.children?.length);
131
+ if (firstWithChildren) {
132
+ setExpandedKeys(new Set([firstWithChildren.id]));
133
+ }
134
+ } finally {
135
+ setLoading(false);
136
+ }
137
+ }, [searchFn, fieldName]);
138
+
139
+ // 首次打开时加载;已有值时提前加载以解析名称
140
+ useEffect(() => {
141
+ if (open) loadOptions();
142
+ }, [open, loadOptions]);
143
+
144
+ useEffect(() => {
145
+ const hasValue = multiple ? selectedIds.length > 0 : !!singleId;
146
+ if (hasValue) loadOptions();
147
+ // eslint-disable-next-line react-hooks/exhaustive-deps
148
+ }, []);
149
+
150
+ const searching = !!keyword.trim();
151
+ const searchResults = useMemo(() => {
152
+ if (!searching) return [];
153
+ const kw = keyword.trim();
154
+ return flatList.filter((n) => n.name.includes(kw));
155
+ }, [searching, keyword, flatList]);
156
+
157
+ // 全选作用于当前可见范围:搜索时为搜索结果,否则为全部部门
158
+ const visibleIds = searching ? searchResults.map((n) => n.id) : flatList.map((n) => n.id);
159
+ const isAllSelected =
160
+ multiple && visibleIds.length > 0 && visibleIds.every((id) => selectedIdSet.has(id));
161
+
162
+ const handleToggle = useCallback(
163
+ (node: DeptFlatNode) => {
164
+ if (multiple) {
165
+ const next = selectedIdSet.has(node.id)
166
+ ? selectedIds.filter((id) => id !== node.id)
167
+ : [...selectedIds, node.id];
168
+ onChange?.(next);
169
+ } else {
170
+ onChange?.(node.id);
171
+ setNameMap((prev) => ({ ...prev, [node.id]: node.name }));
172
+ setOpen(false);
173
+ setKeyword('');
174
+ }
175
+ },
176
+ [multiple, selectedIds, selectedIdSet, onChange],
177
+ );
178
+
179
+ const handleSelectAll = useCallback(() => {
180
+ if (!multiple) return;
181
+ if (isAllSelected) {
182
+ const visibleSet = new Set(visibleIds);
183
+ onChange?.(selectedIds.filter((id) => !visibleSet.has(id)));
184
+ } else {
185
+ onChange?.([...new Set([...selectedIds, ...visibleIds])]);
186
+ }
187
+ }, [multiple, isAllSelected, visibleIds, selectedIds, onChange]);
188
+
189
+ const toggleExpand = useCallback((id: string, e: React.MouseEvent) => {
190
+ e.stopPropagation();
191
+ setExpandedKeys((prev) => {
192
+ const next = new Set(prev);
193
+ if (next.has(id)) {
194
+ next.delete(id);
195
+ } else {
196
+ next.add(id);
197
+ }
198
+ return next;
199
+ });
200
+ }, []);
201
+
202
+ const handleClearSingle = useCallback(
203
+ (e: React.MouseEvent) => {
204
+ e.stopPropagation();
205
+ onChange?.('');
206
+ },
207
+ [onChange],
208
+ );
209
+
210
+ const handleRemoveMultiple = useCallback(
211
+ (id: string, e: React.MouseEvent) => {
212
+ e.stopPropagation();
213
+ onChange?.(selectedIds.filter((v) => v !== id));
214
+ },
215
+ [selectedIds, onChange],
216
+ );
217
+
218
+ const handleClearAllMultiple = useCallback(
219
+ (e: React.MouseEvent) => {
220
+ e.stopPropagation();
221
+ onChange?.([]);
222
+ },
223
+ [onChange],
224
+ );
225
+
226
+ const renderTreeNodes = (nodes: DeptTreeNode[], level: number): React.ReactNode =>
227
+ nodes.map((node) => {
228
+ const hasChildren = !!node.children?.length;
229
+ const expanded = expandedKeys.has(node.id);
230
+ const checked = multiple ? selectedIdSet.has(node.id) : singleId === node.id;
231
+ return (
232
+ <div key={node.id}>
233
+ <button
234
+ type="button"
235
+ onClick={() => handleToggle(node)}
236
+ className={cn(
237
+ 'flex items-center gap-[6px] w-full h-[34px] pr-[10px] rounded-[4px] text-left',
238
+ checked ? 'bg-[#F0F6FF] text-[#0265FF]' : 'text-[#030303] hover:bg-[#EFF0F6]',
239
+ transCls,
240
+ )}
241
+ style={{ paddingLeft: 10 + level * 20 }}
242
+ >
243
+ {hasChildren ? (
244
+ <span
245
+ role="button"
246
+ tabIndex={-1}
247
+ onClick={(e) => toggleExpand(node.id, e)}
248
+ className="flex items-center justify-center w-[16px] h-[16px] shrink-0"
249
+ >
250
+ <TreeChevron expanded={expanded} />
251
+ </span>
252
+ ) : (
253
+ <span className="w-[16px] shrink-0" />
254
+ )}
255
+ {multiple && (
256
+ <div
257
+ className={cn(
258
+ 'w-[14px] h-[14px] rounded-[2px] flex items-center justify-center shrink-0 border',
259
+ checked ? 'bg-[#0265FF] border-[#0265FF]' : 'bg-white border-[#CCCCCC]',
260
+ )}
261
+ >
262
+ {checked && <CheckIcon />}
263
+ </div>
264
+ )}
265
+ <span className="truncate text-[14px]">{node.name}</span>
266
+ </button>
267
+ {hasChildren && expanded && renderTreeNodes(node.children!, level + 1)}
268
+ </div>
269
+ );
270
+ });
271
+
272
+ const renderSearchResults = () =>
273
+ searchResults.map((node) => {
274
+ const checked = multiple ? selectedIdSet.has(node.id) : singleId === node.id;
275
+ return (
276
+ <button
277
+ key={node.id}
278
+ type="button"
279
+ onClick={() => handleToggle(node)}
280
+ className={cn(
281
+ 'flex items-center gap-[10px] h-[34px] px-[10px] rounded-[4px] text-left',
282
+ checked ? 'bg-[#F0F6FF] text-[#0265FF]' : 'text-[#030303] hover:bg-[#EFF0F6]',
283
+ transCls,
284
+ )}
285
+ >
286
+ {multiple && (
287
+ <div
288
+ className={cn(
289
+ 'w-[14px] h-[14px] rounded-[2px] flex items-center justify-center shrink-0 border',
290
+ checked ? 'bg-[#0265FF] border-[#0265FF]' : 'bg-white border-[#CCCCCC]',
291
+ )}
292
+ >
293
+ {checked && <CheckIcon />}
294
+ </div>
295
+ )}
296
+ <span className="truncate text-[14px]">{node.name}</span>
297
+ </button>
298
+ );
299
+ });
300
+
301
+ const hasSelectionMulti = multiple && selectedIds.length > 0;
302
+ const showClearMulti = hasSelectionMulti && isHovering;
303
+ const singleLabel = singleId ? nameMap[singleId] || singleId : '';
304
+
305
+ return (
306
+ <FieldWrapper field={field} showLabel={showLabel} showDescription={showDescription}>
307
+ <Popover open={open} onOpenChange={setOpen}>
308
+ <PopoverTrigger asChild>
309
+ <button
310
+ ref={setTriggerRef}
311
+ type="button"
312
+ disabled={disabled}
313
+ onMouseEnter={() => setIsHovering(true)}
314
+ onMouseLeave={() => setIsHovering(false)}
315
+ className={cn(
316
+ 'flex items-center justify-between w-full h-[34px] gap-2.5',
317
+ 'bg-white border rounded-[4px]',
318
+ 'py-[7px]',
319
+ (multiple ? hasSelectionMulti : !!singleLabel) ? 'pl-[4px] pr-[10px]' : 'px-[10px]',
320
+ 'text-left cursor-pointer',
321
+ 'hover:border-[#0265FF]',
322
+ disabled && 'opacity-50 cursor-not-allowed hover:border-[#E0E0E0]',
323
+ open ? 'border-[#0265FF]' : 'border-[#E0E0E0]',
324
+ transCls,
325
+ )}
326
+ >
327
+ {multiple ? (
328
+ hasSelectionMulti ? (
329
+ <>
330
+ <div className="flex items-center gap-[4px] min-w-0 flex-1 overflow-x-auto scrollbar-hide">
331
+ {selectedIds.map((id) => (
332
+ <span
333
+ key={id}
334
+ className="flex items-center gap-[4px] bg-[#EEEEEE] rounded-[2px] h-[26px] px-[6px] shrink-0"
335
+ >
336
+ <span className="text-[14px] text-[#030303] leading-[normal]">
337
+ {nameMap[id] || id}
338
+ </span>
339
+ <span
340
+ className="cursor-pointer flex items-center shrink-0"
341
+ onClick={(e) => handleRemoveMultiple(id, e)}
342
+ >
343
+ <CloseIcon />
344
+ </span>
345
+ </span>
346
+ ))}
347
+ </div>
348
+ {showClearMulti ? (
349
+ <span
350
+ className="cursor-pointer flex items-center shrink-0"
351
+ onClick={handleClearAllMultiple}
352
+ >
353
+ <ClearAllIcon />
354
+ </span>
355
+ ) : (
356
+ <DropdownChevron open={open} />
357
+ )}
358
+ </>
359
+ ) : (
360
+ <>
361
+ <span className="text-[14px] text-[#999999] leading-[normal] truncate">
362
+ {field.description ?? `请选择${field.label}`}
363
+ </span>
364
+ <DropdownChevron open={open} />
365
+ </>
366
+ )
367
+ ) : (
368
+ <>
369
+ <span className={cn('truncate', !singleLabel && 'text-[#999999]')}>
370
+ {singleLabel || (field.description ?? `请选择${field.label}`)}
371
+ </span>
372
+ <div className="flex items-center gap-[4px] shrink-0">
373
+ {!!singleLabel && (
374
+ <span
375
+ role="button"
376
+ tabIndex={-1}
377
+ onClick={handleClearSingle}
378
+ className="flex items-center justify-center w-[18px] h-[18px] hover:bg-[#EFF0F6] rounded-full"
379
+ >
380
+ <ClearIcon />
381
+ </span>
382
+ )}
383
+ <DropdownChevron open={open} />
384
+ </div>
385
+ </>
386
+ )}
387
+ </button>
388
+ </PopoverTrigger>
389
+ <PopoverContent
390
+ align="start"
391
+ sideOffset={4}
392
+ portalContainer={portalContainer}
393
+ className="!border-0 rounded-[10px] shadow-[0px_0px_5px_rgba(0,0,0,0.04)] p-[8px]"
394
+ style={{ width: 'var(--radix-popover-trigger-width)' }}
395
+ >
396
+ <div className="flex items-center gap-[8px] px-[6px] mb-[4px] h-[34px] bg-[#F6F7FA] rounded-[4px]">
397
+ <SearchIcon />
398
+ <input
399
+ type="text"
400
+ value={keyword}
401
+ onChange={(e) => setKeyword(e.target.value)}
402
+ placeholder={`搜索${field.label}`}
403
+ className="flex-1 bg-transparent text-[14px] text-[#030303] placeholder:text-[#999999] outline-none border-none"
404
+ autoFocus
405
+ />
406
+ </div>
407
+ {multiple && !loading && visibleIds.length > 0 && (
408
+ <button
409
+ type="button"
410
+ onClick={handleSelectAll}
411
+ className={cn(
412
+ 'flex items-center gap-[10px] w-full h-[34px] px-[10px] rounded-[4px] text-left',
413
+ 'text-[#0265FF] hover:bg-[#EFF0F6]',
414
+ transCls,
415
+ )}
416
+ >
417
+ <div
418
+ className={cn(
419
+ 'w-[14px] h-[14px] rounded-[2px] flex items-center justify-center shrink-0 border',
420
+ isAllSelected ? 'bg-[#0265FF] border-[#0265FF]' : 'bg-white border-[#CCCCCC]',
421
+ )}
422
+ >
423
+ {isAllSelected && <CheckIcon />}
424
+ </div>
425
+ <span className="text-[14px]">{searching ? '全选搜索结果' : '全选'}</span>
426
+ </button>
427
+ )}
428
+ <div className="flex flex-col gap-0 max-h-[240px] overflow-y-auto">
429
+ {loading ? (
430
+ <div className="px-[10px] py-[8px] text-[14px] text-[#999999]">加载中...</div>
431
+ ) : searching ? (
432
+ searchResults.length === 0 ? (
433
+ <div className="px-[10px] py-[8px] text-[14px] text-[#999999]">暂无数据</div>
434
+ ) : (
435
+ renderSearchResults()
436
+ )
437
+ ) : tree.length === 0 ? (
438
+ <div className="px-[10px] py-[8px] text-[14px] text-[#999999]">暂无数据</div>
439
+ ) : (
440
+ renderTreeNodes(tree, 0)
441
+ )}
442
+ </div>
443
+ </PopoverContent>
444
+ </Popover>
445
+ </FieldWrapper>
446
+ );
447
+ }
@@ -5,6 +5,7 @@ import { cn } from '../../../lib/utils';
5
5
 
6
6
  import { TextInput, NumberInput, TextareaInput, PasswordInput, DateTimeInput, UploadInput, RangeNumberInput, RangeDateTimeInput } from './inputs';
7
7
  import { ComboInput, CheckboxGroupInput, RadioGroupInput, AsyncPicker, CascadingAddressInput, TextMultiSelect } from './picker-inputs';
8
+ import { DeptSelect } from './dept-select';
8
9
  import { SeparatorDisplay, ButtonDisplay, SubformPlaceholder } from './display';
9
10
 
10
11
  // ── Props ───────────────────────────────────────────────────────────
@@ -104,11 +105,11 @@ export const FieldValueInput = (props: FieldValueInputProps) => {
104
105
  case 'user':
105
106
  return <AsyncPicker {...props} kind="user" multiple={operator === 'in' || operator === 'nin'} />;
106
107
  case 'dept':
107
- return <AsyncPicker {...props} kind="dept" multiple={operator === 'in' || operator === 'nin'} />;
108
+ return <DeptSelect {...props} kind="dept" multiple={operator === 'in' || operator === 'nin'} />;
108
109
  case 'usergroup':
109
110
  return <AsyncPicker {...props} kind="usergroup" multiple={operator === 'in' || operator === 'nin' || operator === 'all'} />;
110
111
  case 'deptgroup':
111
- return <AsyncPicker {...props} kind="deptgroup" multiple={operator === 'in' || operator === 'nin' || operator === 'all'} />;
112
+ return <DeptSelect {...props} kind="deptgroup" multiple={operator === 'in' || operator === 'nin' || operator === 'all'} />;
112
113
 
113
114
  // ── Combo / linkdata / linkquery ──
114
115
  case 'combo':
@@ -19,24 +19,21 @@ import { chinaAddressData } from './china-address-data';
19
19
  const inputBaseCls =
20
20
  'h-[34px] px-[10px] py-[7px] rounded-[4px] bg-white border text-[14px] text-[#030303] placeholder:text-[#999999] focus:outline-none focus:border-[#0265FF] w-full disabled:bg-[#EFF0F6] disabled:cursor-not-allowed';
21
21
 
22
- type PickerItem = {
22
+ interface WidgetItem {
23
23
  label: string;
24
24
  value: string | number;
25
- };
26
-
27
- function getItems(field: FieldValueInputProps['field']): PickerItem[] {
28
- return (field.widget?.items ?? [])
29
- .map((it) => {
30
- if ('text' in it) {
31
- return { label: it.text, value: it.value };
32
- }
25
+ }
33
26
 
34
- return null;
35
- })
36
- .filter((item): item is PickerItem => item !== null);
27
+ function getItems(field: FieldValueInputProps['field']): WidgetItem[] {
28
+ return field.widget?.items
29
+ ?.filter((it) => typeof it.label === 'string' || typeof it.text === 'string')
30
+ .map((it) => ({
31
+ label: it.label ?? it.text ?? '',
32
+ value: it.value,
33
+ })) as WidgetItem[] ?? [];
37
34
  }
38
35
 
39
- function usePortalContainer(attr: string) {
36
+ export function usePortalContainer(attr: string) {
40
37
  const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
41
38
  const setTriggerRef = useCallback(
42
39
  (node: HTMLButtonElement | null) => {
@@ -134,7 +131,8 @@ export function ComboInput({ field, value, onChange, disabled, service, showLabe
134
131
  type="button"
135
132
  onClick={() => handleSelect(item.value)}
136
133
  className={cn(
137
- 'h-[34px] px-[10px] rounded-[4px] text-left text-[14px] leading-[normal]',
134
+ // shrink-0 防止 flex 容器 max-height 下子项收缩坍缩,改走 overflow 滚动
135
+ 'shrink-0 h-[34px] px-[10px] rounded-[4px] text-left text-[14px] leading-[normal]',
138
136
  value === item.value
139
137
  ? 'bg-[#F0F6FF] text-[#0265FF]'
140
138
  : 'text-[#030303] hover:bg-[#EFF0F6]',
@@ -507,7 +505,8 @@ export function AsyncPicker({ field, value, onChange, disabled, service, kind, m
507
505
  type="button"
508
506
  onClick={() => handleSelect(opt)}
509
507
  className={cn(
510
- 'flex items-center gap-[10px] h-[34px] px-[10px] rounded-[4px] text-left',
508
+ // shrink-0 防止 flex 容器 max-height 下子项收缩坍缩,改走 overflow 滚动
509
+ 'shrink-0 flex items-center gap-[10px] h-[34px] px-[10px] rounded-[4px] text-left',
511
510
  isSelected
512
511
  ? 'bg-[#F0F6FF] text-[#0265FF]'
513
512
  : 'text-[#030303] hover:bg-[#EFF0F6]',
@@ -687,7 +686,8 @@ export function TextMultiSelect({ field, value, onChange, disabled, service, sho
687
686
  type="button"
688
687
  onClick={() => handleToggle(opt)}
689
688
  className={cn(
690
- 'flex items-center gap-[10px] h-[34px] px-[10px] rounded-[4px] text-left',
689
+ // shrink-0 防止 flex 容器 max-height 下子项收缩坍缩,改走 overflow 滚动
690
+ 'shrink-0 flex items-center gap-[10px] h-[34px] px-[10px] rounded-[4px] text-left',
691
691
  checked
692
692
  ? 'bg-[#F0F6FF] text-[#0265FF]'
693
693
  : 'text-[#030303] hover:bg-[#EFF0F6]',
@@ -1030,7 +1030,8 @@ export function AddressInput({ field, value, onChange, disabled, service, showLa
1030
1030
  setKeyword('');
1031
1031
  }}
1032
1032
  className={cn(
1033
- 'h-[34px] px-[10px] rounded-[4px] text-left text-[14px] leading-[normal]',
1033
+ // shrink-0 防止 flex 容器 max-height 下子项收缩坍缩,改走 overflow 滚动
1034
+ 'shrink-0 h-[34px] px-[10px] rounded-[4px] text-left text-[14px] leading-[normal]',
1034
1035
  'text-[#030303] hover:bg-[#EFF0F6]',
1035
1036
  transCls,
1036
1037
  )}
@@ -1127,7 +1128,8 @@ export function LocationInput({ field, value, onChange, disabled, service, showL
1127
1128
  setKeyword('');
1128
1129
  }}
1129
1130
  className={cn(
1130
- 'h-[34px] px-[10px] rounded-[4px] text-left text-[14px] leading-[normal]',
1131
+ // shrink-0 防止 flex 容器 max-height 下子项收缩坍缩,改走 overflow 滚动
1132
+ 'shrink-0 h-[34px] px-[10px] rounded-[4px] text-left text-[14px] leading-[normal]',
1131
1133
  'text-[#030303] hover:bg-[#EFF0F6]',
1132
1134
  transCls,
1133
1135
  )}
@@ -12,6 +12,9 @@ import { cn } from '../../../lib/utils';
12
12
  import { transCls } from '../../../const/ui';
13
13
  import { SolidArrowDownIcon, SearchIcon } from '../../bs-icons';
14
14
  import { useSelectPopoverPosition } from '../../../hooks/use-select-popover-position';
15
+ import { FontIcon } from '../../font-icon';
16
+
17
+ const POPOVER_MAX_HEIGHT = 300;
15
18
 
16
19
  const FolderIcon = () => (
17
20
  <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
@@ -170,14 +173,6 @@ function buildFormTree(items: FormSelectItem[], searchQuery: string): FormTreeIt
170
173
  return result;
171
174
  }
172
175
 
173
- // ── Form Icon ─────────────────────────────────────────────────────
174
-
175
- function getFormIconClass(icon: number): string {
176
- const isNew = icon >= 25;
177
- const newStr = isNew ? '-new' : '';
178
- return `node-icon icon-bsy-form${newStr} icon${newStr}-bsy-form-${icon}`;
179
- }
180
-
181
176
  // ── Entry Item ────────────────────────────────────────────────────
182
177
 
183
178
  function FormEntryItem({
@@ -202,10 +197,7 @@ function FormEntryItem({
202
197
  onClick={onClick}
203
198
  >
204
199
  <span className="flex items-center gap-[6px] min-w-0 flex-1">
205
- <i
206
- className={getFormIconClass(item.icon)}
207
- style={{ fontSize: 16, lineHeight: 1 }}
208
- />
200
+ <FontIcon name={item.icon} />
209
201
  <span className="text-[14px] leading-[normal] truncate">
210
202
  {item.name}
211
203
  </span>
@@ -307,11 +299,12 @@ function FormPopover({
307
299
  <div
308
300
  className={cn(
309
301
  'bg-white rounded-[10px] shadow-[0px_0px_5px_rgba(0,0,0,0.04)] p-[4px]',
310
- 'flex flex-col gap-[4px]',
302
+ 'flex min-h-0 flex-col gap-[4px] overflow-hidden',
311
303
  )}
304
+ style={{ maxHeight: POPOVER_MAX_HEIGHT }}
312
305
  >
313
306
  {/* Search input */}
314
- <div className="flex items-center gap-[6px] border border-[#E0E0E0] rounded-[4px] h-[30px] px-[10px] py-[5px]">
307
+ <div className="flex shrink-0 items-center gap-[6px] border border-[#E0E0E0] rounded-[4px] h-[30px] px-[10px] py-[5px]">
315
308
  <SearchIcon />
316
309
  <input
317
310
  type="text"
@@ -337,7 +330,7 @@ function FormPopover({
337
330
  )}
338
331
 
339
332
  {/* Form list */}
340
- <div className="flex flex-col gap-0 max-h-[400px] overflow-y-auto">
333
+ <div className="flex min-h-0 flex-col gap-0 overflow-y-auto">
341
334
  {tree.length > 0 ? (
342
335
  tree.map((node) => (
343
336
  <FormTreeItemNode
@@ -391,7 +384,7 @@ export const FormSelect = forwardRef<HTMLDivElement, FormSelectProps>(
391
384
  const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
392
385
 
393
386
  const { popoverStyle, updatePopoverPosition } =
394
- useSelectPopoverPosition(triggerRef);
387
+ useSelectPopoverPosition(triggerRef, { estimatedHeight: POPOVER_MAX_HEIGHT });
395
388
 
396
389
  const resolvedValue = controlledValue ?? internalValue;
397
390
 
@@ -655,10 +648,7 @@ export const FormSelect = forwardRef<HTMLDivElement, FormSelectProps>(
655
648
  className="flex items-center gap-[4px] bg-[#EEEEEE] rounded-[2px] h-[26px] px-[6px] shrink-0 max-w-[160px]"
656
649
  onClick={(e) => handleRemoveChip(form.id, e)}
657
650
  >
658
- <i
659
- className={getFormIconClass(form.icon ?? 0)}
660
- style={{ fontSize: 14, lineHeight: 1 }}
661
- />
651
+ <FontIcon name={form.icon ?? 0} style={{ fontSize: 14 }} />
662
652
  <span className="text-[14px] text-[#030303] truncate leading-[normal]">
663
653
  {form.name}
664
654
  </span>
@@ -680,10 +670,7 @@ export const FormSelect = forwardRef<HTMLDivElement, FormSelectProps>(
680
670
  <span className="flex items-center gap-[6px] min-w-0 flex-1">
681
671
  {selectedForm ? (
682
672
  <>
683
- <i
684
- className={getFormIconClass(selectedForm.icon ?? 0)}
685
- style={{ fontSize: 16, lineHeight: 1 }}
686
- />
673
+ <FontIcon name={selectedForm.icon ?? 0} />
687
674
  <span className="text-[14px] text-[#030303] leading-[normal] truncate">
688
675
  {selectedForm.name}
689
676
  </span>
@@ -709,7 +696,7 @@ export const FormSelect = forwardRef<HTMLDivElement, FormSelectProps>(
709
696
  {/* Popover */}
710
697
  {isOpen &&
711
698
  createPortal(
712
- <div ref={popoverRef} style={popoverStyle}>
699
+ <div ref={popoverRef} className="flex flex-col" style={popoverStyle}>
713
700
  <FormPopover
714
701
  searchQuery={searchQuery}
715
702
  onSearchChange={setSearchQuery}