@baishuyun/ai-design 2.0.38 → 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.
- package/.storybook/main.ts +31 -31
- package/.storybook/preview.ts +16 -16
- package/CHANGELOG.md +512 -501
- package/components.json +22 -22
- package/package.json +9 -6
- package/scripts/strip-layers.cjs +41 -41
- package/src/components/abs-fullscreen-gradient-bg.tsx +35 -35
- package/src/components/agent-helper/agent-category-select-menu/index.tsx +57 -57
- package/src/components/agent-helper/agent-entry-card/index.tsx +94 -94
- package/src/components/agent-helper/agent-helper-card/index.tsx +179 -179
- package/src/components/agent-helper/agent-helper-card-list/index.tsx +59 -59
- package/src/components/agent-helper/agent-helper-main-layout/index.tsx +60 -60
- package/src/components/agent-helper/agent-sort-select/index.tsx +89 -89
- package/src/components/agent-helper/agent-tag/index.tsx +29 -29
- package/src/components/agent-helper/app-select/index.tsx +559 -559
- package/src/components/agent-helper/condition-group/index.tsx +75 -75
- package/src/components/agent-helper/condition-input/index.tsx +233 -233
- package/src/components/agent-helper/condition-item/index.tsx +237 -237
- package/src/components/agent-helper/condition-quantifier-select/index.tsx +142 -142
- package/src/components/agent-helper/condition-trigger-description/index.tsx +49 -49
- package/src/components/agent-helper/date-picker/calendar-panel.tsx +164 -164
- package/src/components/agent-helper/date-picker/date-input.tsx +91 -91
- package/src/components/agent-helper/date-picker/date-picker.tsx +327 -327
- package/src/components/agent-helper/date-picker/footer-panel.tsx +44 -44
- package/src/components/agent-helper/date-picker/index.ts +3 -3
- package/src/components/agent-helper/date-picker/month-panel.tsx +82 -82
- package/src/components/agent-helper/date-picker/time-panel.tsx +146 -146
- package/src/components/agent-helper/date-picker/types.ts +37 -37
- package/src/components/agent-helper/date-picker/utils.ts +121 -121
- package/src/components/agent-helper/date-picker/year-panel.tsx +86 -86
- package/src/components/agent-helper/exec-log-filter/index.tsx +232 -232
- package/src/components/agent-helper/field-edit-preference-select/index.tsx +291 -291
- package/src/components/agent-helper/field-select/index.tsx +466 -466
- package/src/components/agent-helper/field-value-input/china-address-data.ts +4117 -4117
- package/src/components/agent-helper/field-value-input/display.tsx +71 -71
- package/src/components/agent-helper/field-value-input/icons.tsx +38 -38
- package/src/components/agent-helper/field-value-input/index.tsx +144 -144
- package/src/components/agent-helper/field-value-input/inputs.tsx +247 -247
- package/src/components/agent-helper/field-value-input/picker-inputs.tsx +1141 -1141
- package/src/components/agent-helper/flow-node-action-trigger-select/index.tsx +57 -57
- package/src/components/agent-helper/form-action-trigger-select/index.tsx +60 -60
- package/src/components/agent-helper/form-divider/index.tsx +44 -44
- package/src/components/agent-helper/form-select/index.tsx +732 -732
- package/src/components/agent-helper/monthdays-select/index.tsx +378 -378
- package/src/components/agent-helper/offset-preference-select/index.tsx +268 -268
- package/src/components/agent-helper/offset-type-select/index.tsx +66 -66
- package/src/components/agent-helper/offset-unit-select/index.tsx +54 -54
- package/src/components/agent-helper/placeholder-card/index.tsx +75 -75
- package/src/components/agent-helper/prompt-input/index.tsx +646 -646
- package/src/components/agent-helper/relational-operator-select/index.tsx +179 -179
- package/src/components/agent-helper/repeat-by-type-select/index.tsx +53 -53
- package/src/components/agent-helper/repeat-preference-select/index.tsx +162 -162
- package/src/components/agent-helper/repeat-preference-type-select/index.tsx +58 -58
- package/src/components/agent-helper/search-input/index.tsx +35 -35
- package/src/components/agent-helper/table/index.tsx +114 -114
- package/src/components/agent-helper/trigger-select/index.tsx +121 -121
- package/src/components/agent-helper/trigger-time-preference-select/index.tsx +189 -189
- package/src/components/agent-helper/trigger-time-source-type-select/index.tsx +53 -53
- package/src/components/agent-helper/weekdays-select/index.tsx +230 -230
- package/src/components/agent-scroll-selector.tsx +193 -193
- package/src/components/agent-select.tsx +154 -154
- package/src/components/attachment-part-group.tsx +35 -35
- package/src/components/attachment-part.tsx +296 -296
- package/src/components/attachments-previewer.tsx +272 -272
- package/src/components/base-button.tsx +49 -49
- package/src/components/border-animation.tsx +107 -107
- package/src/components/border-color-animation.tsx +66 -66
- package/src/components/bot-avatar-name.tsx +36 -36
- package/src/components/bot-icon.tsx +23 -23
- package/src/components/bs-entry-select/app-group-folder.tsx +61 -61
- package/src/components/bs-entry-select/bs-entry-select.tsx +296 -296
- package/src/components/bs-entry-select/build-tree.ts +265 -265
- package/src/components/bs-entry-select/entry-item.tsx +45 -45
- package/src/components/bs-entry-select/entry-popover.tsx +99 -99
- package/src/components/bs-entry-select/index.ts +7 -7
- package/src/components/bs-entry-select/other-app-section.tsx +96 -96
- package/src/components/bs-entry-select/scroll-slider.tsx +872 -872
- package/src/components/bs-entry-select/search-input.tsx +4 -4
- package/src/components/bs-entry-select/tree-item-node.tsx +84 -84
- package/src/components/bs-entry-select/types.ts +46 -46
- package/src/components/bs-icons.tsx +1362 -1362
- package/src/components/card-field.tsx +23 -23
- package/src/components/card.tsx +36 -36
- package/src/components/chat-area-header.tsx +65 -65
- package/src/components/check-card.tsx +77 -77
- package/src/components/circle-checker.tsx +66 -66
- package/src/components/collapsible-txt-msg.tsx +75 -75
- package/src/components/collapsible.tsx +9 -9
- package/src/components/confirm-dialog.tsx +139 -139
- package/src/components/date-range-picker.tsx +91 -91
- package/src/components/fake-bot-msg.tsx +51 -51
- package/src/components/field-checker-list-msg.tsx +146 -146
- package/src/components/field-checker.tsx +185 -185
- package/src/components/field-icon.tsx +13 -13
- package/src/components/field-value-checker.tsx +116 -116
- package/src/components/fields-design-info-table.tsx +160 -160
- package/src/components/fields-generating-indicator.tsx +95 -95
- package/src/components/fields-portal.tsx +18 -18
- package/src/components/fields-previewer.tsx +216 -216
- package/src/components/font-icon.tsx +65 -65
- package/src/components/form-info-editor.tsx +263 -263
- package/src/components/formula-tag.tsx +32 -32
- package/src/components/generate-animation.tsx +56 -56
- package/src/components/icon-btn.tsx +43 -43
- package/src/components/icon-card.tsx +67 -67
- package/src/components/icons/calendar-icon.tsx +5 -5
- package/src/components/icons/clear-all-icon.tsx +5 -5
- package/src/components/icons/empty-agent-icon.tsx +93 -93
- package/src/components/icons/empty-table-icon.tsx +104 -104
- package/src/components/icons/grids-icon.tsx +5 -5
- package/src/components/img-part.tsx +27 -27
- package/src/components/legacy-slot.tsx +110 -110
- package/src/components/line-checker.tsx +168 -168
- package/src/components/linear-gradient-border-btn.tsx +48 -48
- package/src/components/linear-gradient-color-bg-animation.tsx +103 -103
- package/src/components/memoized-markdown.tsx +32 -32
- package/src/components/message-content.tsx +21 -21
- package/src/components/number-part.tsx +46 -46
- package/src/components/office-container.tsx +289 -289
- package/src/components/option-item.tsx +27 -27
- package/src/components/output-config-editor.tsx +478 -478
- package/src/components/previewer-header.tsx +270 -270
- package/src/components/primary-confirm-btn.tsx +58 -58
- package/src/components/primary-entry-btn.tsx +48 -48
- package/src/components/radio-card.tsx +63 -63
- package/src/components/scroll-to-bottom-btn.tsx +28 -28
- package/src/components/split-line.tsx +9 -9
- package/src/components/square-checker.tsx +87 -87
- package/src/components/subform-fields-value-checker.tsx +184 -184
- package/src/components/tab-radio-group.tsx +83 -83
- package/src/components/tooltip.tsx +53 -53
- package/src/components/ui/button.tsx +51 -51
- package/src/components/ui/calendar.tsx +57 -57
- package/src/components/ui/dialog.tsx +104 -104
- package/src/components/ui/drawer.tsx +279 -279
- package/src/components/ui/form.tsx +202 -202
- package/src/components/ui/icons.tsx +1267 -1267
- package/src/components/ui/input.tsx +28 -28
- package/src/components/ui/label.tsx +30 -30
- package/src/components/ui/popover.tsx +85 -85
- package/src/components/ui/select.tsx +236 -236
- package/src/components/ui/spinner.tsx +17 -17
- package/src/components/ui/switch.tsx +33 -33
- package/src/components/ui/tabs.tsx +62 -62
- package/src/components/ui/textarea.tsx +28 -28
- package/src/components/ui/tooltip.tsx +53 -53
- package/src/components/user-txt-msg-bubble.tsx +47 -47
- package/src/components/warning-msg.tsx +27 -27
- package/src/const/ui.ts +119 -119
- package/src/css.d.ts +4 -4
- package/src/hooks/use-column-appear.ts +78 -78
- package/src/hooks/use-portal-container.ts +27 -27
- package/src/hooks/use-resizable-width.ts +129 -129
- package/src/hooks/use-select-popover-position.ts +74 -74
- package/src/hooks/use-shadow.ts +127 -127
- package/src/index.ts +226 -226
- package/src/lib/utils.ts +138 -138
- package/src/stories/AbsFullscreenGradientBg.stories.tsx +36 -36
- package/src/stories/AgentCategorySelectMenu.stories.tsx +33 -33
- package/src/stories/AgentEntryCard.stories.tsx +90 -90
- package/src/stories/AgentHelperCard.stories.tsx +105 -105
- package/src/stories/AgentHelperCardList.stories.tsx +120 -120
- package/src/stories/AgentHelperMainLayout.stories.tsx +71 -71
- package/src/stories/AgentScrollSelector.stories.tsx +95 -95
- package/src/stories/AgentSelect.stories.tsx +134 -134
- package/src/stories/AgentTag.stories.tsx +44 -44
- package/src/stories/AppSelect.stories.tsx +250 -250
- package/src/stories/AttachmentPart.stories.tsx +122 -122
- package/src/stories/AttachmentPartGroup.stories.tsx +152 -152
- package/src/stories/AttachmentsPreviewer.stories.tsx +306 -306
- package/src/stories/BaseButton.stories.tsx +33 -33
- package/src/stories/BorderAnimation.stories.tsx +116 -116
- package/src/stories/BorderColorAnimation.stories.tsx +63 -63
- package/src/stories/BotAvatarAndName.stories.ts +24 -24
- package/src/stories/BotIcon.stories.ts +22 -22
- package/src/stories/BsConditionInput.stories.tsx +460 -460
- package/src/stories/BsEntrySelect.stories.tsx +343 -343
- package/src/stories/BsTooltip.stories.tsx +32 -32
- package/src/stories/Card.stories.tsx +28 -28
- package/src/stories/CardField.stories.tsx +87 -87
- package/src/stories/ChatAreaHeader.stories.ts +26 -26
- package/src/stories/CheckCard.stories.tsx +114 -114
- package/src/stories/CircleChecker.stories.tsx +89 -89
- package/src/stories/CollapsibleTextMsg.stories.ts +44 -44
- package/src/stories/ConditionGroup.stories.tsx +216 -216
- package/src/stories/ConditionInput.stories.tsx +502 -502
- package/src/stories/ConditionItem.stories.tsx +163 -163
- package/src/stories/ConditonQuantifierSelect.stories.tsx +54 -54
- package/src/stories/ConditonTriggerDescription.stories.tsx +61 -61
- package/src/stories/ConfirmDialog.stories.tsx +123 -123
- package/src/stories/DataTable.stories.tsx +191 -191
- package/src/stories/DatePicker.stories.tsx +174 -174
- package/src/stories/ExecLogFilter.stories.tsx +145 -145
- package/src/stories/FakeBotMsg.stories.tsx +36 -36
- package/src/stories/FieldEditPreferenceSelect.stories.tsx +161 -161
- package/src/stories/FieldSelect.stories.tsx +228 -228
- package/src/stories/FieldValueInput.stories.tsx +442 -442
- package/src/stories/FlowNodeActionTriggerSelect.stories.tsx +80 -80
- package/src/stories/Form.stories.tsx +302 -302
- package/src/stories/FormActionTriggerSelect.stories.tsx +80 -80
- package/src/stories/FormDivider.stories.tsx +31 -31
- package/src/stories/FormInfoEditor.stories.tsx +173 -173
- package/src/stories/FormSelect.stories.tsx +222 -222
- package/src/stories/FormulaTag.stories.tsx +68 -68
- package/src/stories/GenerateAnimation.stories.ts +20 -20
- package/src/stories/IconBtn.stories.tsx +32 -32
- package/src/stories/IconCard.stories.tsx +35 -35
- package/src/stories/IconLib.tsx +45 -45
- package/src/stories/Icons.stories.ts +22 -22
- package/src/stories/ImgMsgPart.stories.tsx +42 -42
- package/src/stories/Input.stories.tsx +102 -102
- package/src/stories/Label.stories.tsx +66 -66
- package/src/stories/LegacySlot.stories.tsx +56 -56
- package/src/stories/LineChecker.stories.tsx +133 -133
- package/src/stories/LinearGradientBorderBtn.stories.tsx +86 -86
- package/src/stories/LinearGradientColorBgAnimation.stories.tsx +60 -60
- package/src/stories/MemoizedMarkdown.stories.tsx +102 -102
- package/src/stories/MessageContent.stories.tsx +36 -36
- package/src/stories/MonthdaysSelect.stories.tsx +78 -78
- package/src/stories/NumbersMsgPart.stories.tsx +42 -42
- package/src/stories/OfficeContainer.stories.tsx +287 -287
- package/src/stories/OffsetPreferenceSelect.stories.tsx +118 -118
- package/src/stories/OffsetTypeSelect.stories.tsx +75 -75
- package/src/stories/OffsetUnitSelect.stories.tsx +61 -61
- package/src/stories/OptionItem.stories.tsx +77 -77
- package/src/stories/OutputConfigEditor.stories.tsx +96 -96
- package/src/stories/PlaceholderCard.stories.tsx +35 -35
- package/src/stories/PreviewerHeader.stories.tsx +91 -91
- package/src/stories/PrimaryConfirmBtn.stories.tsx +60 -60
- package/src/stories/PrimaryEntryBtn.stories.tsx +74 -74
- package/src/stories/PromptInput.stories.tsx +228 -228
- package/src/stories/RadioCard.stories.tsx +103 -103
- package/src/stories/RelationalOperatorSelect.stories.tsx +61 -61
- package/src/stories/RepeatByTypeSelect.stories.tsx +58 -58
- package/src/stories/RepeatPreferenceSelect.stories.tsx +107 -107
- package/src/stories/RepeatPreferenceTypeSelect.stories.tsx +58 -58
- package/src/stories/ScrollToBottom.stories.tsx +24 -24
- package/src/stories/SearchInput.stories.tsx +50 -50
- package/src/stories/Select.stories.tsx +128 -128
- package/src/stories/SplitLine.stories.tsx +40 -40
- package/src/stories/SquareChecker.stories.tsx +96 -96
- package/src/stories/TabRadioGroup.stories.tsx +84 -84
- package/src/stories/Textarea.stories.tsx +67 -67
- package/src/stories/TriggerSelect.stories.tsx +48 -48
- package/src/stories/TriggerTimePreferenceSelect.stories.tsx +106 -106
- package/src/stories/TriggerTimeSourceTypeSelect.stories.tsx +68 -68
- package/src/stories/UserTextMsgBubble.stories.ts +24 -24
- package/src/stories/WarningMessage.stories.tsx +23 -23
- package/src/stories/WeekdaysSelect.stories.tsx +66 -66
- package/src/stories/assets/demo.css +539 -539
- package/src/stories/assets/demo_index.html +7988 -7988
- package/src/stories/assets/iconfont.css +1377 -1377
- package/src/stories/assets/iconfont.json +2382 -2382
- package/src/stories/assets/iconfont.svg +697 -697
- package/src/stories/collapsible.stories.tsx +47 -47
- package/src/stories/drawer.stories.tsx +196 -196
- package/src/stories/field-checker-list-msg.stories.tsx +95 -95
- package/src/stories/field-checker.stories.tsx +117 -117
- package/src/stories/field-icon.stories.tsx +70 -70
- package/src/stories/field-value-checker.stories.tsx +113 -113
- package/src/stories/fields-design-info-table.stories.tsx +203 -203
- package/src/stories/fields-generating.stories.tsx +41 -41
- package/src/stories/fields-previewer.stories.tsx +77 -77
- package/src/stories/subform-fields-value-checker.stories.tsx +98 -98
- package/src/style.css +226 -226
- package/src/types.ts +8 -8
- package/style.css +3652 -3652
- package/tsconfig.json +13 -13
- 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
|
+
}
|