@baishuyun/ai-design 2.0.48 → 2.0.50

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.
@@ -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':
@@ -33,7 +33,7 @@ function getItems(field: FieldValueInputProps['field']): WidgetItem[] {
33
33
  })) as WidgetItem[] ?? [];
34
34
  }
35
35
 
36
- function usePortalContainer(attr: string) {
36
+ export function usePortalContainer(attr: string) {
37
37
  const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
38
38
  const setTriggerRef = useCallback(
39
39
  (node: HTMLButtonElement | null) => {
@@ -131,7 +131,8 @@ export function ComboInput({ field, value, onChange, disabled, service, showLabe
131
131
  type="button"
132
132
  onClick={() => handleSelect(item.value)}
133
133
  className={cn(
134
- '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]',
135
136
  value === item.value
136
137
  ? 'bg-[#F0F6FF] text-[#0265FF]'
137
138
  : 'text-[#030303] hover:bg-[#EFF0F6]',
@@ -504,7 +505,8 @@ export function AsyncPicker({ field, value, onChange, disabled, service, kind, m
504
505
  type="button"
505
506
  onClick={() => handleSelect(opt)}
506
507
  className={cn(
507
- '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',
508
510
  isSelected
509
511
  ? 'bg-[#F0F6FF] text-[#0265FF]'
510
512
  : 'text-[#030303] hover:bg-[#EFF0F6]',
@@ -684,7 +686,8 @@ export function TextMultiSelect({ field, value, onChange, disabled, service, sho
684
686
  type="button"
685
687
  onClick={() => handleToggle(opt)}
686
688
  className={cn(
687
- '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',
688
691
  checked
689
692
  ? 'bg-[#F0F6FF] text-[#0265FF]'
690
693
  : 'text-[#030303] hover:bg-[#EFF0F6]',
@@ -1027,7 +1030,8 @@ export function AddressInput({ field, value, onChange, disabled, service, showLa
1027
1030
  setKeyword('');
1028
1031
  }}
1029
1032
  className={cn(
1030
- '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]',
1031
1035
  'text-[#030303] hover:bg-[#EFF0F6]',
1032
1036
  transCls,
1033
1037
  )}
@@ -1124,7 +1128,8 @@ export function LocationInput({ field, value, onChange, disabled, service, showL
1124
1128
  setKeyword('');
1125
1129
  }}
1126
1130
  className={cn(
1127
- '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]',
1128
1133
  'text-[#030303] hover:bg-[#EFF0F6]',
1129
1134
  transCls,
1130
1135
  )}
@@ -1,27 +1,27 @@
1
1
  import { useState, useCallback, useMemo } from 'react';
2
- import type { relationalOperator } from '@baishuyun/types';
3
- import { OPERATOR_LABEL_MAP } from '@baishuyun/ai-feat-utils';
2
+ import type { FieldType, relationalOperator } from '@baishuyun/types';
3
+ import { getOperatorLabel } from '@baishuyun/ai-feat-utils';
4
4
  import { Popover, PopoverTrigger, PopoverContent } from '../../ui/popover';
5
5
  import { cn } from '../../../lib/utils';
6
6
  import { transCls } from '../../../const/ui';
7
7
 
8
- const ALL_OPTIONS: Array<{ value: relationalOperator; label: string }> = [
9
- { value: 'eq', label: OPERATOR_LABEL_MAP.eq },
10
- { value: 'neq', label: OPERATOR_LABEL_MAP.neq },
11
- { value: 'gt', label: OPERATOR_LABEL_MAP.gt },
12
- { value: 'lt', label: OPERATOR_LABEL_MAP.lt },
13
- { value: 'gte', label: OPERATOR_LABEL_MAP.gte },
14
- { value: 'lte', label: OPERATOR_LABEL_MAP.lte },
15
- { value: 'contains', label: OPERATOR_LABEL_MAP.contains },
16
- { value: 'not_contains', label: OPERATOR_LABEL_MAP.not_contains },
17
- { value: 'in', label: OPERATOR_LABEL_MAP.in },
18
- { value: 'nin', label: OPERATOR_LABEL_MAP.nin },
19
- { value: 'all', label: OPERATOR_LABEL_MAP.all },
20
- { value: 'empty', label: OPERATOR_LABEL_MAP.empty },
21
- { value: 'not_empty', label: OPERATOR_LABEL_MAP.not_empty },
22
- { value: 'range', label: OPERATOR_LABEL_MAP.range },
23
- { value: 'like', label: OPERATOR_LABEL_MAP.like },
24
- { value: 'unlike', label: OPERATOR_LABEL_MAP.unlike },
8
+ const ALL_OPERATORS: relationalOperator[] = [
9
+ 'eq',
10
+ 'neq',
11
+ 'gt',
12
+ 'lt',
13
+ 'gte',
14
+ 'lte',
15
+ 'contains',
16
+ 'not_contains',
17
+ 'in',
18
+ 'nin',
19
+ 'all',
20
+ 'empty',
21
+ 'not_empty',
22
+ 'range',
23
+ 'like',
24
+ 'unlike',
25
25
  ];
26
26
 
27
27
  export interface IRelationalOperatorSelectProps {
@@ -29,6 +29,13 @@ export interface IRelationalOperatorSelectProps {
29
29
  defaultValue?: relationalOperator;
30
30
  onChange?: (value: relationalOperator) => void;
31
31
  operators?: relationalOperator[];
32
+ /**
33
+ * 字段类型:同一操作符在不同字段类型下描述不同
34
+ * (如部门多选的 in 为「包含任意一个」),未提供时使用默认文案
35
+ */
36
+ fieldType?: FieldType;
37
+ /** 逐操作符的文案覆盖,优先级高于 fieldType 推导 */
38
+ operatorLabels?: Partial<Record<relationalOperator, string>>;
32
39
  borderless?: boolean;
33
40
  disabled?: boolean;
34
41
  className?: string;
@@ -39,6 +46,8 @@ export const RelationalOperatorSelect = ({
39
46
  defaultValue = 'eq',
40
47
  onChange,
41
48
  operators,
49
+ fieldType,
50
+ operatorLabels,
42
51
  borderless = false,
43
52
  disabled = false,
44
53
  className,
@@ -48,10 +57,15 @@ export const RelationalOperatorSelect = ({
48
57
  const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
49
58
 
50
59
  const options = useMemo(() => {
51
- if (!operators || operators.length === 0) return ALL_OPTIONS;
52
- const set = new Set(operators);
53
- return ALL_OPTIONS.filter((o) => set.has(o.value));
54
- }, [operators]);
60
+ const list =
61
+ !operators || operators.length === 0
62
+ ? ALL_OPERATORS
63
+ : ALL_OPERATORS.filter((o) => new Set(operators).has(o));
64
+ return list.map((value) => ({
65
+ value,
66
+ label: operatorLabels?.[value] ?? getOperatorLabel(value, fieldType),
67
+ }));
68
+ }, [operators, fieldType, operatorLabels]);
55
69
 
56
70
  const resolvedValue = (() => {
57
71
  const v = controlledValue !== undefined ? controlledValue : internalValue;
@@ -47,9 +47,13 @@ export function DataTable<TData>({
47
47
  return (
48
48
  <div
49
49
  className={cn(
50
- 'rounded-[10px] border border-[#e0e0e0] overflow-hidden bg-white',
50
+ 'rounded-[10px] border border-[#e0e0e0] overflow-auto bg-white',
51
51
  className,
52
52
  )}
53
+ // 抽屉(vaul/Radix Dialog)经 react-remove-scroll 在 document 级 preventDefault
54
+ // wheel 事件;shadow DOM 下其 contains 判定失效,内部滚动会被误杀。
55
+ // 阻断冒泡使其不可见,恢复触摸板滚动(同 form-select 的处理先例)。
56
+ onWheel={(e) => e.stopPropagation()}
53
57
  >
54
58
  <table className="w-full h-full border-collapse">
55
59
  <thead>
@@ -59,7 +63,7 @@ export function DataTable<TData>({
59
63
  <th
60
64
  key={header.id}
61
65
  className={cn(
62
- 'bg-[#f5f7fa] h-[34px] px-[10px] text-left text-[14px] font-normal text-[#030303] border-b border-[#e0e0e0] align-middle whitespace-nowrap',
66
+ 'sticky top-0 z-[1] bg-[#f5f7fa] h-[34px] px-[10px] text-left text-[14px] font-normal text-[#030303] border-b border-[#e0e0e0] align-middle whitespace-nowrap',
63
67
  index !== 0 && 'border-l border-[#e0e0e0]',
64
68
  )}
65
69
  style={{