@baishuyun/ai-design 2.0.37 → 2.1.0-alpha.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 (269) hide show
  1. package/.storybook/main.ts +31 -31
  2. package/.storybook/preview.ts +16 -16
  3. package/CHANGELOG.md +512 -492
  4. package/components.json +22 -22
  5. package/package.json +9 -6
  6. package/scripts/strip-layers.cjs +41 -41
  7. package/src/components/abs-fullscreen-gradient-bg.tsx +35 -35
  8. package/src/components/agent-helper/agent-category-select-menu/index.tsx +57 -57
  9. package/src/components/agent-helper/agent-entry-card/index.tsx +94 -94
  10. package/src/components/agent-helper/agent-helper-card/index.tsx +179 -179
  11. package/src/components/agent-helper/agent-helper-card-list/index.tsx +59 -59
  12. package/src/components/agent-helper/agent-helper-main-layout/index.tsx +60 -60
  13. package/src/components/agent-helper/agent-sort-select/index.tsx +89 -89
  14. package/src/components/agent-helper/agent-tag/index.tsx +29 -29
  15. package/src/components/agent-helper/app-select/index.tsx +559 -559
  16. package/src/components/agent-helper/condition-group/index.tsx +75 -75
  17. package/src/components/agent-helper/condition-input/index.tsx +233 -233
  18. package/src/components/agent-helper/condition-item/index.tsx +237 -237
  19. package/src/components/agent-helper/condition-quantifier-select/index.tsx +142 -142
  20. package/src/components/agent-helper/condition-trigger-description/index.tsx +49 -49
  21. package/src/components/agent-helper/date-picker/calendar-panel.tsx +164 -164
  22. package/src/components/agent-helper/date-picker/date-input.tsx +91 -91
  23. package/src/components/agent-helper/date-picker/date-picker.tsx +327 -327
  24. package/src/components/agent-helper/date-picker/footer-panel.tsx +44 -44
  25. package/src/components/agent-helper/date-picker/index.ts +3 -3
  26. package/src/components/agent-helper/date-picker/month-panel.tsx +82 -82
  27. package/src/components/agent-helper/date-picker/time-panel.tsx +146 -146
  28. package/src/components/agent-helper/date-picker/types.ts +37 -37
  29. package/src/components/agent-helper/date-picker/utils.ts +121 -121
  30. package/src/components/agent-helper/date-picker/year-panel.tsx +86 -86
  31. package/src/components/agent-helper/exec-log-filter/index.tsx +232 -232
  32. package/src/components/agent-helper/field-edit-preference-select/index.tsx +291 -291
  33. package/src/components/agent-helper/field-select/index.tsx +466 -466
  34. package/src/components/agent-helper/field-value-input/china-address-data.ts +4117 -4117
  35. package/src/components/agent-helper/field-value-input/display.tsx +71 -71
  36. package/src/components/agent-helper/field-value-input/icons.tsx +38 -38
  37. package/src/components/agent-helper/field-value-input/index.tsx +144 -144
  38. package/src/components/agent-helper/field-value-input/inputs.tsx +247 -247
  39. package/src/components/agent-helper/field-value-input/picker-inputs.tsx +1141 -1141
  40. package/src/components/agent-helper/flow-node-action-trigger-select/index.tsx +57 -57
  41. package/src/components/agent-helper/form-action-trigger-select/index.tsx +60 -60
  42. package/src/components/agent-helper/form-divider/index.tsx +44 -44
  43. package/src/components/agent-helper/form-select/index.tsx +732 -732
  44. package/src/components/agent-helper/monthdays-select/index.tsx +378 -378
  45. package/src/components/agent-helper/offset-preference-select/index.tsx +268 -268
  46. package/src/components/agent-helper/offset-type-select/index.tsx +66 -66
  47. package/src/components/agent-helper/offset-unit-select/index.tsx +54 -54
  48. package/src/components/agent-helper/placeholder-card/index.tsx +75 -75
  49. package/src/components/agent-helper/prompt-input/index.tsx +646 -640
  50. package/src/components/agent-helper/relational-operator-select/index.tsx +179 -179
  51. package/src/components/agent-helper/repeat-by-type-select/index.tsx +53 -53
  52. package/src/components/agent-helper/repeat-preference-select/index.tsx +162 -162
  53. package/src/components/agent-helper/repeat-preference-type-select/index.tsx +58 -58
  54. package/src/components/agent-helper/search-input/index.tsx +35 -35
  55. package/src/components/agent-helper/table/index.tsx +114 -114
  56. package/src/components/agent-helper/trigger-select/index.tsx +121 -121
  57. package/src/components/agent-helper/trigger-time-preference-select/index.tsx +189 -189
  58. package/src/components/agent-helper/trigger-time-source-type-select/index.tsx +53 -53
  59. package/src/components/agent-helper/weekdays-select/index.tsx +230 -230
  60. package/src/components/agent-scroll-selector.tsx +193 -193
  61. package/src/components/agent-select.tsx +154 -154
  62. package/src/components/attachment-part-group.tsx +35 -35
  63. package/src/components/attachment-part.tsx +296 -296
  64. package/src/components/attachments-previewer.tsx +272 -272
  65. package/src/components/base-button.tsx +49 -49
  66. package/src/components/border-animation.tsx +107 -107
  67. package/src/components/border-color-animation.tsx +66 -66
  68. package/src/components/bot-avatar-name.tsx +36 -36
  69. package/src/components/bot-icon.tsx +23 -23
  70. package/src/components/bs-entry-select/app-group-folder.tsx +61 -61
  71. package/src/components/bs-entry-select/bs-entry-select.tsx +296 -296
  72. package/src/components/bs-entry-select/build-tree.ts +265 -265
  73. package/src/components/bs-entry-select/entry-item.tsx +45 -45
  74. package/src/components/bs-entry-select/entry-popover.tsx +99 -99
  75. package/src/components/bs-entry-select/index.ts +7 -7
  76. package/src/components/bs-entry-select/other-app-section.tsx +96 -96
  77. package/src/components/bs-entry-select/scroll-slider.tsx +872 -872
  78. package/src/components/bs-entry-select/search-input.tsx +4 -4
  79. package/src/components/bs-entry-select/tree-item-node.tsx +84 -84
  80. package/src/components/bs-entry-select/types.ts +46 -46
  81. package/src/components/bs-icons.tsx +1362 -1362
  82. package/src/components/card-field.tsx +23 -23
  83. package/src/components/card.tsx +36 -36
  84. package/src/components/chat-area-header.tsx +65 -65
  85. package/src/components/check-card.tsx +77 -77
  86. package/src/components/circle-checker.tsx +66 -66
  87. package/src/components/collapsible-txt-msg.tsx +75 -75
  88. package/src/components/collapsible.tsx +9 -9
  89. package/src/components/confirm-dialog.tsx +139 -139
  90. package/src/components/date-range-picker.tsx +91 -91
  91. package/src/components/fake-bot-msg.tsx +51 -51
  92. package/src/components/field-checker-list-msg.tsx +146 -146
  93. package/src/components/field-checker.tsx +185 -185
  94. package/src/components/field-icon.tsx +13 -13
  95. package/src/components/field-value-checker.tsx +116 -116
  96. package/src/components/fields-design-info-table.tsx +160 -160
  97. package/src/components/fields-generating-indicator.tsx +95 -95
  98. package/src/components/fields-portal.tsx +18 -18
  99. package/src/components/fields-previewer.tsx +216 -216
  100. package/src/components/font-icon.tsx +65 -65
  101. package/src/components/form-info-editor.tsx +263 -263
  102. package/src/components/formula-tag.tsx +32 -32
  103. package/src/components/generate-animation.tsx +56 -56
  104. package/src/components/icon-btn.tsx +43 -43
  105. package/src/components/icon-card.tsx +67 -67
  106. package/src/components/icons/calendar-icon.tsx +5 -5
  107. package/src/components/icons/clear-all-icon.tsx +5 -5
  108. package/src/components/icons/empty-agent-icon.tsx +93 -93
  109. package/src/components/icons/empty-table-icon.tsx +104 -104
  110. package/src/components/icons/grids-icon.tsx +5 -5
  111. package/src/components/img-part.tsx +27 -27
  112. package/src/components/legacy-slot.tsx +110 -110
  113. package/src/components/line-checker.tsx +168 -168
  114. package/src/components/linear-gradient-border-btn.tsx +48 -48
  115. package/src/components/linear-gradient-color-bg-animation.tsx +103 -103
  116. package/src/components/memoized-markdown.tsx +32 -32
  117. package/src/components/message-content.tsx +21 -21
  118. package/src/components/number-part.tsx +46 -46
  119. package/src/components/office-container.tsx +289 -265
  120. package/src/components/option-item.tsx +27 -27
  121. package/src/components/output-config-editor.tsx +478 -478
  122. package/src/components/previewer-header.tsx +270 -270
  123. package/src/components/primary-confirm-btn.tsx +58 -58
  124. package/src/components/primary-entry-btn.tsx +48 -48
  125. package/src/components/radio-card.tsx +63 -63
  126. package/src/components/scroll-to-bottom-btn.tsx +28 -28
  127. package/src/components/split-line.tsx +9 -9
  128. package/src/components/square-checker.tsx +87 -87
  129. package/src/components/subform-fields-value-checker.tsx +184 -184
  130. package/src/components/tab-radio-group.tsx +83 -83
  131. package/src/components/tooltip.tsx +53 -53
  132. package/src/components/ui/button.tsx +51 -51
  133. package/src/components/ui/calendar.tsx +57 -57
  134. package/src/components/ui/dialog.tsx +104 -104
  135. package/src/components/ui/drawer.tsx +279 -279
  136. package/src/components/ui/form.tsx +202 -202
  137. package/src/components/ui/icons.tsx +1267 -1267
  138. package/src/components/ui/input.tsx +28 -28
  139. package/src/components/ui/label.tsx +30 -30
  140. package/src/components/ui/popover.tsx +85 -85
  141. package/src/components/ui/select.tsx +236 -236
  142. package/src/components/ui/spinner.tsx +17 -17
  143. package/src/components/ui/switch.tsx +33 -33
  144. package/src/components/ui/tabs.tsx +62 -62
  145. package/src/components/ui/textarea.tsx +28 -28
  146. package/src/components/ui/tooltip.tsx +53 -53
  147. package/src/components/user-txt-msg-bubble.tsx +47 -47
  148. package/src/components/warning-msg.tsx +27 -27
  149. package/src/const/ui.ts +119 -119
  150. package/src/css.d.ts +4 -4
  151. package/src/hooks/use-column-appear.ts +78 -78
  152. package/src/hooks/use-portal-container.ts +27 -27
  153. package/src/hooks/use-resizable-width.ts +129 -129
  154. package/src/hooks/use-select-popover-position.ts +74 -74
  155. package/src/hooks/use-shadow.ts +127 -127
  156. package/src/index.ts +226 -226
  157. package/src/lib/utils.ts +138 -138
  158. package/src/stories/AbsFullscreenGradientBg.stories.tsx +36 -36
  159. package/src/stories/AgentCategorySelectMenu.stories.tsx +33 -33
  160. package/src/stories/AgentEntryCard.stories.tsx +90 -90
  161. package/src/stories/AgentHelperCard.stories.tsx +105 -105
  162. package/src/stories/AgentHelperCardList.stories.tsx +120 -120
  163. package/src/stories/AgentHelperMainLayout.stories.tsx +71 -71
  164. package/src/stories/AgentScrollSelector.stories.tsx +95 -95
  165. package/src/stories/AgentSelect.stories.tsx +134 -134
  166. package/src/stories/AgentTag.stories.tsx +44 -44
  167. package/src/stories/AppSelect.stories.tsx +250 -250
  168. package/src/stories/AttachmentPart.stories.tsx +122 -122
  169. package/src/stories/AttachmentPartGroup.stories.tsx +152 -152
  170. package/src/stories/AttachmentsPreviewer.stories.tsx +306 -306
  171. package/src/stories/BaseButton.stories.tsx +33 -33
  172. package/src/stories/BorderAnimation.stories.tsx +116 -116
  173. package/src/stories/BorderColorAnimation.stories.tsx +63 -63
  174. package/src/stories/BotAvatarAndName.stories.ts +24 -24
  175. package/src/stories/BotIcon.stories.ts +22 -22
  176. package/src/stories/BsConditionInput.stories.tsx +460 -460
  177. package/src/stories/BsEntrySelect.stories.tsx +343 -343
  178. package/src/stories/BsTooltip.stories.tsx +32 -32
  179. package/src/stories/Card.stories.tsx +28 -28
  180. package/src/stories/CardField.stories.tsx +87 -87
  181. package/src/stories/ChatAreaHeader.stories.ts +26 -26
  182. package/src/stories/CheckCard.stories.tsx +114 -114
  183. package/src/stories/CircleChecker.stories.tsx +89 -89
  184. package/src/stories/CollapsibleTextMsg.stories.ts +44 -44
  185. package/src/stories/ConditionGroup.stories.tsx +216 -216
  186. package/src/stories/ConditionInput.stories.tsx +502 -502
  187. package/src/stories/ConditionItem.stories.tsx +163 -163
  188. package/src/stories/ConditonQuantifierSelect.stories.tsx +54 -54
  189. package/src/stories/ConditonTriggerDescription.stories.tsx +61 -61
  190. package/src/stories/ConfirmDialog.stories.tsx +123 -123
  191. package/src/stories/DataTable.stories.tsx +191 -191
  192. package/src/stories/DatePicker.stories.tsx +174 -174
  193. package/src/stories/ExecLogFilter.stories.tsx +145 -145
  194. package/src/stories/FakeBotMsg.stories.tsx +36 -36
  195. package/src/stories/FieldEditPreferenceSelect.stories.tsx +161 -161
  196. package/src/stories/FieldSelect.stories.tsx +228 -228
  197. package/src/stories/FieldValueInput.stories.tsx +442 -442
  198. package/src/stories/FlowNodeActionTriggerSelect.stories.tsx +80 -80
  199. package/src/stories/Form.stories.tsx +302 -302
  200. package/src/stories/FormActionTriggerSelect.stories.tsx +80 -80
  201. package/src/stories/FormDivider.stories.tsx +31 -31
  202. package/src/stories/FormInfoEditor.stories.tsx +173 -173
  203. package/src/stories/FormSelect.stories.tsx +222 -222
  204. package/src/stories/FormulaTag.stories.tsx +68 -68
  205. package/src/stories/GenerateAnimation.stories.ts +20 -20
  206. package/src/stories/IconBtn.stories.tsx +32 -32
  207. package/src/stories/IconCard.stories.tsx +35 -35
  208. package/src/stories/IconLib.tsx +45 -45
  209. package/src/stories/Icons.stories.ts +22 -22
  210. package/src/stories/ImgMsgPart.stories.tsx +42 -42
  211. package/src/stories/Input.stories.tsx +102 -102
  212. package/src/stories/Label.stories.tsx +66 -66
  213. package/src/stories/LegacySlot.stories.tsx +56 -56
  214. package/src/stories/LineChecker.stories.tsx +133 -133
  215. package/src/stories/LinearGradientBorderBtn.stories.tsx +86 -86
  216. package/src/stories/LinearGradientColorBgAnimation.stories.tsx +60 -60
  217. package/src/stories/MemoizedMarkdown.stories.tsx +102 -102
  218. package/src/stories/MessageContent.stories.tsx +36 -36
  219. package/src/stories/MonthdaysSelect.stories.tsx +78 -78
  220. package/src/stories/NumbersMsgPart.stories.tsx +42 -42
  221. package/src/stories/OfficeContainer.stories.tsx +287 -287
  222. package/src/stories/OffsetPreferenceSelect.stories.tsx +118 -118
  223. package/src/stories/OffsetTypeSelect.stories.tsx +75 -75
  224. package/src/stories/OffsetUnitSelect.stories.tsx +61 -61
  225. package/src/stories/OptionItem.stories.tsx +77 -77
  226. package/src/stories/OutputConfigEditor.stories.tsx +96 -96
  227. package/src/stories/PlaceholderCard.stories.tsx +35 -35
  228. package/src/stories/PreviewerHeader.stories.tsx +91 -91
  229. package/src/stories/PrimaryConfirmBtn.stories.tsx +60 -60
  230. package/src/stories/PrimaryEntryBtn.stories.tsx +74 -74
  231. package/src/stories/PromptInput.stories.tsx +228 -228
  232. package/src/stories/RadioCard.stories.tsx +103 -103
  233. package/src/stories/RelationalOperatorSelect.stories.tsx +61 -61
  234. package/src/stories/RepeatByTypeSelect.stories.tsx +58 -58
  235. package/src/stories/RepeatPreferenceSelect.stories.tsx +107 -107
  236. package/src/stories/RepeatPreferenceTypeSelect.stories.tsx +58 -58
  237. package/src/stories/ScrollToBottom.stories.tsx +24 -24
  238. package/src/stories/SearchInput.stories.tsx +50 -50
  239. package/src/stories/Select.stories.tsx +128 -128
  240. package/src/stories/SplitLine.stories.tsx +40 -40
  241. package/src/stories/SquareChecker.stories.tsx +96 -96
  242. package/src/stories/TabRadioGroup.stories.tsx +84 -84
  243. package/src/stories/Textarea.stories.tsx +67 -67
  244. package/src/stories/TriggerSelect.stories.tsx +48 -48
  245. package/src/stories/TriggerTimePreferenceSelect.stories.tsx +106 -106
  246. package/src/stories/TriggerTimeSourceTypeSelect.stories.tsx +68 -68
  247. package/src/stories/UserTextMsgBubble.stories.ts +24 -24
  248. package/src/stories/WarningMessage.stories.tsx +23 -23
  249. package/src/stories/WeekdaysSelect.stories.tsx +66 -66
  250. package/src/stories/assets/demo.css +539 -539
  251. package/src/stories/assets/demo_index.html +7988 -7988
  252. package/src/stories/assets/iconfont.css +1377 -1377
  253. package/src/stories/assets/iconfont.json +2382 -2382
  254. package/src/stories/assets/iconfont.svg +697 -697
  255. package/src/stories/collapsible.stories.tsx +47 -47
  256. package/src/stories/drawer.stories.tsx +196 -196
  257. package/src/stories/field-checker-list-msg.stories.tsx +95 -95
  258. package/src/stories/field-checker.stories.tsx +117 -117
  259. package/src/stories/field-icon.stories.tsx +70 -70
  260. package/src/stories/field-value-checker.stories.tsx +113 -113
  261. package/src/stories/fields-design-info-table.stories.tsx +203 -203
  262. package/src/stories/fields-generating.stories.tsx +41 -41
  263. package/src/stories/fields-previewer.stories.tsx +77 -77
  264. package/src/stories/subform-fields-value-checker.stories.tsx +98 -98
  265. package/src/style.css +226 -226
  266. package/src/types.ts +8 -8
  267. package/style.css +3652 -3652
  268. package/tsconfig.json +13 -13
  269. package/vite.config.ts +44 -44
@@ -1,1141 +1,1141 @@
1
- import { useState, useCallback, useEffect, useRef } from 'react';
2
- import type {
3
- IFieldValueService,
4
- IUserOption,
5
- IDeptOption,
6
- ILinkDataOption,
7
- IAddressOption,
8
- ILocationOption,
9
- } from '@baishuyun/types';
10
- import { Popover, PopoverTrigger, PopoverContent } from '../../ui/popover';
11
- import { FieldWrapper, type FieldValueInputProps } from './index';
12
- import { DropdownChevron, ClearIcon, CloseIcon, CheckIcon } from './icons';
13
- import { SearchIcon } from '../../bs-icons';
14
- import { ClearAllIcon } from '../../icons/clear-all-icon';
15
- import { cn } from '../../../lib/utils';
16
- import { transCls } from '../../../const/ui';
17
- import { chinaAddressData } from './china-address-data';
18
-
19
- const inputBaseCls =
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
-
22
- interface WidgetItem {
23
- label: string;
24
- value: string | number;
25
- }
26
-
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[] ?? [];
34
- }
35
-
36
- function usePortalContainer(attr: string) {
37
- const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
38
- const setTriggerRef = useCallback(
39
- (node: HTMLButtonElement | null) => {
40
- if (!node) { setPortalContainer(null); return; }
41
- const rootNode = node.getRootNode();
42
- if (rootNode instanceof ShadowRoot) {
43
- const existing = rootNode.querySelector<HTMLElement>(`[${attr}]`);
44
- if (existing) { setPortalContainer(existing); return; }
45
- const container = document.createElement('div');
46
- container.setAttribute(attr, 'true');
47
- rootNode.appendChild(container);
48
- setPortalContainer(container);
49
- } else {
50
- setPortalContainer(null);
51
- }
52
- },
53
- [attr],
54
- );
55
- return { portalContainer, setTriggerRef };
56
- }
57
-
58
- // ── ComboInput (combo / linkdata / linkquery) ───────────────────────
59
-
60
- export function ComboInput({ field, value, onChange, disabled, service, showLabel, showDescription }: FieldValueInputProps) {
61
- const [open, setOpen] = useState(false);
62
- const [asyncItems, setAsyncItems] = useState<ILinkDataOption[]>([]);
63
- const [loading, setLoading] = useState(false);
64
- const { portalContainer, setTriggerRef } = usePortalContainer('data-bs-field-value-input-portal');
65
-
66
- const staticItems = getItems(field);
67
- const hasAsync = !!(field.widget?.type === 'linkdata' || field.widget?.type === 'linkquery');
68
-
69
- const selectedLabel =
70
- staticItems.find((it) => it.value === value)?.label ??
71
- asyncItems.find((it) => it.value === value)?.label ??
72
- '';
73
-
74
- useEffect(() => {
75
- if (hasAsync && open && service?.fetchLinkData) {
76
- setLoading(true);
77
- service.fetchLinkData(field.widget?.widgetName ?? '').then((data) => {
78
- setAsyncItems(data);
79
- setLoading(false);
80
- });
81
- }
82
- }, [hasAsync, open, field.widget?.widgetName, service]);
83
-
84
- const displayItems = hasAsync ? asyncItems : staticItems;
85
-
86
- const handleSelect = useCallback(
87
- (v: string | number) => {
88
- onChange?.(v);
89
- setOpen(false);
90
- },
91
- [onChange],
92
- );
93
-
94
- return (
95
- <FieldWrapper field={field} showLabel={showLabel} showDescription={showDescription}>
96
- <Popover open={open} onOpenChange={setOpen}>
97
- <PopoverTrigger asChild>
98
- <button
99
- ref={setTriggerRef}
100
- type="button"
101
- disabled={disabled}
102
- className={cn(
103
- 'flex items-center justify-between gap-[10px]',
104
- inputBaseCls,
105
- 'text-left cursor-pointer',
106
- open ? 'border-[#0265FF]' : 'border-[#E0E0E0] hover:border-[#0265FF]',
107
- )}
108
- >
109
- <span className={cn('truncate', !selectedLabel && 'text-[#999999]')}>
110
- {selectedLabel || (field.description ?? `请选择${field.label}`)}
111
- </span>
112
- <DropdownChevron open={open} />
113
- </button>
114
- </PopoverTrigger>
115
- <PopoverContent
116
- align="start"
117
- sideOffset={4}
118
- portalContainer={portalContainer}
119
- className="!border-0 rounded-[10px] shadow-[0px_0px_5px_rgba(0,0,0,0.04)] p-[4px]"
120
- style={{ width: 'var(--radix-popover-trigger-width)' }}
121
- >
122
- {loading ? (
123
- <div className="px-[10px] py-[8px] text-[14px] text-[#999999]">加载中...</div>
124
- ) : displayItems.length === 0 ? (
125
- <div className="px-[10px] py-[8px] text-[14px] text-[#999999]">暂无数据</div>
126
- ) : (
127
- <div className="flex flex-col gap-0 max-h-[240px] overflow-y-auto">
128
- {displayItems.map((item) => (
129
- <button
130
- key={item.value}
131
- type="button"
132
- onClick={() => handleSelect(item.value)}
133
- className={cn(
134
- 'h-[34px] px-[10px] rounded-[4px] text-left text-[14px] leading-[normal]',
135
- value === item.value
136
- ? 'bg-[#F0F6FF] text-[#0265FF]'
137
- : 'text-[#030303] hover:bg-[#EFF0F6]',
138
- transCls,
139
- )}
140
- >
141
- {item.label}
142
- </button>
143
- ))}
144
- </div>
145
- )}
146
- </PopoverContent>
147
- </Popover>
148
- </FieldWrapper>
149
- );
150
- }
151
-
152
- // ── CheckboxGroupInput ──────────────────────────────────────────────
153
-
154
- export function CheckboxGroupInput({ field, value, onChange, disabled, showLabel, showDescription }: FieldValueInputProps) {
155
- const items = getItems(field);
156
- const selectedValues: string[] = Array.isArray(value) ? value.map(String) : [];
157
-
158
- const toggle = useCallback(
159
- (itemValue: string) => {
160
- const next = selectedValues.includes(itemValue)
161
- ? selectedValues.filter((v) => v !== itemValue)
162
- : [...selectedValues, itemValue];
163
- onChange?.(next);
164
- },
165
- [selectedValues, onChange],
166
- );
167
-
168
- return (
169
- <FieldWrapper field={field} showLabel={showLabel} showDescription={showDescription}>
170
- <div className="flex flex-col gap-[8px]">
171
- {items.map((item) => {
172
- const checked = selectedValues.includes(String(item.value));
173
- return (
174
- <label
175
- key={item.value}
176
- className={cn(
177
- 'flex items-center gap-[8px] cursor-pointer text-[14px] text-[#030303]',
178
- disabled && 'cursor-not-allowed opacity-50',
179
- )}
180
- >
181
- <div
182
- className={cn(
183
- 'w-[16px] h-[16px] rounded-[2px] flex items-center justify-center shrink-0 border',
184
- transCls,
185
- checked ? 'bg-[#0265FF] border-[#0265FF]' : 'bg-white border-[#CCCCCC]',
186
- !disabled && 'hover:border-[#0265FF]',
187
- )}
188
- >
189
- {checked && <CheckIcon />}
190
- </div>
191
- <input
192
- type="checkbox"
193
- checked={checked}
194
- onChange={() => toggle(String(item.value))}
195
- disabled={disabled}
196
- className="sr-only"
197
- />
198
- {item.label}
199
- </label>
200
- );
201
- })}
202
- </div>
203
- </FieldWrapper>
204
- );
205
- }
206
-
207
- // ── RadioGroupInput ─────────────────────────────────────────────────
208
-
209
- export function RadioGroupInput({ field, value, onChange, disabled, showLabel, showDescription }: FieldValueInputProps) {
210
- const items = getItems(field);
211
- return (
212
- <FieldWrapper field={field} showLabel={showLabel} showDescription={showDescription}>
213
- <div className="flex flex-col gap-[8px]">
214
- {items.map((item) => {
215
- const checked = String(value) === String(item.value);
216
- return (
217
- <label
218
- key={item.value}
219
- className={cn(
220
- 'flex items-center gap-[8px] cursor-pointer text-[14px] text-[#030303]',
221
- disabled && 'cursor-not-allowed opacity-50',
222
- )}
223
- >
224
- <div
225
- className={cn(
226
- 'w-[16px] h-[16px] rounded-full flex items-center justify-center shrink-0 border',
227
- transCls,
228
- checked ? 'border-[#0265FF]' : 'border-[#CCCCCC]',
229
- !disabled && 'hover:border-[#0265FF]',
230
- )}
231
- >
232
- {checked && <div className="w-[8px] h-[8px] rounded-full bg-[#0265FF]" />}
233
- </div>
234
- <input
235
- type="radio"
236
- checked={checked}
237
- onChange={() => onChange?.(item.value)}
238
- disabled={disabled}
239
- className="sr-only"
240
- />
241
- {item.label}
242
- </label>
243
- );
244
- })}
245
- </div>
246
- </FieldWrapper>
247
- );
248
- }
249
-
250
- // ── AsyncPicker (user / dept / usergroup / deptgroup) ──────────────
251
-
252
- type PickerKind = 'user' | 'dept' | 'usergroup' | 'deptgroup';
253
-
254
- interface AsyncPickerProps extends FieldValueInputProps {
255
- kind: PickerKind;
256
- multiple?: boolean;
257
- }
258
-
259
- function getSearchFn(kind: PickerKind, service?: IFieldValueService) {
260
- switch (kind) {
261
- case 'user': return service?.searchUsers;
262
- case 'dept': return service?.searchDepts;
263
- case 'usergroup': return service?.searchUserGroups;
264
- case 'deptgroup': return service?.searchDeptGroups;
265
- }
266
- }
267
-
268
- export function AsyncPicker({ field, value, onChange, disabled, service, kind, multiple = false, showLabel, showDescription }: AsyncPickerProps) {
269
- const [open, setOpen] = useState(false);
270
- const [keyword, setKeyword] = useState('');
271
- const [options, setOptions] = useState<Array<IUserOption | IDeptOption>>([]);
272
- const [loading, setLoading] = useState(false);
273
- const [selectedLabel, setSelectedLabel] = useState('');
274
- const [nameMap, setNameMap] = useState<Record<string, string>>({});
275
- const [isHovering, setIsHovering] = useState(false);
276
- const { portalContainer, setTriggerRef } = usePortalContainer('data-bs-field-value-picker-portal');
277
-
278
- const searchFn = getSearchFn(kind, service);
279
-
280
- const selectedIds: string[] = multiple
281
- ? (Array.isArray(value) ? value.map(String) : [])
282
- : [];
283
- const selectedIdSet = new Set(selectedIds);
284
-
285
- // Resolve labels for existing values
286
- useEffect(() => {
287
- if (multiple) {
288
- if (!Array.isArray(value) || value.length === 0) {
289
- setNameMap({});
290
- return;
291
- }
292
- if (service?.searchUsers && kind === 'user') {
293
- service.searchUsers('', { field: fieldName }).then((users) => {
294
- const map: Record<string, string> = {};
295
- users.forEach((u) => {
296
- if (value.includes(u.id)) map[u.id] = u.name;
297
- });
298
- setNameMap(map);
299
- });
300
- }
301
- } else {
302
- if (!value || value === '') {
303
- setSelectedLabel('');
304
- return;
305
- }
306
- if (service?.searchUsers && kind === 'user') {
307
- service.searchUsers('', { field: fieldName }).then((users) => {
308
- const found = users.find((u) => u.id === value);
309
- if (found) setSelectedLabel(found.name);
310
- });
311
- }
312
- }
313
- }, [value, service, kind, multiple]);
314
-
315
- const fieldName = field.widget?.widgetName || "";
316
- const doSearch = useCallback(
317
- async (kw: string) => {
318
- if (!searchFn) return;
319
- setLoading(true);
320
- try {
321
- const results = await searchFn(kw, { field: fieldName });
322
- setOptions(results);
323
- } finally {
324
- setLoading(false);
325
- }
326
- },
327
- [searchFn],
328
- );
329
-
330
- useEffect(() => {
331
- if (open) doSearch(keyword);
332
- }, [open, keyword, doSearch]);
333
-
334
- const handleSelect = useCallback(
335
- (opt: IUserOption | IDeptOption) => {
336
- if (multiple) {
337
- const next = selectedIdSet.has(opt.id)
338
- ? selectedIds.filter((id) => id !== opt.id)
339
- : [...selectedIds, opt.id];
340
- onChange?.(next);
341
- setNameMap((prev) => {
342
- if (selectedIdSet.has(opt.id)) {
343
- const nextMap = { ...prev };
344
- delete nextMap[opt.id];
345
- return nextMap;
346
- }
347
- return { ...prev, [opt.id]: opt.name };
348
- });
349
- } else {
350
- onChange?.(opt.id);
351
- setSelectedLabel(opt.name);
352
- setOpen(false);
353
- setKeyword('');
354
- }
355
- },
356
- [onChange, multiple, selectedIds, selectedIdSet],
357
- );
358
-
359
- const handleClear = useCallback(() => {
360
- if (multiple) {
361
- onChange?.([]);
362
- setNameMap({});
363
- } else {
364
- onChange?.('');
365
- setSelectedLabel('');
366
- }
367
- }, [onChange, multiple]);
368
-
369
- const handleRemoveMultiple = useCallback(
370
- (id: string, e: React.MouseEvent) => {
371
- e.stopPropagation();
372
- onChange?.(selectedIds.filter((v) => v !== id));
373
- setNameMap((prev) => {
374
- const next = { ...prev };
375
- delete next[id];
376
- return next;
377
- });
378
- },
379
- [selectedIds, onChange],
380
- );
381
-
382
- const handleClearAllMultiple = useCallback(
383
- (e: React.MouseEvent) => {
384
- e.stopPropagation();
385
- onChange?.([]);
386
- setNameMap({});
387
- },
388
- [onChange],
389
- );
390
-
391
- const hasSelectionMulti = multiple && selectedIds.length > 0;
392
- const showClearMulti = hasSelectionMulti && isHovering;
393
-
394
- return (
395
- <FieldWrapper field={field} showLabel={showLabel} showDescription={showDescription}>
396
- <Popover open={open} onOpenChange={setOpen}>
397
- <PopoverTrigger asChild>
398
- <button
399
- ref={setTriggerRef}
400
- type="button"
401
- disabled={disabled}
402
- onMouseEnter={() => setIsHovering(true)}
403
- onMouseLeave={() => setIsHovering(false)}
404
- className={cn(
405
- 'flex items-center justify-between w-full h-[34px] gap-2.5',
406
- 'bg-white border rounded-[4px]',
407
- 'py-[7px]',
408
- (multiple ? hasSelectionMulti : !!selectedLabel) ? 'pl-[4px] pr-[10px]' : 'px-[10px]',
409
- 'text-left cursor-pointer',
410
- 'hover:border-[#0265FF]',
411
- disabled && 'opacity-50 cursor-not-allowed hover:border-[#E0E0E0]',
412
- open ? 'border-[#0265FF]' : 'border-[#E0E0E0]',
413
- transCls,
414
- )}
415
- >
416
- {multiple ? (
417
- hasSelectionMulti ? (
418
- <>
419
- <div className="flex items-center gap-[4px] min-w-0 flex-1 overflow-x-auto scrollbar-hide">
420
- {selectedIds.map((id) => (
421
- <span
422
- key={id}
423
- className="flex items-center gap-[4px] bg-[#EEEEEE] rounded-[2px] h-[26px] px-[6px] shrink-0"
424
- >
425
- <span className="text-[14px] text-[#030303] leading-[normal]">
426
- {nameMap[id] || id}
427
- </span>
428
- <span
429
- className="cursor-pointer flex items-center shrink-0"
430
- onClick={(e) => handleRemoveMultiple(id, e)}
431
- >
432
- <CloseIcon />
433
- </span>
434
- </span>
435
- ))}
436
- </div>
437
- {showClearMulti ? (
438
- <span className="cursor-pointer flex items-center shrink-0" onClick={handleClearAllMultiple}>
439
- <ClearAllIcon />
440
- </span>
441
- ) : (
442
- <DropdownChevron open={open} />
443
- )}
444
- </>
445
- ) : (
446
- <>
447
- <span className="text-[14px] text-[#999999] leading-[normal] truncate">
448
- {field.description ?? `请选择${field.label}`}
449
- </span>
450
- <DropdownChevron open={open} />
451
- </>
452
- )
453
- ) : (
454
- <>
455
- <span className={cn('truncate', !selectedLabel && 'text-[#999999]')}>
456
- {selectedLabel || (field.description ?? `请选择${field.label}`)}
457
- </span>
458
- <div className="flex items-center gap-[4px] shrink-0">
459
- {!!selectedLabel && (
460
- <span
461
- role="button"
462
- tabIndex={-1}
463
- onClick={(e) => { e.stopPropagation(); handleClear(); }}
464
- className="flex items-center justify-center w-[18px] h-[18px] hover:bg-[#EFF0F6] rounded-full"
465
- >
466
- <ClearIcon />
467
- </span>
468
- )}
469
- <DropdownChevron open={open} />
470
- </div>
471
- </>
472
- )}
473
- </button>
474
- </PopoverTrigger>
475
- <PopoverContent
476
- align="start"
477
- sideOffset={4}
478
- portalContainer={portalContainer}
479
- className="!border-0 rounded-[10px] shadow-[0px_0px_5px_rgba(0,0,0,0.04)] p-[8px]"
480
- style={{ width: 'var(--radix-popover-trigger-width)' }}
481
- >
482
- <div className="flex items-center gap-[8px] px-[6px] mb-[4px] h-[34px] bg-[#F6F7FA] rounded-[4px]">
483
- <SearchIcon />
484
- <input
485
- type="text"
486
- value={keyword}
487
- onChange={(e) => setKeyword(e.target.value)}
488
- placeholder={`搜索${field.label}`}
489
- className="flex-1 bg-transparent text-[14px] text-[#030303] placeholder:text-[#999999] outline-none border-none"
490
- autoFocus
491
- />
492
- </div>
493
- <div className="flex flex-col gap-0 max-h-[200px] overflow-y-auto">
494
- {loading ? (
495
- <div className="px-[10px] py-[8px] text-[14px] text-[#999999]">搜索中...</div>
496
- ) : options.length === 0 ? (
497
- <div className="px-[10px] py-[8px] text-[14px] text-[#999999]">暂无数据</div>
498
- ) : (
499
- options.map((opt) => {
500
- const isSelected = multiple ? selectedIdSet.has(opt.id) : value === opt.id;
501
- return (
502
- <button
503
- key={opt.id}
504
- type="button"
505
- onClick={() => handleSelect(opt)}
506
- className={cn(
507
- 'flex items-center gap-[10px] h-[34px] px-[10px] rounded-[4px] text-left',
508
- isSelected
509
- ? 'bg-[#F0F6FF] text-[#0265FF]'
510
- : 'text-[#030303] hover:bg-[#EFF0F6]',
511
- transCls,
512
- )}
513
- >
514
- {multiple && (
515
- <div
516
- className={cn(
517
- 'w-[14px] h-[14px] rounded-[2px] flex items-center justify-center shrink-0 border',
518
- isSelected ? 'bg-[#0265FF] border-[#0265FF]' : 'bg-white border-[#CCCCCC]',
519
- )}
520
- >
521
- {isSelected && <CheckIcon />}
522
- </div>
523
- )}
524
- {'avatar' in opt && opt.avatar && (
525
- <img src={opt.avatar} alt="" className="w-[20px] h-[20px] rounded-full" />
526
- )}
527
- <span className="text-[14px]">{opt.name}</span>
528
- </button>
529
- );
530
- })
531
- )}
532
- </div>
533
- </PopoverContent>
534
- </Popover>
535
- </FieldWrapper>
536
- );
537
- }
538
-
539
- // ── TextMultiSelect (in/nin operators for text fields) ───────────────
540
-
541
- export function TextMultiSelect({ field, value, onChange, disabled, service, showLabel, showDescription }: FieldValueInputProps) {
542
- const [open, setOpen] = useState(false);
543
- const [keyword, setKeyword] = useState('');
544
- const [options, setOptions] = useState<ILinkDataOption[]>([]);
545
- const [loading, setLoading] = useState(false);
546
- const [labelMap, setLabelMap] = useState<Record<string, string>>({});
547
- const [isHovering, setIsHovering] = useState(false);
548
- const { portalContainer, setTriggerRef } = usePortalContainer('data-bs-field-value-picker-portal');
549
-
550
- const selectedValues: string[] = Array.isArray(value) ? value.map(String) : [];
551
- const selectedSet = new Set(selectedValues);
552
-
553
- useEffect(() => {
554
- if (open && service?.searchDistinctValues) {
555
- setLoading(true);
556
- service.searchDistinctValues(field, keyword).then((data) => {
557
- setOptions(data);
558
- const map: Record<string, string> = {};
559
- data.forEach((opt) => { map[opt.value] = opt.label; });
560
- setLabelMap(map);
561
- setLoading(false);
562
- });
563
- }
564
- }, [open, keyword, field, service]);
565
-
566
- const handleToggle = useCallback(
567
- (opt: ILinkDataOption) => {
568
- const next = selectedSet.has(opt.value)
569
- ? selectedValues.filter((v) => v !== opt.value)
570
- : [...selectedValues, opt.value];
571
- onChange?.(next);
572
- },
573
- [selectedValues, selectedSet, onChange],
574
- );
575
-
576
- const handleClearAll = useCallback(
577
- (e: React.MouseEvent) => {
578
- e.stopPropagation();
579
- onChange?.([]);
580
- },
581
- [onChange],
582
- );
583
-
584
- const handleRemove = useCallback(
585
- (val: string, e: React.MouseEvent) => {
586
- e.stopPropagation();
587
- onChange?.(selectedValues.filter((v) => v !== val));
588
- },
589
- [selectedValues, onChange],
590
- );
591
-
592
- const hasSelection = selectedValues.length > 0;
593
- const showClear = hasSelection && isHovering;
594
-
595
- return (
596
- <FieldWrapper field={field} showLabel={showLabel} showDescription={showDescription}>
597
- <Popover open={open} onOpenChange={setOpen}>
598
- <PopoverTrigger asChild>
599
- <button
600
- ref={setTriggerRef}
601
- type="button"
602
- disabled={disabled}
603
- onMouseEnter={() => setIsHovering(true)}
604
- onMouseLeave={() => setIsHovering(false)}
605
- className={cn(
606
- 'flex items-center justify-between w-full h-[34px] gap-2.5',
607
- 'bg-white border rounded-[4px]',
608
- 'py-[7px]',
609
- hasSelection ? 'pl-[4px] pr-[10px]' : 'px-[10px]',
610
- 'text-left cursor-pointer',
611
- 'hover:border-[#0265FF]',
612
- disabled && 'opacity-50 cursor-not-allowed hover:border-[#E0E0E0]',
613
- open ? 'border-[#0265FF]' : 'border-[#E0E0E0]',
614
- transCls,
615
- )}
616
- >
617
- {hasSelection ? (
618
- <>
619
- <div className="flex items-center gap-[4px] min-w-0 flex-1 overflow-x-auto scrollbar-hide">
620
- {selectedValues.map((val) => (
621
- <span
622
- key={val}
623
- className="flex items-center gap-[4px] bg-[#EEEEEE] rounded-[2px] h-[26px] px-[6px] shrink-0"
624
- >
625
- <span className="text-[14px] text-[#030303] leading-[normal]">
626
- {labelMap[val] || val}
627
- </span>
628
- <span
629
- className="cursor-pointer flex items-center shrink-0"
630
- onClick={(e) => handleRemove(val, e)}
631
- >
632
- <CloseIcon />
633
- </span>
634
- </span>
635
- ))}
636
- </div>
637
- {showClear ? (
638
- <span className="cursor-pointer flex items-center shrink-0" onClick={handleClearAll}>
639
- <ClearAllIcon />
640
- </span>
641
- ) : (
642
- <DropdownChevron open={open} />
643
- )}
644
- </>
645
- ) : (
646
- <>
647
- <span className="text-[14px] text-[#999999] leading-[normal] truncate">
648
- {field.description ?? `请输入${field.label}`}
649
- </span>
650
- <DropdownChevron open={open} />
651
- </>
652
- )}
653
- </button>
654
- </PopoverTrigger>
655
- <PopoverContent
656
- align="start"
657
- sideOffset={4}
658
- portalContainer={portalContainer}
659
- className="!border-0 rounded-[10px] shadow-[0px_0px_5px_rgba(0,0,0,0.04)] p-[8px]"
660
- style={{ width: 'var(--radix-popover-trigger-width)' }}
661
- >
662
- <div className="flex items-center gap-[8px] px-[6px] mb-[4px] h-[34px] bg-[#F6F7FA] rounded-[4px]">
663
- <SearchIcon />
664
- <input
665
- type="text"
666
- value={keyword}
667
- onChange={(e) => setKeyword(e.target.value)}
668
- placeholder="输入关键字搜索"
669
- className="flex-1 bg-transparent text-[14px] text-[#030303] placeholder:text-[#999999] outline-none border-none"
670
- autoFocus
671
- />
672
- </div>
673
- <div className="flex flex-col gap-0 max-h-[200px] overflow-y-auto">
674
- {loading ? (
675
- <div className="px-[10px] py-[8px] text-[14px] text-[#999999]">搜索中...</div>
676
- ) : options.length === 0 ? (
677
- <div className="px-[10px] py-[8px] text-[14px] text-[#999999]">暂无数据</div>
678
- ) : (
679
- options.map((opt) => {
680
- const checked = selectedSet.has(opt.value);
681
- return (
682
- <button
683
- key={opt.value}
684
- type="button"
685
- onClick={() => handleToggle(opt)}
686
- className={cn(
687
- 'flex items-center gap-[10px] h-[34px] px-[10px] rounded-[4px] text-left',
688
- checked
689
- ? 'bg-[#F0F6FF] text-[#0265FF]'
690
- : 'text-[#030303] hover:bg-[#EFF0F6]',
691
- transCls,
692
- )}
693
- >
694
- <div
695
- className={cn(
696
- 'w-[14px] h-[14px] rounded-[2px] flex items-center justify-center shrink-0 border',
697
- checked ? 'bg-[#0265FF] border-[#0265FF]' : 'bg-white border-[#CCCCCC]',
698
- )}
699
- >
700
- {checked && <CheckIcon />}
701
- </div>
702
- <span className="text-[14px]">{opt.label}</span>
703
- </button>
704
- );
705
- })
706
- )}
707
- </div>
708
- </PopoverContent>
709
- </Popover>
710
- </FieldWrapper>
711
- );
712
- }
713
-
714
- // ── CascadingAddressInput (province → city → district) ──────────────
715
-
716
- const provinceList = Object.keys(chinaAddressData);
717
-
718
- interface AddressValue {
719
- province?: string;
720
- city?: string;
721
- district?: string;
722
- }
723
-
724
- export function CascadingAddressInput({ field, value, onChange, disabled, showLabel, showDescription }: FieldValueInputProps) {
725
- const [open, setOpen] = useState(false);
726
- const [isHovering, setIsHovering] = useState(false);
727
- const provinceRef = useRef<HTMLDivElement>(null);
728
- const cityRef = useRef<HTMLDivElement>(null);
729
- const districtRef = useRef<HTMLDivElement>(null);
730
- const { portalContainer, setTriggerRef } = usePortalContainer('data-bs-field-value-picker-portal');
731
-
732
- const addrValue: AddressValue =
733
- value && typeof value === 'object' && !Array.isArray(value)
734
- ? (value as AddressValue)
735
- : {};
736
-
737
- const selectedProvince = addrValue.province || '';
738
- const selectedCity = addrValue.city || '';
739
- const selectedDistrict = addrValue.district || '';
740
-
741
- const cityList = selectedProvince && chinaAddressData[selectedProvince]
742
- ? Object.keys(chinaAddressData[selectedProvince])
743
- : [];
744
- const districtList = selectedProvince && selectedCity && chinaAddressData[selectedProvince]?.[selectedCity]
745
- ? chinaAddressData[selectedProvince][selectedCity]
746
- : [];
747
-
748
- const displayText =
749
- selectedProvince
750
- ? `${selectedProvince}${selectedCity ? ` ${selectedCity}` : ''}${selectedDistrict ? ` ${selectedDistrict}` : ''}`
751
- : '';
752
-
753
- const handleSelectProvince = useCallback(
754
- (province: string) => {
755
- if (province === selectedProvince) return;
756
- onChange?.({ province, city: undefined, district: undefined });
757
- setTimeout(() => {
758
- const el = cityRef.current;
759
- if (el) el.scrollTop = 0;
760
- }, 0);
761
- },
762
- [onChange, selectedProvince],
763
- );
764
-
765
- const handleSelectCity = useCallback(
766
- (city: string) => {
767
- if (city === selectedCity) return;
768
- onChange?.({ province: selectedProvince, city, district: undefined });
769
- setTimeout(() => {
770
- const el = districtRef.current;
771
- if (el) el.scrollTop = 0;
772
- }, 0);
773
- },
774
- [onChange, selectedProvince, selectedCity],
775
- );
776
-
777
- const handleSelectDistrict = useCallback(
778
- (district: string) => {
779
- onChange?.({ province: selectedProvince, city: selectedCity, district });
780
- setOpen(false);
781
- },
782
- [onChange, selectedProvince, selectedCity],
783
- );
784
-
785
- const handleClear = useCallback(
786
- (e: React.MouseEvent) => {
787
- e.stopPropagation();
788
- onChange?.({});
789
- },
790
- [onChange],
791
- );
792
-
793
- const scrollToSelected = useCallback(() => {
794
- if (provinceRef.current) {
795
- const idx = provinceList.indexOf(selectedProvince);
796
- if (idx >= 0) provinceRef.current.scrollTop = idx * 32;
797
- }
798
- if (cityRef.current) {
799
- const idx = cityList.indexOf(selectedCity);
800
- if (idx >= 0) cityRef.current.scrollTop = idx * 32;
801
- }
802
- if (districtRef.current) {
803
- const idx = districtList.indexOf(selectedDistrict);
804
- if (idx >= 0) districtRef.current.scrollTop = idx * 32;
805
- }
806
- }, [selectedProvince, selectedCity, selectedDistrict, cityList, districtList]);
807
-
808
- useEffect(() => {
809
- if (open) scrollToSelected();
810
- }, [open, scrollToSelected]);
811
-
812
- const hasValue = !!displayText;
813
- const showClear = hasValue && isHovering;
814
-
815
- return (
816
- <FieldWrapper field={field} showLabel={showLabel} showDescription={showDescription}>
817
- <Popover open={open} onOpenChange={setOpen}>
818
- <PopoverTrigger asChild>
819
- <button
820
- ref={setTriggerRef}
821
- type="button"
822
- disabled={disabled}
823
- onMouseEnter={() => setIsHovering(true)}
824
- onMouseLeave={() => setIsHovering(false)}
825
- className={cn(
826
- 'flex items-center justify-between gap-[10px]',
827
- inputBaseCls,
828
- 'text-left cursor-pointer',
829
- open ? 'border-[#0265FF]' : 'border-[#E0E0E0] hover:border-[#0265FF]',
830
- )}
831
- >
832
- <span className={cn('truncate', !displayText && 'text-[#999999]')}>
833
- {displayText || (field.description ?? `请选择${field.label}`)}
834
- </span>
835
- {showClear ? (
836
- <span className="cursor-pointer flex items-center shrink-0" onClick={handleClear}>
837
- <ClearAllIcon />
838
- </span>
839
- ) : (
840
- <DropdownChevron open={open} />
841
- )}
842
- </button>
843
- </PopoverTrigger>
844
- <PopoverContent
845
- align="start"
846
- sideOffset={4}
847
- portalContainer={portalContainer}
848
- className="!border-0 rounded-[10px] shadow-[0px_0px_5px_rgba(0,0,0,0.04)] p-0"
849
- style={{ width: '520px' }}
850
- >
851
- <div className="flex h-[280px]">
852
- {/* Province Column */}
853
- <div className="flex-1 border-r border-[#F0F0F0] flex flex-col min-w-0">
854
- <div className="h-[34px] px-[12px] flex items-center text-[12px] text-[#999999] bg-[#FAFAFA] shrink-0 border-b border-[#F0F0F0]">
855
- 省份
856
- </div>
857
- <div ref={provinceRef} className="flex-1 overflow-y-auto">
858
- {provinceList.map((province) => (
859
- <button
860
- key={province}
861
- type="button"
862
- onClick={() => handleSelectProvince(province)}
863
- className={cn(
864
- 'w-full h-[32px] px-[12px] flex items-center justify-between text-left text-[14px]',
865
- transCls,
866
- province === selectedProvince
867
- ? 'bg-[#F0F6FF] text-[#0265FF]'
868
- : 'text-[#030303] hover:bg-[#EFF0F6]',
869
- )}
870
- >
871
- <span className="truncate">{province}</span>
872
- {chinaAddressData[province] && (
873
- <svg width="12" height="12" viewBox="0 0 12 12" fill="none" className="shrink-0 ml-[4px]">
874
- <path d="M4.5 2.5L8 6L4.5 9.5" stroke={province === selectedProvince ? '#0265FF' : '#999999'} strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
875
- </svg>
876
- )}
877
- </button>
878
- ))}
879
- </div>
880
- </div>
881
- {/* City Column */}
882
- <div className="flex-1 border-r border-[#F0F0F0] flex flex-col min-w-0">
883
- <div className="h-[34px] px-[12px] flex items-center text-[12px] text-[#999999] bg-[#FAFAFA] shrink-0 border-b border-[#F0F0F0]">
884
- 城市
885
- </div>
886
- <div ref={cityRef} className="flex-1 overflow-y-auto">
887
- {cityList.length === 0 ? (
888
- <div className="px-[12px] py-[16px] text-[14px] text-[#999999] text-center">请选择省份</div>
889
- ) : (
890
- cityList.map((city) => (
891
- <button
892
- key={city}
893
- type="button"
894
- onClick={() => handleSelectCity(city)}
895
- className={cn(
896
- 'w-full h-[32px] px-[12px] flex items-center justify-between text-left text-[14px]',
897
- transCls,
898
- city === selectedCity
899
- ? 'bg-[#F0F6FF] text-[#0265FF]'
900
- : 'text-[#030303] hover:bg-[#EFF0F6]',
901
- )}
902
- >
903
- <span className="truncate">{city}</span>
904
- {selectedProvince && chinaAddressData[selectedProvince]?.[city] && (
905
- <svg width="12" height="12" viewBox="0 0 12 12" fill="none" className="shrink-0 ml-[4px]">
906
- <path d="M4.5 2.5L8 6L4.5 9.5" stroke={city === selectedCity ? '#0265FF' : '#999999'} strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
907
- </svg>
908
- )}
909
- </button>
910
- ))
911
- )}
912
- </div>
913
- </div>
914
- {/* District Column */}
915
- <div className="flex-1 flex flex-col min-w-0">
916
- <div className="h-[34px] px-[12px] flex items-center text-[12px] text-[#999999] bg-[#FAFAFA] shrink-0 border-b border-[#F0F0F0]">
917
- 区/县
918
- </div>
919
- <div ref={districtRef} className="flex-1 overflow-y-auto">
920
- {districtList.length === 0 ? (
921
- <div className="px-[12px] py-[16px] text-[14px] text-[#999999] text-center">请选择城市</div>
922
- ) : (
923
- districtList.map((district) => (
924
- <button
925
- key={district}
926
- type="button"
927
- onClick={() => handleSelectDistrict(district)}
928
- className={cn(
929
- 'w-full h-[32px] px-[12px] flex items-center text-left text-[14px]',
930
- transCls,
931
- district === selectedDistrict
932
- ? 'bg-[#F0F6FF] text-[#0265FF]'
933
- : 'text-[#030303] hover:bg-[#EFF0F6]',
934
- )}
935
- >
936
- <span className="truncate">{district}</span>
937
- </button>
938
- ))
939
- )}
940
- </div>
941
- </div>
942
- </div>
943
- </PopoverContent>
944
- </Popover>
945
- </FieldWrapper>
946
- );
947
- }
948
-
949
- // ── AddressInput ────────────────────────────────────────────────────
950
-
951
- export function AddressInput({ field, value, onChange, disabled, service, showLabel, showDescription }: FieldValueInputProps) {
952
- const [open, setOpen] = useState(false);
953
- const [keyword, setKeyword] = useState('');
954
- const [options, setOptions] = useState<IAddressOption[]>([]);
955
- const [loading, setLoading] = useState(false);
956
- const { portalContainer, setTriggerRef } = usePortalContainer('data-bs-field-value-picker-portal');
957
-
958
- const selectedLabel =
959
- value && typeof value === 'object' && 'province' in (value as Record<string, unknown>)
960
- ? `${(value as IAddressOption).province}${(value as IAddressOption).city}${(value as IAddressOption).district}`
961
- : typeof value === 'string'
962
- ? value
963
- : '';
964
-
965
- useEffect(() => {
966
- if (open && service?.searchAddress) {
967
- setLoading(true);
968
- service.searchAddress(keyword).then((data) => {
969
- setOptions(data);
970
- setLoading(false);
971
- });
972
- }
973
- }, [open, keyword, service]);
974
-
975
- return (
976
- <FieldWrapper field={field} showLabel={showLabel} showDescription={showDescription}>
977
- <Popover open={open} onOpenChange={setOpen}>
978
- <PopoverTrigger asChild>
979
- <button
980
- ref={setTriggerRef}
981
- type="button"
982
- disabled={disabled}
983
- className={cn(
984
- 'flex items-center justify-between gap-[10px]',
985
- inputBaseCls,
986
- 'text-left cursor-pointer',
987
- open ? 'border-[#0265FF]' : 'border-[#E0E0E0] hover:border-[#0265FF]',
988
- )}
989
- >
990
- <span className={cn('truncate', !selectedLabel && 'text-[#999999]')}>
991
- {selectedLabel || (field.description ?? `请选择${field.label}`)}
992
- </span>
993
- <DropdownChevron open={open} />
994
- </button>
995
- </PopoverTrigger>
996
- <PopoverContent
997
- align="start"
998
- sideOffset={4}
999
- portalContainer={portalContainer}
1000
- className="!border-0 rounded-[10px] shadow-[0px_0px_5px_rgba(0,0,0,0.04)] p-[8px]"
1001
- style={{ width: 'var(--radix-popover-trigger-width)' }}
1002
- >
1003
- <div className="flex items-center gap-[8px] px-[6px] mb-[4px] h-[34px] bg-[#F6F7FA] rounded-[4px]">
1004
- <SearchIcon />
1005
- <input
1006
- type="text"
1007
- value={keyword}
1008
- onChange={(e) => setKeyword(e.target.value)}
1009
- placeholder={`搜索${field.label}`}
1010
- className="flex-1 bg-transparent text-[14px] text-[#030303] placeholder:text-[#999999] outline-none border-none"
1011
- autoFocus
1012
- />
1013
- </div>
1014
- <div className="flex flex-col gap-0 max-h-[200px] overflow-y-auto">
1015
- {loading ? (
1016
- <div className="px-[10px] py-[8px] text-[14px] text-[#999999]">搜索中...</div>
1017
- ) : options.length === 0 ? (
1018
- <div className="px-[10px] py-[8px] text-[14px] text-[#999999]">暂无数据</div>
1019
- ) : (
1020
- options.map((opt, i) => (
1021
- <button
1022
- key={i}
1023
- type="button"
1024
- onClick={() => {
1025
- onChange?.(opt);
1026
- setOpen(false);
1027
- setKeyword('');
1028
- }}
1029
- className={cn(
1030
- 'h-[34px] px-[10px] rounded-[4px] text-left text-[14px] leading-[normal]',
1031
- 'text-[#030303] hover:bg-[#EFF0F6]',
1032
- transCls,
1033
- )}
1034
- >
1035
- {opt.province}{opt.city}{opt.district}
1036
- </button>
1037
- ))
1038
- )}
1039
- </div>
1040
- </PopoverContent>
1041
- </Popover>
1042
- </FieldWrapper>
1043
- );
1044
- }
1045
-
1046
- // ── LocationInput ───────────────────────────────────────────────────
1047
-
1048
- export function LocationInput({ field, value, onChange, disabled, service, showLabel, showDescription }: FieldValueInputProps) {
1049
- const [open, setOpen] = useState(false);
1050
- const [keyword, setKeyword] = useState('');
1051
- const [options, setOptions] = useState<ILocationOption[]>([]);
1052
- const [loading, setLoading] = useState(false);
1053
- const { portalContainer, setTriggerRef } = usePortalContainer('data-bs-field-value-picker-portal');
1054
-
1055
- const selectedLabel =
1056
- value && typeof value === 'object' && 'detail' in (value as Record<string, unknown>)
1057
- ? (value as ILocationOption).detail
1058
- : typeof value === 'string'
1059
- ? value
1060
- : '';
1061
-
1062
- useEffect(() => {
1063
- if (open && service?.searchLocation) {
1064
- setLoading(true);
1065
- service.searchLocation(keyword).then((data) => {
1066
- setOptions(data);
1067
- setLoading(false);
1068
- });
1069
- }
1070
- }, [open, keyword, service]);
1071
-
1072
- return (
1073
- <FieldWrapper field={field} showLabel={showLabel} showDescription={showDescription}>
1074
- <Popover open={open} onOpenChange={setOpen}>
1075
- <PopoverTrigger asChild>
1076
- <button
1077
- ref={setTriggerRef}
1078
- type="button"
1079
- disabled={disabled}
1080
- className={cn(
1081
- 'flex items-center justify-between gap-[10px]',
1082
- inputBaseCls,
1083
- 'text-left cursor-pointer',
1084
- open ? 'border-[#0265FF]' : 'border-[#E0E0E0] hover:border-[#0265FF]',
1085
- )}
1086
- >
1087
- <span className={cn('truncate', !selectedLabel && 'text-[#999999]')}>
1088
- {selectedLabel || (field.description ?? `请选择${field.label}`)}
1089
- </span>
1090
- <DropdownChevron open={open} />
1091
- </button>
1092
- </PopoverTrigger>
1093
- <PopoverContent
1094
- align="start"
1095
- sideOffset={4}
1096
- portalContainer={portalContainer}
1097
- className="!border-0 rounded-[10px] shadow-[0px_0px_5px_rgba(0,0,0,0.04)] p-[8px]"
1098
- style={{ width: 'var(--radix-popover-trigger-width)' }}
1099
- >
1100
- <div className="flex items-center gap-[8px] px-[6px] mb-[4px] h-[34px] bg-[#F6F7FA] rounded-[4px]">
1101
- <SearchIcon />
1102
- <input
1103
- type="text"
1104
- value={keyword}
1105
- onChange={(e) => setKeyword(e.target.value)}
1106
- placeholder={`搜索${field.label}`}
1107
- className="flex-1 bg-transparent text-[14px] text-[#030303] placeholder:text-[#999999] outline-none border-none"
1108
- autoFocus
1109
- />
1110
- </div>
1111
- <div className="flex flex-col gap-0 max-h-[200px] overflow-y-auto">
1112
- {loading ? (
1113
- <div className="px-[10px] py-[8px] text-[14px] text-[#999999]">搜索中...</div>
1114
- ) : options.length === 0 ? (
1115
- <div className="px-[10px] py-[8px] text-[14px] text-[#999999]">暂无数据</div>
1116
- ) : (
1117
- options.map((opt, i) => (
1118
- <button
1119
- key={i}
1120
- type="button"
1121
- onClick={() => {
1122
- onChange?.(opt);
1123
- setOpen(false);
1124
- setKeyword('');
1125
- }}
1126
- className={cn(
1127
- 'h-[34px] px-[10px] rounded-[4px] text-left text-[14px] leading-[normal]',
1128
- 'text-[#030303] hover:bg-[#EFF0F6]',
1129
- transCls,
1130
- )}
1131
- >
1132
- {opt.province}{opt.city}{opt.district} {opt.detail}
1133
- </button>
1134
- ))
1135
- )}
1136
- </div>
1137
- </PopoverContent>
1138
- </Popover>
1139
- </FieldWrapper>
1140
- );
1141
- }
1
+ import { useState, useCallback, useEffect, useRef } from 'react';
2
+ import type {
3
+ IFieldValueService,
4
+ IUserOption,
5
+ IDeptOption,
6
+ ILinkDataOption,
7
+ IAddressOption,
8
+ ILocationOption,
9
+ } from '@baishuyun/types';
10
+ import { Popover, PopoverTrigger, PopoverContent } from '../../ui/popover';
11
+ import { FieldWrapper, type FieldValueInputProps } from './index';
12
+ import { DropdownChevron, ClearIcon, CloseIcon, CheckIcon } from './icons';
13
+ import { SearchIcon } from '../../bs-icons';
14
+ import { ClearAllIcon } from '../../icons/clear-all-icon';
15
+ import { cn } from '../../../lib/utils';
16
+ import { transCls } from '../../../const/ui';
17
+ import { chinaAddressData } from './china-address-data';
18
+
19
+ const inputBaseCls =
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
+
22
+ interface WidgetItem {
23
+ label: string;
24
+ value: string | number;
25
+ }
26
+
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[] ?? [];
34
+ }
35
+
36
+ function usePortalContainer(attr: string) {
37
+ const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
38
+ const setTriggerRef = useCallback(
39
+ (node: HTMLButtonElement | null) => {
40
+ if (!node) { setPortalContainer(null); return; }
41
+ const rootNode = node.getRootNode();
42
+ if (rootNode instanceof ShadowRoot) {
43
+ const existing = rootNode.querySelector<HTMLElement>(`[${attr}]`);
44
+ if (existing) { setPortalContainer(existing); return; }
45
+ const container = document.createElement('div');
46
+ container.setAttribute(attr, 'true');
47
+ rootNode.appendChild(container);
48
+ setPortalContainer(container);
49
+ } else {
50
+ setPortalContainer(null);
51
+ }
52
+ },
53
+ [attr],
54
+ );
55
+ return { portalContainer, setTriggerRef };
56
+ }
57
+
58
+ // ── ComboInput (combo / linkdata / linkquery) ───────────────────────
59
+
60
+ export function ComboInput({ field, value, onChange, disabled, service, showLabel, showDescription }: FieldValueInputProps) {
61
+ const [open, setOpen] = useState(false);
62
+ const [asyncItems, setAsyncItems] = useState<ILinkDataOption[]>([]);
63
+ const [loading, setLoading] = useState(false);
64
+ const { portalContainer, setTriggerRef } = usePortalContainer('data-bs-field-value-input-portal');
65
+
66
+ const staticItems = getItems(field);
67
+ const hasAsync = !!(field.widget?.type === 'linkdata' || field.widget?.type === 'linkquery');
68
+
69
+ const selectedLabel =
70
+ staticItems.find((it) => it.value === value)?.label ??
71
+ asyncItems.find((it) => it.value === value)?.label ??
72
+ '';
73
+
74
+ useEffect(() => {
75
+ if (hasAsync && open && service?.fetchLinkData) {
76
+ setLoading(true);
77
+ service.fetchLinkData(field.widget?.widgetName ?? '').then((data) => {
78
+ setAsyncItems(data);
79
+ setLoading(false);
80
+ });
81
+ }
82
+ }, [hasAsync, open, field.widget?.widgetName, service]);
83
+
84
+ const displayItems = hasAsync ? asyncItems : staticItems;
85
+
86
+ const handleSelect = useCallback(
87
+ (v: string | number) => {
88
+ onChange?.(v);
89
+ setOpen(false);
90
+ },
91
+ [onChange],
92
+ );
93
+
94
+ return (
95
+ <FieldWrapper field={field} showLabel={showLabel} showDescription={showDescription}>
96
+ <Popover open={open} onOpenChange={setOpen}>
97
+ <PopoverTrigger asChild>
98
+ <button
99
+ ref={setTriggerRef}
100
+ type="button"
101
+ disabled={disabled}
102
+ className={cn(
103
+ 'flex items-center justify-between gap-[10px]',
104
+ inputBaseCls,
105
+ 'text-left cursor-pointer',
106
+ open ? 'border-[#0265FF]' : 'border-[#E0E0E0] hover:border-[#0265FF]',
107
+ )}
108
+ >
109
+ <span className={cn('truncate', !selectedLabel && 'text-[#999999]')}>
110
+ {selectedLabel || (field.description ?? `请选择${field.label}`)}
111
+ </span>
112
+ <DropdownChevron open={open} />
113
+ </button>
114
+ </PopoverTrigger>
115
+ <PopoverContent
116
+ align="start"
117
+ sideOffset={4}
118
+ portalContainer={portalContainer}
119
+ className="!border-0 rounded-[10px] shadow-[0px_0px_5px_rgba(0,0,0,0.04)] p-[4px]"
120
+ style={{ width: 'var(--radix-popover-trigger-width)' }}
121
+ >
122
+ {loading ? (
123
+ <div className="px-[10px] py-[8px] text-[14px] text-[#999999]">加载中...</div>
124
+ ) : displayItems.length === 0 ? (
125
+ <div className="px-[10px] py-[8px] text-[14px] text-[#999999]">暂无数据</div>
126
+ ) : (
127
+ <div className="flex flex-col gap-0 max-h-[240px] overflow-y-auto">
128
+ {displayItems.map((item) => (
129
+ <button
130
+ key={item.value}
131
+ type="button"
132
+ onClick={() => handleSelect(item.value)}
133
+ className={cn(
134
+ 'h-[34px] px-[10px] rounded-[4px] text-left text-[14px] leading-[normal]',
135
+ value === item.value
136
+ ? 'bg-[#F0F6FF] text-[#0265FF]'
137
+ : 'text-[#030303] hover:bg-[#EFF0F6]',
138
+ transCls,
139
+ )}
140
+ >
141
+ {item.label}
142
+ </button>
143
+ ))}
144
+ </div>
145
+ )}
146
+ </PopoverContent>
147
+ </Popover>
148
+ </FieldWrapper>
149
+ );
150
+ }
151
+
152
+ // ── CheckboxGroupInput ──────────────────────────────────────────────
153
+
154
+ export function CheckboxGroupInput({ field, value, onChange, disabled, showLabel, showDescription }: FieldValueInputProps) {
155
+ const items = getItems(field);
156
+ const selectedValues: string[] = Array.isArray(value) ? value.map(String) : [];
157
+
158
+ const toggle = useCallback(
159
+ (itemValue: string) => {
160
+ const next = selectedValues.includes(itemValue)
161
+ ? selectedValues.filter((v) => v !== itemValue)
162
+ : [...selectedValues, itemValue];
163
+ onChange?.(next);
164
+ },
165
+ [selectedValues, onChange],
166
+ );
167
+
168
+ return (
169
+ <FieldWrapper field={field} showLabel={showLabel} showDescription={showDescription}>
170
+ <div className="flex flex-col gap-[8px]">
171
+ {items.map((item) => {
172
+ const checked = selectedValues.includes(String(item.value));
173
+ return (
174
+ <label
175
+ key={item.value}
176
+ className={cn(
177
+ 'flex items-center gap-[8px] cursor-pointer text-[14px] text-[#030303]',
178
+ disabled && 'cursor-not-allowed opacity-50',
179
+ )}
180
+ >
181
+ <div
182
+ className={cn(
183
+ 'w-[16px] h-[16px] rounded-[2px] flex items-center justify-center shrink-0 border',
184
+ transCls,
185
+ checked ? 'bg-[#0265FF] border-[#0265FF]' : 'bg-white border-[#CCCCCC]',
186
+ !disabled && 'hover:border-[#0265FF]',
187
+ )}
188
+ >
189
+ {checked && <CheckIcon />}
190
+ </div>
191
+ <input
192
+ type="checkbox"
193
+ checked={checked}
194
+ onChange={() => toggle(String(item.value))}
195
+ disabled={disabled}
196
+ className="sr-only"
197
+ />
198
+ {item.label}
199
+ </label>
200
+ );
201
+ })}
202
+ </div>
203
+ </FieldWrapper>
204
+ );
205
+ }
206
+
207
+ // ── RadioGroupInput ─────────────────────────────────────────────────
208
+
209
+ export function RadioGroupInput({ field, value, onChange, disabled, showLabel, showDescription }: FieldValueInputProps) {
210
+ const items = getItems(field);
211
+ return (
212
+ <FieldWrapper field={field} showLabel={showLabel} showDescription={showDescription}>
213
+ <div className="flex flex-col gap-[8px]">
214
+ {items.map((item) => {
215
+ const checked = String(value) === String(item.value);
216
+ return (
217
+ <label
218
+ key={item.value}
219
+ className={cn(
220
+ 'flex items-center gap-[8px] cursor-pointer text-[14px] text-[#030303]',
221
+ disabled && 'cursor-not-allowed opacity-50',
222
+ )}
223
+ >
224
+ <div
225
+ className={cn(
226
+ 'w-[16px] h-[16px] rounded-full flex items-center justify-center shrink-0 border',
227
+ transCls,
228
+ checked ? 'border-[#0265FF]' : 'border-[#CCCCCC]',
229
+ !disabled && 'hover:border-[#0265FF]',
230
+ )}
231
+ >
232
+ {checked && <div className="w-[8px] h-[8px] rounded-full bg-[#0265FF]" />}
233
+ </div>
234
+ <input
235
+ type="radio"
236
+ checked={checked}
237
+ onChange={() => onChange?.(item.value)}
238
+ disabled={disabled}
239
+ className="sr-only"
240
+ />
241
+ {item.label}
242
+ </label>
243
+ );
244
+ })}
245
+ </div>
246
+ </FieldWrapper>
247
+ );
248
+ }
249
+
250
+ // ── AsyncPicker (user / dept / usergroup / deptgroup) ──────────────
251
+
252
+ type PickerKind = 'user' | 'dept' | 'usergroup' | 'deptgroup';
253
+
254
+ interface AsyncPickerProps extends FieldValueInputProps {
255
+ kind: PickerKind;
256
+ multiple?: boolean;
257
+ }
258
+
259
+ function getSearchFn(kind: PickerKind, service?: IFieldValueService) {
260
+ switch (kind) {
261
+ case 'user': return service?.searchUsers;
262
+ case 'dept': return service?.searchDepts;
263
+ case 'usergroup': return service?.searchUserGroups;
264
+ case 'deptgroup': return service?.searchDeptGroups;
265
+ }
266
+ }
267
+
268
+ export function AsyncPicker({ field, value, onChange, disabled, service, kind, multiple = false, showLabel, showDescription }: AsyncPickerProps) {
269
+ const [open, setOpen] = useState(false);
270
+ const [keyword, setKeyword] = useState('');
271
+ const [options, setOptions] = useState<Array<IUserOption | IDeptOption>>([]);
272
+ const [loading, setLoading] = useState(false);
273
+ const [selectedLabel, setSelectedLabel] = useState('');
274
+ const [nameMap, setNameMap] = useState<Record<string, string>>({});
275
+ const [isHovering, setIsHovering] = useState(false);
276
+ const { portalContainer, setTriggerRef } = usePortalContainer('data-bs-field-value-picker-portal');
277
+
278
+ const searchFn = getSearchFn(kind, service);
279
+
280
+ const selectedIds: string[] = multiple
281
+ ? (Array.isArray(value) ? value.map(String) : [])
282
+ : [];
283
+ const selectedIdSet = new Set(selectedIds);
284
+
285
+ // Resolve labels for existing values
286
+ useEffect(() => {
287
+ if (multiple) {
288
+ if (!Array.isArray(value) || value.length === 0) {
289
+ setNameMap({});
290
+ return;
291
+ }
292
+ if (service?.searchUsers && kind === 'user') {
293
+ service.searchUsers('', { field: fieldName }).then((users) => {
294
+ const map: Record<string, string> = {};
295
+ users.forEach((u) => {
296
+ if (value.includes(u.id)) map[u.id] = u.name;
297
+ });
298
+ setNameMap(map);
299
+ });
300
+ }
301
+ } else {
302
+ if (!value || value === '') {
303
+ setSelectedLabel('');
304
+ return;
305
+ }
306
+ if (service?.searchUsers && kind === 'user') {
307
+ service.searchUsers('', { field: fieldName }).then((users) => {
308
+ const found = users.find((u) => u.id === value);
309
+ if (found) setSelectedLabel(found.name);
310
+ });
311
+ }
312
+ }
313
+ }, [value, service, kind, multiple]);
314
+
315
+ const fieldName = field.widget?.widgetName || "";
316
+ const doSearch = useCallback(
317
+ async (kw: string) => {
318
+ if (!searchFn) return;
319
+ setLoading(true);
320
+ try {
321
+ const results = await searchFn(kw, { field: fieldName });
322
+ setOptions(results);
323
+ } finally {
324
+ setLoading(false);
325
+ }
326
+ },
327
+ [searchFn],
328
+ );
329
+
330
+ useEffect(() => {
331
+ if (open) doSearch(keyword);
332
+ }, [open, keyword, doSearch]);
333
+
334
+ const handleSelect = useCallback(
335
+ (opt: IUserOption | IDeptOption) => {
336
+ if (multiple) {
337
+ const next = selectedIdSet.has(opt.id)
338
+ ? selectedIds.filter((id) => id !== opt.id)
339
+ : [...selectedIds, opt.id];
340
+ onChange?.(next);
341
+ setNameMap((prev) => {
342
+ if (selectedIdSet.has(opt.id)) {
343
+ const nextMap = { ...prev };
344
+ delete nextMap[opt.id];
345
+ return nextMap;
346
+ }
347
+ return { ...prev, [opt.id]: opt.name };
348
+ });
349
+ } else {
350
+ onChange?.(opt.id);
351
+ setSelectedLabel(opt.name);
352
+ setOpen(false);
353
+ setKeyword('');
354
+ }
355
+ },
356
+ [onChange, multiple, selectedIds, selectedIdSet],
357
+ );
358
+
359
+ const handleClear = useCallback(() => {
360
+ if (multiple) {
361
+ onChange?.([]);
362
+ setNameMap({});
363
+ } else {
364
+ onChange?.('');
365
+ setSelectedLabel('');
366
+ }
367
+ }, [onChange, multiple]);
368
+
369
+ const handleRemoveMultiple = useCallback(
370
+ (id: string, e: React.MouseEvent) => {
371
+ e.stopPropagation();
372
+ onChange?.(selectedIds.filter((v) => v !== id));
373
+ setNameMap((prev) => {
374
+ const next = { ...prev };
375
+ delete next[id];
376
+ return next;
377
+ });
378
+ },
379
+ [selectedIds, onChange],
380
+ );
381
+
382
+ const handleClearAllMultiple = useCallback(
383
+ (e: React.MouseEvent) => {
384
+ e.stopPropagation();
385
+ onChange?.([]);
386
+ setNameMap({});
387
+ },
388
+ [onChange],
389
+ );
390
+
391
+ const hasSelectionMulti = multiple && selectedIds.length > 0;
392
+ const showClearMulti = hasSelectionMulti && isHovering;
393
+
394
+ return (
395
+ <FieldWrapper field={field} showLabel={showLabel} showDescription={showDescription}>
396
+ <Popover open={open} onOpenChange={setOpen}>
397
+ <PopoverTrigger asChild>
398
+ <button
399
+ ref={setTriggerRef}
400
+ type="button"
401
+ disabled={disabled}
402
+ onMouseEnter={() => setIsHovering(true)}
403
+ onMouseLeave={() => setIsHovering(false)}
404
+ className={cn(
405
+ 'flex items-center justify-between w-full h-[34px] gap-2.5',
406
+ 'bg-white border rounded-[4px]',
407
+ 'py-[7px]',
408
+ (multiple ? hasSelectionMulti : !!selectedLabel) ? 'pl-[4px] pr-[10px]' : 'px-[10px]',
409
+ 'text-left cursor-pointer',
410
+ 'hover:border-[#0265FF]',
411
+ disabled && 'opacity-50 cursor-not-allowed hover:border-[#E0E0E0]',
412
+ open ? 'border-[#0265FF]' : 'border-[#E0E0E0]',
413
+ transCls,
414
+ )}
415
+ >
416
+ {multiple ? (
417
+ hasSelectionMulti ? (
418
+ <>
419
+ <div className="flex items-center gap-[4px] min-w-0 flex-1 overflow-x-auto scrollbar-hide">
420
+ {selectedIds.map((id) => (
421
+ <span
422
+ key={id}
423
+ className="flex items-center gap-[4px] bg-[#EEEEEE] rounded-[2px] h-[26px] px-[6px] shrink-0"
424
+ >
425
+ <span className="text-[14px] text-[#030303] leading-[normal]">
426
+ {nameMap[id] || id}
427
+ </span>
428
+ <span
429
+ className="cursor-pointer flex items-center shrink-0"
430
+ onClick={(e) => handleRemoveMultiple(id, e)}
431
+ >
432
+ <CloseIcon />
433
+ </span>
434
+ </span>
435
+ ))}
436
+ </div>
437
+ {showClearMulti ? (
438
+ <span className="cursor-pointer flex items-center shrink-0" onClick={handleClearAllMultiple}>
439
+ <ClearAllIcon />
440
+ </span>
441
+ ) : (
442
+ <DropdownChevron open={open} />
443
+ )}
444
+ </>
445
+ ) : (
446
+ <>
447
+ <span className="text-[14px] text-[#999999] leading-[normal] truncate">
448
+ {field.description ?? `请选择${field.label}`}
449
+ </span>
450
+ <DropdownChevron open={open} />
451
+ </>
452
+ )
453
+ ) : (
454
+ <>
455
+ <span className={cn('truncate', !selectedLabel && 'text-[#999999]')}>
456
+ {selectedLabel || (field.description ?? `请选择${field.label}`)}
457
+ </span>
458
+ <div className="flex items-center gap-[4px] shrink-0">
459
+ {!!selectedLabel && (
460
+ <span
461
+ role="button"
462
+ tabIndex={-1}
463
+ onClick={(e) => { e.stopPropagation(); handleClear(); }}
464
+ className="flex items-center justify-center w-[18px] h-[18px] hover:bg-[#EFF0F6] rounded-full"
465
+ >
466
+ <ClearIcon />
467
+ </span>
468
+ )}
469
+ <DropdownChevron open={open} />
470
+ </div>
471
+ </>
472
+ )}
473
+ </button>
474
+ </PopoverTrigger>
475
+ <PopoverContent
476
+ align="start"
477
+ sideOffset={4}
478
+ portalContainer={portalContainer}
479
+ className="!border-0 rounded-[10px] shadow-[0px_0px_5px_rgba(0,0,0,0.04)] p-[8px]"
480
+ style={{ width: 'var(--radix-popover-trigger-width)' }}
481
+ >
482
+ <div className="flex items-center gap-[8px] px-[6px] mb-[4px] h-[34px] bg-[#F6F7FA] rounded-[4px]">
483
+ <SearchIcon />
484
+ <input
485
+ type="text"
486
+ value={keyword}
487
+ onChange={(e) => setKeyword(e.target.value)}
488
+ placeholder={`搜索${field.label}`}
489
+ className="flex-1 bg-transparent text-[14px] text-[#030303] placeholder:text-[#999999] outline-none border-none"
490
+ autoFocus
491
+ />
492
+ </div>
493
+ <div className="flex flex-col gap-0 max-h-[200px] overflow-y-auto">
494
+ {loading ? (
495
+ <div className="px-[10px] py-[8px] text-[14px] text-[#999999]">搜索中...</div>
496
+ ) : options.length === 0 ? (
497
+ <div className="px-[10px] py-[8px] text-[14px] text-[#999999]">暂无数据</div>
498
+ ) : (
499
+ options.map((opt) => {
500
+ const isSelected = multiple ? selectedIdSet.has(opt.id) : value === opt.id;
501
+ return (
502
+ <button
503
+ key={opt.id}
504
+ type="button"
505
+ onClick={() => handleSelect(opt)}
506
+ className={cn(
507
+ 'flex items-center gap-[10px] h-[34px] px-[10px] rounded-[4px] text-left',
508
+ isSelected
509
+ ? 'bg-[#F0F6FF] text-[#0265FF]'
510
+ : 'text-[#030303] hover:bg-[#EFF0F6]',
511
+ transCls,
512
+ )}
513
+ >
514
+ {multiple && (
515
+ <div
516
+ className={cn(
517
+ 'w-[14px] h-[14px] rounded-[2px] flex items-center justify-center shrink-0 border',
518
+ isSelected ? 'bg-[#0265FF] border-[#0265FF]' : 'bg-white border-[#CCCCCC]',
519
+ )}
520
+ >
521
+ {isSelected && <CheckIcon />}
522
+ </div>
523
+ )}
524
+ {'avatar' in opt && opt.avatar && (
525
+ <img src={opt.avatar} alt="" className="w-[20px] h-[20px] rounded-full" />
526
+ )}
527
+ <span className="text-[14px]">{opt.name}</span>
528
+ </button>
529
+ );
530
+ })
531
+ )}
532
+ </div>
533
+ </PopoverContent>
534
+ </Popover>
535
+ </FieldWrapper>
536
+ );
537
+ }
538
+
539
+ // ── TextMultiSelect (in/nin operators for text fields) ───────────────
540
+
541
+ export function TextMultiSelect({ field, value, onChange, disabled, service, showLabel, showDescription }: FieldValueInputProps) {
542
+ const [open, setOpen] = useState(false);
543
+ const [keyword, setKeyword] = useState('');
544
+ const [options, setOptions] = useState<ILinkDataOption[]>([]);
545
+ const [loading, setLoading] = useState(false);
546
+ const [labelMap, setLabelMap] = useState<Record<string, string>>({});
547
+ const [isHovering, setIsHovering] = useState(false);
548
+ const { portalContainer, setTriggerRef } = usePortalContainer('data-bs-field-value-picker-portal');
549
+
550
+ const selectedValues: string[] = Array.isArray(value) ? value.map(String) : [];
551
+ const selectedSet = new Set(selectedValues);
552
+
553
+ useEffect(() => {
554
+ if (open && service?.searchDistinctValues) {
555
+ setLoading(true);
556
+ service.searchDistinctValues(field, keyword).then((data) => {
557
+ setOptions(data);
558
+ const map: Record<string, string> = {};
559
+ data.forEach((opt) => { map[opt.value] = opt.label; });
560
+ setLabelMap(map);
561
+ setLoading(false);
562
+ });
563
+ }
564
+ }, [open, keyword, field, service]);
565
+
566
+ const handleToggle = useCallback(
567
+ (opt: ILinkDataOption) => {
568
+ const next = selectedSet.has(opt.value)
569
+ ? selectedValues.filter((v) => v !== opt.value)
570
+ : [...selectedValues, opt.value];
571
+ onChange?.(next);
572
+ },
573
+ [selectedValues, selectedSet, onChange],
574
+ );
575
+
576
+ const handleClearAll = useCallback(
577
+ (e: React.MouseEvent) => {
578
+ e.stopPropagation();
579
+ onChange?.([]);
580
+ },
581
+ [onChange],
582
+ );
583
+
584
+ const handleRemove = useCallback(
585
+ (val: string, e: React.MouseEvent) => {
586
+ e.stopPropagation();
587
+ onChange?.(selectedValues.filter((v) => v !== val));
588
+ },
589
+ [selectedValues, onChange],
590
+ );
591
+
592
+ const hasSelection = selectedValues.length > 0;
593
+ const showClear = hasSelection && isHovering;
594
+
595
+ return (
596
+ <FieldWrapper field={field} showLabel={showLabel} showDescription={showDescription}>
597
+ <Popover open={open} onOpenChange={setOpen}>
598
+ <PopoverTrigger asChild>
599
+ <button
600
+ ref={setTriggerRef}
601
+ type="button"
602
+ disabled={disabled}
603
+ onMouseEnter={() => setIsHovering(true)}
604
+ onMouseLeave={() => setIsHovering(false)}
605
+ className={cn(
606
+ 'flex items-center justify-between w-full h-[34px] gap-2.5',
607
+ 'bg-white border rounded-[4px]',
608
+ 'py-[7px]',
609
+ hasSelection ? 'pl-[4px] pr-[10px]' : 'px-[10px]',
610
+ 'text-left cursor-pointer',
611
+ 'hover:border-[#0265FF]',
612
+ disabled && 'opacity-50 cursor-not-allowed hover:border-[#E0E0E0]',
613
+ open ? 'border-[#0265FF]' : 'border-[#E0E0E0]',
614
+ transCls,
615
+ )}
616
+ >
617
+ {hasSelection ? (
618
+ <>
619
+ <div className="flex items-center gap-[4px] min-w-0 flex-1 overflow-x-auto scrollbar-hide">
620
+ {selectedValues.map((val) => (
621
+ <span
622
+ key={val}
623
+ className="flex items-center gap-[4px] bg-[#EEEEEE] rounded-[2px] h-[26px] px-[6px] shrink-0"
624
+ >
625
+ <span className="text-[14px] text-[#030303] leading-[normal]">
626
+ {labelMap[val] || val}
627
+ </span>
628
+ <span
629
+ className="cursor-pointer flex items-center shrink-0"
630
+ onClick={(e) => handleRemove(val, e)}
631
+ >
632
+ <CloseIcon />
633
+ </span>
634
+ </span>
635
+ ))}
636
+ </div>
637
+ {showClear ? (
638
+ <span className="cursor-pointer flex items-center shrink-0" onClick={handleClearAll}>
639
+ <ClearAllIcon />
640
+ </span>
641
+ ) : (
642
+ <DropdownChevron open={open} />
643
+ )}
644
+ </>
645
+ ) : (
646
+ <>
647
+ <span className="text-[14px] text-[#999999] leading-[normal] truncate">
648
+ {field.description ?? `请输入${field.label}`}
649
+ </span>
650
+ <DropdownChevron open={open} />
651
+ </>
652
+ )}
653
+ </button>
654
+ </PopoverTrigger>
655
+ <PopoverContent
656
+ align="start"
657
+ sideOffset={4}
658
+ portalContainer={portalContainer}
659
+ className="!border-0 rounded-[10px] shadow-[0px_0px_5px_rgba(0,0,0,0.04)] p-[8px]"
660
+ style={{ width: 'var(--radix-popover-trigger-width)' }}
661
+ >
662
+ <div className="flex items-center gap-[8px] px-[6px] mb-[4px] h-[34px] bg-[#F6F7FA] rounded-[4px]">
663
+ <SearchIcon />
664
+ <input
665
+ type="text"
666
+ value={keyword}
667
+ onChange={(e) => setKeyword(e.target.value)}
668
+ placeholder="输入关键字搜索"
669
+ className="flex-1 bg-transparent text-[14px] text-[#030303] placeholder:text-[#999999] outline-none border-none"
670
+ autoFocus
671
+ />
672
+ </div>
673
+ <div className="flex flex-col gap-0 max-h-[200px] overflow-y-auto">
674
+ {loading ? (
675
+ <div className="px-[10px] py-[8px] text-[14px] text-[#999999]">搜索中...</div>
676
+ ) : options.length === 0 ? (
677
+ <div className="px-[10px] py-[8px] text-[14px] text-[#999999]">暂无数据</div>
678
+ ) : (
679
+ options.map((opt) => {
680
+ const checked = selectedSet.has(opt.value);
681
+ return (
682
+ <button
683
+ key={opt.value}
684
+ type="button"
685
+ onClick={() => handleToggle(opt)}
686
+ className={cn(
687
+ 'flex items-center gap-[10px] h-[34px] px-[10px] rounded-[4px] text-left',
688
+ checked
689
+ ? 'bg-[#F0F6FF] text-[#0265FF]'
690
+ : 'text-[#030303] hover:bg-[#EFF0F6]',
691
+ transCls,
692
+ )}
693
+ >
694
+ <div
695
+ className={cn(
696
+ 'w-[14px] h-[14px] rounded-[2px] flex items-center justify-center shrink-0 border',
697
+ checked ? 'bg-[#0265FF] border-[#0265FF]' : 'bg-white border-[#CCCCCC]',
698
+ )}
699
+ >
700
+ {checked && <CheckIcon />}
701
+ </div>
702
+ <span className="text-[14px]">{opt.label}</span>
703
+ </button>
704
+ );
705
+ })
706
+ )}
707
+ </div>
708
+ </PopoverContent>
709
+ </Popover>
710
+ </FieldWrapper>
711
+ );
712
+ }
713
+
714
+ // ── CascadingAddressInput (province → city → district) ──────────────
715
+
716
+ const provinceList = Object.keys(chinaAddressData);
717
+
718
+ interface AddressValue {
719
+ province?: string;
720
+ city?: string;
721
+ district?: string;
722
+ }
723
+
724
+ export function CascadingAddressInput({ field, value, onChange, disabled, showLabel, showDescription }: FieldValueInputProps) {
725
+ const [open, setOpen] = useState(false);
726
+ const [isHovering, setIsHovering] = useState(false);
727
+ const provinceRef = useRef<HTMLDivElement>(null);
728
+ const cityRef = useRef<HTMLDivElement>(null);
729
+ const districtRef = useRef<HTMLDivElement>(null);
730
+ const { portalContainer, setTriggerRef } = usePortalContainer('data-bs-field-value-picker-portal');
731
+
732
+ const addrValue: AddressValue =
733
+ value && typeof value === 'object' && !Array.isArray(value)
734
+ ? (value as AddressValue)
735
+ : {};
736
+
737
+ const selectedProvince = addrValue.province || '';
738
+ const selectedCity = addrValue.city || '';
739
+ const selectedDistrict = addrValue.district || '';
740
+
741
+ const cityList = selectedProvince && chinaAddressData[selectedProvince]
742
+ ? Object.keys(chinaAddressData[selectedProvince])
743
+ : [];
744
+ const districtList = selectedProvince && selectedCity && chinaAddressData[selectedProvince]?.[selectedCity]
745
+ ? chinaAddressData[selectedProvince][selectedCity]
746
+ : [];
747
+
748
+ const displayText =
749
+ selectedProvince
750
+ ? `${selectedProvince}${selectedCity ? ` ${selectedCity}` : ''}${selectedDistrict ? ` ${selectedDistrict}` : ''}`
751
+ : '';
752
+
753
+ const handleSelectProvince = useCallback(
754
+ (province: string) => {
755
+ if (province === selectedProvince) return;
756
+ onChange?.({ province, city: undefined, district: undefined });
757
+ setTimeout(() => {
758
+ const el = cityRef.current;
759
+ if (el) el.scrollTop = 0;
760
+ }, 0);
761
+ },
762
+ [onChange, selectedProvince],
763
+ );
764
+
765
+ const handleSelectCity = useCallback(
766
+ (city: string) => {
767
+ if (city === selectedCity) return;
768
+ onChange?.({ province: selectedProvince, city, district: undefined });
769
+ setTimeout(() => {
770
+ const el = districtRef.current;
771
+ if (el) el.scrollTop = 0;
772
+ }, 0);
773
+ },
774
+ [onChange, selectedProvince, selectedCity],
775
+ );
776
+
777
+ const handleSelectDistrict = useCallback(
778
+ (district: string) => {
779
+ onChange?.({ province: selectedProvince, city: selectedCity, district });
780
+ setOpen(false);
781
+ },
782
+ [onChange, selectedProvince, selectedCity],
783
+ );
784
+
785
+ const handleClear = useCallback(
786
+ (e: React.MouseEvent) => {
787
+ e.stopPropagation();
788
+ onChange?.({});
789
+ },
790
+ [onChange],
791
+ );
792
+
793
+ const scrollToSelected = useCallback(() => {
794
+ if (provinceRef.current) {
795
+ const idx = provinceList.indexOf(selectedProvince);
796
+ if (idx >= 0) provinceRef.current.scrollTop = idx * 32;
797
+ }
798
+ if (cityRef.current) {
799
+ const idx = cityList.indexOf(selectedCity);
800
+ if (idx >= 0) cityRef.current.scrollTop = idx * 32;
801
+ }
802
+ if (districtRef.current) {
803
+ const idx = districtList.indexOf(selectedDistrict);
804
+ if (idx >= 0) districtRef.current.scrollTop = idx * 32;
805
+ }
806
+ }, [selectedProvince, selectedCity, selectedDistrict, cityList, districtList]);
807
+
808
+ useEffect(() => {
809
+ if (open) scrollToSelected();
810
+ }, [open, scrollToSelected]);
811
+
812
+ const hasValue = !!displayText;
813
+ const showClear = hasValue && isHovering;
814
+
815
+ return (
816
+ <FieldWrapper field={field} showLabel={showLabel} showDescription={showDescription}>
817
+ <Popover open={open} onOpenChange={setOpen}>
818
+ <PopoverTrigger asChild>
819
+ <button
820
+ ref={setTriggerRef}
821
+ type="button"
822
+ disabled={disabled}
823
+ onMouseEnter={() => setIsHovering(true)}
824
+ onMouseLeave={() => setIsHovering(false)}
825
+ className={cn(
826
+ 'flex items-center justify-between gap-[10px]',
827
+ inputBaseCls,
828
+ 'text-left cursor-pointer',
829
+ open ? 'border-[#0265FF]' : 'border-[#E0E0E0] hover:border-[#0265FF]',
830
+ )}
831
+ >
832
+ <span className={cn('truncate', !displayText && 'text-[#999999]')}>
833
+ {displayText || (field.description ?? `请选择${field.label}`)}
834
+ </span>
835
+ {showClear ? (
836
+ <span className="cursor-pointer flex items-center shrink-0" onClick={handleClear}>
837
+ <ClearAllIcon />
838
+ </span>
839
+ ) : (
840
+ <DropdownChevron open={open} />
841
+ )}
842
+ </button>
843
+ </PopoverTrigger>
844
+ <PopoverContent
845
+ align="start"
846
+ sideOffset={4}
847
+ portalContainer={portalContainer}
848
+ className="!border-0 rounded-[10px] shadow-[0px_0px_5px_rgba(0,0,0,0.04)] p-0"
849
+ style={{ width: '520px' }}
850
+ >
851
+ <div className="flex h-[280px]">
852
+ {/* Province Column */}
853
+ <div className="flex-1 border-r border-[#F0F0F0] flex flex-col min-w-0">
854
+ <div className="h-[34px] px-[12px] flex items-center text-[12px] text-[#999999] bg-[#FAFAFA] shrink-0 border-b border-[#F0F0F0]">
855
+ 省份
856
+ </div>
857
+ <div ref={provinceRef} className="flex-1 overflow-y-auto">
858
+ {provinceList.map((province) => (
859
+ <button
860
+ key={province}
861
+ type="button"
862
+ onClick={() => handleSelectProvince(province)}
863
+ className={cn(
864
+ 'w-full h-[32px] px-[12px] flex items-center justify-between text-left text-[14px]',
865
+ transCls,
866
+ province === selectedProvince
867
+ ? 'bg-[#F0F6FF] text-[#0265FF]'
868
+ : 'text-[#030303] hover:bg-[#EFF0F6]',
869
+ )}
870
+ >
871
+ <span className="truncate">{province}</span>
872
+ {chinaAddressData[province] && (
873
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" className="shrink-0 ml-[4px]">
874
+ <path d="M4.5 2.5L8 6L4.5 9.5" stroke={province === selectedProvince ? '#0265FF' : '#999999'} strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
875
+ </svg>
876
+ )}
877
+ </button>
878
+ ))}
879
+ </div>
880
+ </div>
881
+ {/* City Column */}
882
+ <div className="flex-1 border-r border-[#F0F0F0] flex flex-col min-w-0">
883
+ <div className="h-[34px] px-[12px] flex items-center text-[12px] text-[#999999] bg-[#FAFAFA] shrink-0 border-b border-[#F0F0F0]">
884
+ 城市
885
+ </div>
886
+ <div ref={cityRef} className="flex-1 overflow-y-auto">
887
+ {cityList.length === 0 ? (
888
+ <div className="px-[12px] py-[16px] text-[14px] text-[#999999] text-center">请选择省份</div>
889
+ ) : (
890
+ cityList.map((city) => (
891
+ <button
892
+ key={city}
893
+ type="button"
894
+ onClick={() => handleSelectCity(city)}
895
+ className={cn(
896
+ 'w-full h-[32px] px-[12px] flex items-center justify-between text-left text-[14px]',
897
+ transCls,
898
+ city === selectedCity
899
+ ? 'bg-[#F0F6FF] text-[#0265FF]'
900
+ : 'text-[#030303] hover:bg-[#EFF0F6]',
901
+ )}
902
+ >
903
+ <span className="truncate">{city}</span>
904
+ {selectedProvince && chinaAddressData[selectedProvince]?.[city] && (
905
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" className="shrink-0 ml-[4px]">
906
+ <path d="M4.5 2.5L8 6L4.5 9.5" stroke={city === selectedCity ? '#0265FF' : '#999999'} strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
907
+ </svg>
908
+ )}
909
+ </button>
910
+ ))
911
+ )}
912
+ </div>
913
+ </div>
914
+ {/* District Column */}
915
+ <div className="flex-1 flex flex-col min-w-0">
916
+ <div className="h-[34px] px-[12px] flex items-center text-[12px] text-[#999999] bg-[#FAFAFA] shrink-0 border-b border-[#F0F0F0]">
917
+ 区/县
918
+ </div>
919
+ <div ref={districtRef} className="flex-1 overflow-y-auto">
920
+ {districtList.length === 0 ? (
921
+ <div className="px-[12px] py-[16px] text-[14px] text-[#999999] text-center">请选择城市</div>
922
+ ) : (
923
+ districtList.map((district) => (
924
+ <button
925
+ key={district}
926
+ type="button"
927
+ onClick={() => handleSelectDistrict(district)}
928
+ className={cn(
929
+ 'w-full h-[32px] px-[12px] flex items-center text-left text-[14px]',
930
+ transCls,
931
+ district === selectedDistrict
932
+ ? 'bg-[#F0F6FF] text-[#0265FF]'
933
+ : 'text-[#030303] hover:bg-[#EFF0F6]',
934
+ )}
935
+ >
936
+ <span className="truncate">{district}</span>
937
+ </button>
938
+ ))
939
+ )}
940
+ </div>
941
+ </div>
942
+ </div>
943
+ </PopoverContent>
944
+ </Popover>
945
+ </FieldWrapper>
946
+ );
947
+ }
948
+
949
+ // ── AddressInput ────────────────────────────────────────────────────
950
+
951
+ export function AddressInput({ field, value, onChange, disabled, service, showLabel, showDescription }: FieldValueInputProps) {
952
+ const [open, setOpen] = useState(false);
953
+ const [keyword, setKeyword] = useState('');
954
+ const [options, setOptions] = useState<IAddressOption[]>([]);
955
+ const [loading, setLoading] = useState(false);
956
+ const { portalContainer, setTriggerRef } = usePortalContainer('data-bs-field-value-picker-portal');
957
+
958
+ const selectedLabel =
959
+ value && typeof value === 'object' && 'province' in (value as Record<string, unknown>)
960
+ ? `${(value as IAddressOption).province}${(value as IAddressOption).city}${(value as IAddressOption).district}`
961
+ : typeof value === 'string'
962
+ ? value
963
+ : '';
964
+
965
+ useEffect(() => {
966
+ if (open && service?.searchAddress) {
967
+ setLoading(true);
968
+ service.searchAddress(keyword).then((data) => {
969
+ setOptions(data);
970
+ setLoading(false);
971
+ });
972
+ }
973
+ }, [open, keyword, service]);
974
+
975
+ return (
976
+ <FieldWrapper field={field} showLabel={showLabel} showDescription={showDescription}>
977
+ <Popover open={open} onOpenChange={setOpen}>
978
+ <PopoverTrigger asChild>
979
+ <button
980
+ ref={setTriggerRef}
981
+ type="button"
982
+ disabled={disabled}
983
+ className={cn(
984
+ 'flex items-center justify-between gap-[10px]',
985
+ inputBaseCls,
986
+ 'text-left cursor-pointer',
987
+ open ? 'border-[#0265FF]' : 'border-[#E0E0E0] hover:border-[#0265FF]',
988
+ )}
989
+ >
990
+ <span className={cn('truncate', !selectedLabel && 'text-[#999999]')}>
991
+ {selectedLabel || (field.description ?? `请选择${field.label}`)}
992
+ </span>
993
+ <DropdownChevron open={open} />
994
+ </button>
995
+ </PopoverTrigger>
996
+ <PopoverContent
997
+ align="start"
998
+ sideOffset={4}
999
+ portalContainer={portalContainer}
1000
+ className="!border-0 rounded-[10px] shadow-[0px_0px_5px_rgba(0,0,0,0.04)] p-[8px]"
1001
+ style={{ width: 'var(--radix-popover-trigger-width)' }}
1002
+ >
1003
+ <div className="flex items-center gap-[8px] px-[6px] mb-[4px] h-[34px] bg-[#F6F7FA] rounded-[4px]">
1004
+ <SearchIcon />
1005
+ <input
1006
+ type="text"
1007
+ value={keyword}
1008
+ onChange={(e) => setKeyword(e.target.value)}
1009
+ placeholder={`搜索${field.label}`}
1010
+ className="flex-1 bg-transparent text-[14px] text-[#030303] placeholder:text-[#999999] outline-none border-none"
1011
+ autoFocus
1012
+ />
1013
+ </div>
1014
+ <div className="flex flex-col gap-0 max-h-[200px] overflow-y-auto">
1015
+ {loading ? (
1016
+ <div className="px-[10px] py-[8px] text-[14px] text-[#999999]">搜索中...</div>
1017
+ ) : options.length === 0 ? (
1018
+ <div className="px-[10px] py-[8px] text-[14px] text-[#999999]">暂无数据</div>
1019
+ ) : (
1020
+ options.map((opt, i) => (
1021
+ <button
1022
+ key={i}
1023
+ type="button"
1024
+ onClick={() => {
1025
+ onChange?.(opt);
1026
+ setOpen(false);
1027
+ setKeyword('');
1028
+ }}
1029
+ className={cn(
1030
+ 'h-[34px] px-[10px] rounded-[4px] text-left text-[14px] leading-[normal]',
1031
+ 'text-[#030303] hover:bg-[#EFF0F6]',
1032
+ transCls,
1033
+ )}
1034
+ >
1035
+ {opt.province}{opt.city}{opt.district}
1036
+ </button>
1037
+ ))
1038
+ )}
1039
+ </div>
1040
+ </PopoverContent>
1041
+ </Popover>
1042
+ </FieldWrapper>
1043
+ );
1044
+ }
1045
+
1046
+ // ── LocationInput ───────────────────────────────────────────────────
1047
+
1048
+ export function LocationInput({ field, value, onChange, disabled, service, showLabel, showDescription }: FieldValueInputProps) {
1049
+ const [open, setOpen] = useState(false);
1050
+ const [keyword, setKeyword] = useState('');
1051
+ const [options, setOptions] = useState<ILocationOption[]>([]);
1052
+ const [loading, setLoading] = useState(false);
1053
+ const { portalContainer, setTriggerRef } = usePortalContainer('data-bs-field-value-picker-portal');
1054
+
1055
+ const selectedLabel =
1056
+ value && typeof value === 'object' && 'detail' in (value as Record<string, unknown>)
1057
+ ? (value as ILocationOption).detail
1058
+ : typeof value === 'string'
1059
+ ? value
1060
+ : '';
1061
+
1062
+ useEffect(() => {
1063
+ if (open && service?.searchLocation) {
1064
+ setLoading(true);
1065
+ service.searchLocation(keyword).then((data) => {
1066
+ setOptions(data);
1067
+ setLoading(false);
1068
+ });
1069
+ }
1070
+ }, [open, keyword, service]);
1071
+
1072
+ return (
1073
+ <FieldWrapper field={field} showLabel={showLabel} showDescription={showDescription}>
1074
+ <Popover open={open} onOpenChange={setOpen}>
1075
+ <PopoverTrigger asChild>
1076
+ <button
1077
+ ref={setTriggerRef}
1078
+ type="button"
1079
+ disabled={disabled}
1080
+ className={cn(
1081
+ 'flex items-center justify-between gap-[10px]',
1082
+ inputBaseCls,
1083
+ 'text-left cursor-pointer',
1084
+ open ? 'border-[#0265FF]' : 'border-[#E0E0E0] hover:border-[#0265FF]',
1085
+ )}
1086
+ >
1087
+ <span className={cn('truncate', !selectedLabel && 'text-[#999999]')}>
1088
+ {selectedLabel || (field.description ?? `请选择${field.label}`)}
1089
+ </span>
1090
+ <DropdownChevron open={open} />
1091
+ </button>
1092
+ </PopoverTrigger>
1093
+ <PopoverContent
1094
+ align="start"
1095
+ sideOffset={4}
1096
+ portalContainer={portalContainer}
1097
+ className="!border-0 rounded-[10px] shadow-[0px_0px_5px_rgba(0,0,0,0.04)] p-[8px]"
1098
+ style={{ width: 'var(--radix-popover-trigger-width)' }}
1099
+ >
1100
+ <div className="flex items-center gap-[8px] px-[6px] mb-[4px] h-[34px] bg-[#F6F7FA] rounded-[4px]">
1101
+ <SearchIcon />
1102
+ <input
1103
+ type="text"
1104
+ value={keyword}
1105
+ onChange={(e) => setKeyword(e.target.value)}
1106
+ placeholder={`搜索${field.label}`}
1107
+ className="flex-1 bg-transparent text-[14px] text-[#030303] placeholder:text-[#999999] outline-none border-none"
1108
+ autoFocus
1109
+ />
1110
+ </div>
1111
+ <div className="flex flex-col gap-0 max-h-[200px] overflow-y-auto">
1112
+ {loading ? (
1113
+ <div className="px-[10px] py-[8px] text-[14px] text-[#999999]">搜索中...</div>
1114
+ ) : options.length === 0 ? (
1115
+ <div className="px-[10px] py-[8px] text-[14px] text-[#999999]">暂无数据</div>
1116
+ ) : (
1117
+ options.map((opt, i) => (
1118
+ <button
1119
+ key={i}
1120
+ type="button"
1121
+ onClick={() => {
1122
+ onChange?.(opt);
1123
+ setOpen(false);
1124
+ setKeyword('');
1125
+ }}
1126
+ className={cn(
1127
+ 'h-[34px] px-[10px] rounded-[4px] text-left text-[14px] leading-[normal]',
1128
+ 'text-[#030303] hover:bg-[#EFF0F6]',
1129
+ transCls,
1130
+ )}
1131
+ >
1132
+ {opt.province}{opt.city}{opt.district} {opt.detail}
1133
+ </button>
1134
+ ))
1135
+ )}
1136
+ </div>
1137
+ </PopoverContent>
1138
+ </Popover>
1139
+ </FieldWrapper>
1140
+ );
1141
+ }