Files
FastGPT/packages/web/components/common/MySelect/index.tsx
Archer 2ed1545eb5 V4.12.4 features (#5626)
* fix: push again, user select option button and form input radio content overflow (#5601)

* fix: push again, user select option button and form input radio content overflow

* fix: use useCallback instead of useMemo, fix unnecessary delete

* fix: Move the variable inside the component

* fix: do not pass valueLabel to MySelect

* ui

* del collection api adapt

* refactor: inherit permission (#5529)

* refactor: permission update conflict check function

* refactor(permission): app collaborator update api

* refactor(permission): support app update collaborator

* feat: support fe permission conflict check

* refactor(permission): app permission

* refactor(permission): dataset permission

* refactor(permission): team permission

* chore: fe adjust

* fix: type error

* fix: audit pagiation

* fix: tc

* chore: initv4130

* fix: app/dataset auth logic

* chore: move code

* refactor(permission): remove selfPermission

* fix: mock

* fix: test

* fix: app & dataset auth

* fix: inherit

* test(inheritPermission): test syncChildrenPermission

* prompt editor add list plugin (#5620)

* perf: search result (#5608)

* fix: table size (#5598)

* temp: list value

* backspace

* optimize code

---------

Co-authored-by: Archer <545436317@qq.com>
Co-authored-by: 伍闲犬 <whoeverimf5@gmail.com>

* fix: fe & member list (#5619)

* chore: initv4130

* fix: MemberItemCard

* fix: MemberItemCard

* chore: fe adjust & init script

* perf: test code

* doc

* fix debug variables (#5617)

* perf: search result (#5608)

* fix: table size (#5598)

* fix debug variables

* fix

---------

Co-authored-by: Archer <545436317@qq.com>
Co-authored-by: 伍闲犬 <whoeverimf5@gmail.com>

* perf: member ui

* fix: inherit bug (#5624)

* refactor(permission): remove getClbsWithInfo, which is useless

* fix: app list privateApp

* fix: get infos

* perf(fe): remove delete icon when it is disable in MemberItemCard

* fix: dataset private dataset

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Archer <545436317@qq.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* perf: auto coupon

* chore: upgrade script & get infos avatar  (#5625)

* fix: get infos

* chore: initv4130

* feat: support WecomRobot publish, and fix AesKey can not save bug (#5526)

* feat: resolve conflicts

* fix: add param 'show_publish_wecom'

* feat: abstract out WecomCrypto type

* doc: wecom robot document

* fix: solve instability in AI output

* doc: update some pictures

* feat: remove functions from request.ts to chat.ts and toolCall.ts

* doc: wecom robot doc update

* fix

* delete unused code

* doc: update version and prompt

* feat: remove wecom crypto, delete wecom code in workflow

* feat: delete unused codes

---------

Co-authored-by: heheer <zhiyu44@qq.com>

* remove test

* rename init shell

* feat: collection page store

* reload sandbox

* pysandbox

* remove log

* chore: remove useless code (#5629)

* chore: remove useless code

* fix: checkConflict

* perf: support hidden type for RoleList

* fix: copy node

* update doc

* fix(permission): some bug (#5632)

* fix: app/dataset list

* fix: inherit bug

* perf: del app;i18n;save chat

* fix: test

* i18n

* fix: sumper overflow return OwnerRoleVal (#5633)

* remove invalid code

* fix: scroll

* fix: objectId

* update next

* update package

* object id

* mock redis

* feat: add redis append to resolve wecom stream response  (#5643)

* feat: resolve conflicts

* fix: add param 'show_publish_wecom'

* feat: abstract out WecomCrypto type

* doc: wecom robot document

* fix: solve instability in AI output

* doc: update some pictures

* feat: remove functions from request.ts to chat.ts and toolCall.ts

* doc: wecom robot doc update

* fix

* delete unused code

* doc: update version and prompt

* feat: remove wecom crypto, delete wecom code in workflow

* feat: delete unused codes

* feat: add redis append method

---------

Co-authored-by: heheer <zhiyu44@qq.com>

* cache per

* fix(test): init team sub when creating mocked user (#5646)

* fix: button is not vertically centered (#5647)

* doc

* fix: gridFs objectId (#5649)

---------

Co-authored-by: Zeng Qingwen <143274079+fishwww-ww@users.noreply.github.com>
Co-authored-by: Finley Ge <32237950+FinleyGe@users.noreply.github.com>
Co-authored-by: heheer <heheer@sealos.io>
Co-authored-by: 伍闲犬 <whoeverimf5@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: heheer <zhiyu44@qq.com>
2025-09-15 20:02:54 +08:00

325 lines
9.1 KiB
TypeScript

import React, {
useRef,
forwardRef,
useMemo,
useEffect,
useImperativeHandle,
type ForwardedRef,
useState
} from 'react';
import {
Menu,
MenuList,
MenuItem,
Button,
useDisclosure,
MenuButton,
Box,
Flex,
Input
} from '@chakra-ui/react';
import type { ButtonProps, MenuItemProps } from '@chakra-ui/react';
import MyIcon from '../Icon';
import { useRequest2 } from '../../../hooks/useRequest';
import MyDivider from '../MyDivider';
import type { useScrollPagination } from '../../../hooks/useScrollPagination';
import Avatar from '../Avatar';
import EmptyTip from '../EmptyTip';
/** 选择组件 Props 类型
* value: 选中的值
* placeholder: 占位符
* list: 列表数据
* isLoading: 是否加载中
* ScrollData: 分页滚动数据控制器 [useScrollPagination] 的返回值
* customOnOpen: 自定义打开回调
* customOnClose: 自定义关闭回调
* */
export type SelectProps<T = any> = Omit<ButtonProps, 'onChange'> & {
value?: T;
valueLabel?: string | React.ReactNode;
placeholder?: string;
isSearch?: boolean;
list: {
alias?: string | React.ReactNode;
icon?: string;
iconSize?: string;
label: string | React.ReactNode;
description?: string;
value: T;
showBorder?: boolean;
}[];
isLoading?: boolean;
onChange?: (val: T) => any | Promise<any>;
ScrollData?: ReturnType<typeof useScrollPagination>['ScrollData'];
customOnOpen?: () => void;
customOnClose?: () => void;
isInvalid?: boolean;
isDisabled?: boolean;
};
export const menuItemStyles: MenuItemProps = {
borderRadius: 'sm',
py: 2,
display: 'flex',
alignItems: 'center',
_hover: {
backgroundColor: 'myGray.100'
},
_notLast: {
mb: 1
}
};
const MySelect = <T = any,>(
{
bg = '#fff',
placeholder,
value,
valueLabel,
isSearch = false,
width = '100%',
list = [],
onChange,
isLoading = false,
ScrollData,
customOnOpen,
customOnClose,
isInvalid,
isDisabled,
...props
}: SelectProps<T>,
ref: ForwardedRef<{
focus: () => void;
}>
) => {
const ButtonRef = useRef<HTMLButtonElement>(null);
const MenuListRef = useRef<HTMLDivElement>(null);
const SelectedItemRef = useRef<HTMLDivElement>(null);
const SearchInputRef = useRef<HTMLInputElement>(null);
const { isOpen, onOpen: defaultOnOpen, onClose: defaultOnClose } = useDisclosure();
const selectItem = useMemo(() => list.find((item) => item.value === value), [list, value]);
const onOpen = () => {
defaultOnOpen();
customOnOpen?.();
};
const onClose = () => {
defaultOnClose();
customOnClose?.();
};
const [search, setSearch] = useState('');
const filterList = useMemo(() => {
if (!isSearch || !search) {
return list;
}
return list.filter((item) => {
const text = `${item.label?.toString()}${item.alias}${item.value}`;
const regx = new RegExp(search, 'gi');
return regx.test(text);
});
}, [list, search, isSearch]);
useImperativeHandle(ref, () => ({
focus() {
onOpen();
}
}));
// Auto scroll
useEffect(() => {
if (isOpen && MenuListRef.current && SelectedItemRef.current) {
const menu = MenuListRef.current;
const selectedItem = SelectedItemRef.current;
menu.scrollTop = selectedItem.offsetTop - menu.offsetTop - 100;
if (isSearch) {
setSearch('');
}
}
}, [isSearch, isOpen]);
const { runAsync: onClickChange, loading } = useRequest2((val: T) => onChange?.(val));
const ListRender = useMemo(() => {
return (
<>
{filterList.length > 0 ? (
filterList.map((item, i) => (
<Box key={i}>
<MenuItem
{...menuItemStyles}
{...(value === item.value
? {
ref: SelectedItemRef,
color: 'primary.700',
bg: 'myGray.100'
}
: {
color: 'myGray.900'
})}
onClick={(e) => {
e.stopPropagation();
if (value !== item.value) {
onClickChange(item.value);
}
}}
whiteSpace={'pre-wrap'}
fontSize={'sm'}
display={'block'}
mb={0.5}
>
<Flex alignItems={'center'}>
{item.icon && (
<Avatar mr={2} src={item.icon as any} w={item.iconSize ?? '1rem'} />
)}
{item.label}
</Flex>
{item.description && (
<Box color={'myGray.500'} fontSize={'xs'}>
{item.description}
</Box>
)}
</MenuItem>
{item.showBorder && <MyDivider my={2} />}
</Box>
))
) : (
<EmptyTip py={0} />
)}
</>
);
}, [filterList, onClickChange, value]);
const isSelecting = loading || isLoading;
return (
<Box>
<Menu
autoSelect={false}
isOpen={isOpen && !isSelecting}
onOpen={onOpen}
onClose={onClose}
strategy={'fixed'}
// matchWidth
>
<MenuButton
as={Button}
ref={ButtonRef}
width={width}
px={3}
rightIcon={<MyIcon name={'core/chat/chevronDown'} w={4} color={'myGray.500'} />}
variant={'whitePrimaryOutline'}
size={'md'}
fontSize={'sm'}
textAlign={'left'}
h={'auto'}
whiteSpace={'pre-wrap'}
wordBreak={'break-word'}
transition={'border-color 0.1s ease-in-out, box-shadow 0.1s ease-in-out'}
isDisabled={isDisabled}
_active={{
transform: 'none'
}}
bg={bg ? (isOpen ? '#fff' : bg) : '#fff'}
color={isOpen ? 'primary.700' : 'myGray.700'}
borderColor={isInvalid ? 'red.500' : isOpen ? 'primary.300' : 'myGray.200'}
boxShadow={
isOpen
? isInvalid
? '0px 0px 0px 2.4px rgba(255, 0, 0, 0.15)'
: '0px 0px 0px 2.4px rgba(51, 112, 255, 0.15)'
: 'none'
}
_hover={isInvalid ? { borderColor: 'red.400' } : { borderColor: 'primary.300' }}
{...props}
>
<Flex alignItems={'center'} justifyContent="space-between" w="100%">
<Flex alignItems={'center'}>
{isSelecting && <MyIcon mr={2} name={'common/loading'} w={'1rem'} />}
{valueLabel ? (
<>{valueLabel}</>
) : (
<>
{isSearch && isOpen ? (
<Input
ref={SearchInputRef}
autoFocus
variant={'unstyled'}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={
(typeof selectItem?.alias === 'string' ? selectItem?.alias : '') ||
(typeof selectItem?.label === 'string' ? selectItem?.label : placeholder)
}
size={'sm'}
w={'100%'}
color={'myGray.700'}
onBlur={() => {
setTimeout(() => {
SearchInputRef?.current?.focus();
}, 0);
}}
/>
) : (
<>
{selectItem?.icon && (
<Avatar
mr={2}
src={selectItem.icon as any}
w={selectItem.iconSize ?? '1rem'}
/>
)}
{
<Box noOfLines={1}>
{selectItem?.alias || selectItem?.label || placeholder}
</Box>
}
</>
)}
</>
)}
</Flex>
</Flex>
</MenuButton>
<MenuList
ref={MenuListRef}
className={props.className}
w={(() => {
const w = ButtonRef.current?.clientWidth;
if (w) {
return `${w}px !important`;
}
return Array.isArray(width)
? width.map((item) => `${item} !important`)
: `${width} !important`;
})()}
px={'6px'}
py={'6px'}
border={'1px solid #fff'}
boxShadow={
'0px 2px 4px rgba(161, 167, 179, 0.25), 0px 0px 1px rgba(121, 141, 159, 0.25);'
}
zIndex={99}
maxH={'45vh'}
overflowY={'auto'}
onClick={(e) => {
e.stopPropagation();
}}
>
{ScrollData ? <ScrollData>{ListRender}</ScrollData> : ListRender}
</MenuList>
</Menu>
</Box>
);
};
export default forwardRef(MySelect) as <T>(
props: SelectProps<T> & { ref?: React.Ref<HTMLSelectElement> }
) => JSX.Element;