diff --git a/src/components/auto-tooltip/index.tsx b/src/components/auto-tooltip/index.tsx index 86c97622..41b8a7d7 100644 --- a/src/components/auto-tooltip/index.tsx +++ b/src/components/auto-tooltip/index.tsx @@ -29,6 +29,7 @@ interface AutoTooltipProps extends Omit { } const StyledTag = styled(Tag)` + margin: 0; &.tag-filled { border: none; background-color: var(--ant-color-fill-secondary); diff --git a/src/components/seal-form/multiple-select.tsx b/src/components/seal-form/multiple-select.tsx new file mode 100644 index 00000000..69aa7c88 --- /dev/null +++ b/src/components/seal-form/multiple-select.tsx @@ -0,0 +1,121 @@ +import { isNotEmptyValue } from '@/utils/index'; +import { useIntl } from '@umijs/max'; +import type { SelectProps } from 'antd'; +import { Form } from 'antd'; +import { cloneDeep } from 'lodash'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import SimpleSelect from './simple-select'; +import { SealFormItemProps } from './types'; +import Wrapper from './wrapper'; +import SelectWrapper from './wrapper/select'; + +const SealSelect: React.FC< + SelectProps & SealFormItemProps & { showTags?: boolean } +> = (props) => { + const { + label, + placeholder, + children, + required, + description, + options, + allowNull, + isInFormItems = true, + notFoundContent = null, + ...rest + } = props; + const intl = useIntl(); + const [isFocus, setIsFocus] = useState(false); + const inputRef = useRef(null); + + let status = ''; + + // the status can be controlled by Form.Item + if (isInFormItems) { + const statusData = Form?.Item?.useStatus?.(); + status = statusData?.status || ''; + } else { + status = props.status || ''; + } + + const _options = useMemo(() => { + if (!options?.length) { + return []; + } + const list = cloneDeep(options); + return list.map((item: any) => { + if (item.locale) { + item.label = intl.formatMessage({ id: item.label as string }); + } + return item; + }); + }, [options, intl]); + + useEffect(() => { + if ( + isNotEmptyValue(props.value) || + (allowNull && (props.value === null || props.value === undefined)) + ) { + setIsFocus(true); + } + }, [props.value, allowNull]); + + const handleClickWrapper = () => { + if (!props.disabled && !isFocus) { + inputRef.current?.focus?.(); + setIsFocus(true); + } + }; + + const handleChange = (val: any, options: any) => { + if (isNotEmptyValue(val) || (allowNull && val === null)) { + setIsFocus(true); + } else { + setIsFocus(false); + } + props.onChange?.(val, options); + }; + + const handleOnFocus = (e: any) => { + setIsFocus(true); + props.onFocus?.(e); + }; + + const handleOnBlur = (e: any) => { + if (allowNull && props.value === null) { + setIsFocus(true); + } else if (!props.value) { + setIsFocus(false); + } + props.onBlur?.(e); + }; + + return ( + + + + {children} + + + + ); +}; + +export default SealSelect; diff --git a/src/components/seal-form/simple-select.tsx b/src/components/seal-form/simple-select.tsx index 163db328..d15cd5bc 100644 --- a/src/components/seal-form/simple-select.tsx +++ b/src/components/seal-form/simple-select.tsx @@ -2,7 +2,7 @@ import { useIntl } from '@umijs/max'; import type { SelectProps } from 'antd'; import { Checkbox, Tag } from 'antd'; import { CheckboxChangeEvent } from 'antd/es/checkbox'; -import React, { useEffect } from 'react'; +import React, { forwardRef, useEffect, useImperativeHandle } from 'react'; import styled from 'styled-components'; import AutoTooltip from '../auto-tooltip'; import BaseSelect from './base/select'; @@ -43,218 +43,239 @@ const TagWrapper = styled(Tag)` border-radius: 12px; `; -const SimpleSelect: React.FC = (props) => { - const intl = useIntl(); - const { options = [], ...restProps } = props; +const SimpleSelect: React.FC = + forwardRef((props, ref) => { + const intl = useIntl(); + const { options = [], showTags, ...restProps } = props; - const [allSelection, setAllSelection] = React.useState<{ - checked: boolean; - indeterminate: boolean; - }>({ - checked: false, - indeterminate: false - }); - const [optionsList, setOptionsList] = React.useState(options || []); - const selectRef = React.useRef(null); - - useEffect(() => { - setOptionsList(options || []); - }, [options]); - - const optionRender = (option: any, info: any) => { - const { value, label } = option; - return ( - - {restProps.value?.includes(value) ? ( - - ) : ( - - )} - {label} - - ); - }; - - const handleOnCheckboxChange = (e: CheckboxChangeEvent) => { - const isChecked = e.target.checked; - const allValues = optionsList?.map((opt: any) => opt.value) || []; - - setAllSelection({ - checked: isChecked, + const [allSelection, setAllSelection] = React.useState<{ + checked: boolean; + indeterminate: boolean; + }>({ + checked: false, indeterminate: false }); + const [optionsList, setOptionsList] = React.useState(options || []); + const selectRef = React.useRef(null); + const selectorRef = React.useRef(null); - let allSelectedValues = [...(restProps.value || [])]; + useEffect(() => { + setOptionsList(options || []); + }, [options]); - if (isChecked) { - // Select all options - allSelectedValues = Array.from( - new Set([...allSelectedValues, ...allValues]) + const optionRender = (option: any, info: any) => { + const { value, label } = option; + return ( + + {restProps.value?.includes?.(value) ? ( + + ) : ( + + )} + {label} + ); - } else { - // Deselect all options - allSelectedValues = allSelectedValues.filter( - (value) => !allValues.includes(value) - ); - } + }; - restProps.onChange?.(allSelectedValues, optionsList || []); - }; + const handleOnCheckboxChange = (e: CheckboxChangeEvent) => { + const isChecked = e.target.checked; + const allValues = optionsList?.map((opt: any) => opt.value) || []; - const dropdownRender = (originPanel: React.ReactNode) => { - return ( - - {restProps.mode === 'multiple' && ( - - - {intl.formatMessage({ id: 'common.checbox.all' })} - - - )} - {originPanel} - - ); - }; - - const handleOnChange = (value: any, option: any) => { - const selectedValues = Array.isArray(value) ? value : [value]; - const allSelected = optionsList?.map((opt: any) => opt.value) || []; - const isAllSelected = selectedValues.length === allSelected?.length; - - setAllSelection({ - checked: isAllSelected, - indeterminate: !isAllSelected && selectedValues.length > 0 - }); - - restProps.onChange?.(selectedValues, option); - }; - - const filterOption = (inputValue: string, option: any) => { - if (!option || !option.label) return false; - return option.label.toLowerCase().includes(inputValue.toLowerCase()); - }; - - const checkAllSelection = (list: Global.BaseOption[]) => { - if ( - !restProps.value || - !Array.isArray(restProps.value) || - list.length === 0 - ) { setAllSelection({ - checked: false, + checked: isChecked, indeterminate: false }); - return; - } - const selectedValues = new Set(restProps.value); - const allValues = list?.map((opt: any) => opt.value) || []; - const isAllSelected = allValues.every((val: any) => - selectedValues.has(val) - ); + let allSelectedValues = [...(restProps.value || [])]; - const isSomeSelected = allValues.some((val: any) => - selectedValues.has(val) - ); + if (isChecked) { + // Select all options + allSelectedValues = Array.from( + new Set([...allSelectedValues, ...allValues]) + ); + } else { + // Deselect all options + allSelectedValues = allSelectedValues.filter( + (value) => !allValues.includes(value) + ); + } - setAllSelection({ - checked: isAllSelected, - indeterminate: isSomeSelected && !isAllSelected - }); - }; + restProps.onChange?.(allSelectedValues, optionsList || []); + }; - const TagRender = (props: any) => { - const { label } = props; - const count = props.isMaxTag ? label.slice(0, -3).slice(1) : label; + const dropdownRender = (originPanel: React.ReactNode) => { + return ( + + {restProps.mode === 'multiple' && ( + + + {intl.formatMessage({ id: 'common.checbox.all' })} + + + )} + {originPanel} + + ); + }; - return ( - - {intl.formatMessage({ id: 'common.select.count' }, { count: count })} - - ); - }; + const handleOnChange = (value: any, option: any) => { + const selectedValues = Array.isArray(value) ? value : [value]; + const allSelected = optionsList?.map((opt: any) => opt.value) || []; + const isAllSelected = selectedValues.length === allSelected?.length; - const handleOnSearch = (value: string) => { - if (restProps.onSearch) { - restProps.onSearch(value); - } else { - const filteredOptions = options?.filter((option: any) => - option.label.toLowerCase().includes(value.toLowerCase()) - ) as Global.BaseOption[]; - setOptionsList(filteredOptions || []); - checkAllSelection(filteredOptions || []); - } - }; + setAllSelection({ + checked: isAllSelected, + indeterminate: !isAllSelected && selectedValues.length > 0 + }); - const handleOnBlur = (e: any) => { - restProps.onBlur?.(e); - }; + restProps.onChange?.(selectedValues, option); + }; - const handleOnFocus = (e: any) => { - restProps.onFocus?.(e); - }; + const filterOption = (inputValue: string, option: any) => { + if (!option || !option.label) return false; + return option.label.toLowerCase().includes(inputValue.toLowerCase()); + }; - const handleOnOpenChange = (open: boolean) => { - if (!open) { - checkAllSelection(options as Global.BaseOption[]); - setOptionsList(options || []); - } - }; - - useEffect(() => { - const input = selectRef.current?.querySelector?.('input'); - - if (!input) return; - - const handler = (event: KeyboardEvent) => { + const checkAllSelection = (list: Global.BaseOption[]) => { if ( - event.key === 'Backspace' && - (input as HTMLInputElement).value === '' + !restProps.value || + !Array.isArray(restProps.value) || + list.length === 0 ) { - event.stopPropagation(); - event.preventDefault(); + setAllSelection({ + checked: false, + indeterminate: false + }); + return; + } + const selectedValues = new Set(restProps.value); + const allValues = list?.map((opt: any) => opt.value) || []; + + const isAllSelected = allValues.every((val: any) => + selectedValues.has(val) + ); + + const isSomeSelected = allValues.some((val: any) => + selectedValues.has(val) + ); + + setAllSelection({ + checked: isAllSelected, + indeterminate: isSomeSelected && !isAllSelected + }); + }; + + const TagRender = (props: any) => { + const { label } = props; + const count = props.isMaxTag ? label.slice(0, -3).slice(1) : label; + + return ( + + {showTags + ? label + : intl.formatMessage( + { id: 'common.select.count' }, + { count: count } + )} + + ); + }; + + const handleOnSearch = (value: string) => { + if (restProps.onSearch) { + restProps.onSearch(value); + } else { + const filteredOptions = options?.filter((option: any) => + option.label.toLowerCase().includes(value.toLowerCase()) + ) as Global.BaseOption[]; + setOptionsList(filteredOptions || []); + checkAllSelection(filteredOptions || []); } }; - input.addEventListener('keydown', handler); - - return () => { - input.removeEventListener('keydown', handler); + const handleOnBlur = (e: any) => { + restProps.onBlur?.(e); }; - }, [selectRef.current]); - return ( -
- -
- ); -}; + const handleOnFocus = (e: any) => { + restProps.onFocus?.(e); + }; + + const handleOnOpenChange = (open: boolean) => { + if (!open) { + checkAllSelection(options as Global.BaseOption[]); + setOptionsList(options || []); + } + }; + + useEffect(() => { + const input = selectRef.current?.querySelector?.('input'); + + if (!input) return; + + const handler = (event: KeyboardEvent) => { + if ( + event.key === 'Backspace' && + (input as HTMLInputElement).value === '' + ) { + event.stopPropagation(); + event.preventDefault(); + } + }; + + input.addEventListener('keydown', handler); + + return () => { + input.removeEventListener('keydown', handler); + }; + }, [selectRef.current]); + + useImperativeHandle(ref, () => ({ + focus: () => { + selectorRef.current?.focus(); + }, + blur: () => { + selectorRef.current?.blur(); + } + })); + + return ( +
+ + {props.children} + +
+ ); + }); export default SimpleSelect; diff --git a/src/pages/_components/select-panel/index.tsx b/src/pages/_components/select-panel/index.tsx new file mode 100644 index 00000000..bafc8a5a --- /dev/null +++ b/src/pages/_components/select-panel/index.tsx @@ -0,0 +1,203 @@ +import { SearchOutlined } from '@ant-design/icons'; +import { Button, Checkbox, Empty, Input } from 'antd'; +import React, { useEffect, useMemo, useState } from 'react'; +import styled from 'styled-components'; +import List from './list'; +import SelectedList from './selected-list'; + +const PanelWrapper = styled.div<{ $maxHeight?: number; $leftWidth?: number }>` + border: 1px solid var(--ant-color-border); + border-radius: var(--ant-border-radius); + overflow-y: auto; + max-height: ${({ $maxHeight }) => + $maxHeight ? `${$maxHeight + 2}px` : 'auto'}; +`; + +const Left = styled.div` + padding: 0; +`; +const Right = styled.div``; + +const Header = styled.div` + padding: 8px 12px 8px; + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--ant-color-split); + background-color: var(--ant-color-fill-alter); +`; + +interface SelectPanelProps { + searchPlaceholder?: string; + height?: number; + leftWidth?: number; + options: Array<{ key: string; title: string }>; + selectedKeys: string[]; + onSelectChange: (selectedKeys: string[]) => void; +} + +const SelectPanel: React.FC = ({ + height = 300, + leftWidth = 260, + options, + selectedKeys, + searchPlaceholder, + onSelectChange +}) => { + const [indeterminate, setIndeterminate] = React.useState(false); + const [checkAll, setCheckAll] = React.useState(false); + const [searchText, setSearchText] = useState(''); + + const showOptions = useMemo(() => { + return options.filter((item) => + item.title.toLowerCase().includes(searchText.toLowerCase()) + ); + }, [options, searchText]); + + const handleSearch = (e: React.ChangeEvent) => { + setSearchText(e.target.value); + }; + + const handleOnUnselect = ( + key: string, + newSelectedKeys: { key: string; title: string }[] + ) => { + onSelectChange(newSelectedKeys.map((item) => item.key)); + }; + + const handleCheckAllChange = (e: any) => { + const checked = e.target.checked; + setCheckAll(checked); + setIndeterminate(false); + if (checked) { + const allKeys = options + .filter((item) => + item.title.toLowerCase().includes(searchText.toLowerCase()) + ) + .map((item) => item.key); + onSelectChange(Array.from(new Set([...selectedKeys, ...allKeys]))); + } else { + const filteredKeys = options + .filter((item) => + item.title.toLowerCase().includes(searchText.toLowerCase()) + ) + .map((item) => item.key); + const newSelectedKeys = selectedKeys.filter( + (key) => !filteredKeys.includes(key) + ); + onSelectChange(newSelectedKeys); + } + }; + + const updateCheckStatus = (newSelectedKeys: string[]) => { + if (options.length === 0) { + setIndeterminate(false); + setCheckAll(false); + return; + } + const filteredOptions = options.filter((item) => + item.title.toLowerCase().includes(searchText.toLowerCase()) + ); + const filteredKeys = filteredOptions.map((item) => item.key); + const selectedFilteredKeys = newSelectedKeys.filter((key) => + filteredKeys.includes(key) + ); + setIndeterminate( + selectedFilteredKeys.length > 0 && + selectedFilteredKeys.length < filteredKeys.length + ); + setCheckAll(selectedFilteredKeys.length === filteredKeys.length); + }; + + const handleSelectChange = (newSelectedKeys: string[]) => { + onSelectChange(newSelectedKeys); + updateCheckStatus(newSelectedKeys); + }; + + const handleClearSelection = () => { + onSelectChange([]); + setCheckAll(false); + setIndeterminate(false); + }; + + useEffect(() => { + updateCheckStatus(selectedKeys); + }, [selectedKeys, options]); + + const renderRight = () => { + return ( + +
+ ({selectedKeys.length}) selected + +
+ + selectedKeys.includes(item.key) + )} + onUnselect={handleOnUnselect} + /> +
+ ); + }; + + return ( + + +
+ + + {selectedKeys.length} selected + + + } + size="small" + allowClear + placeholder={searchPlaceholder} + style={{ + width: 300, + height: 32, + borderRadius: 4, + backgroundColor: 'var(--ant-color-bg-container) !important' + }} + onChange={handleSearch} + /> +
+ {showOptions.length > 0 ? ( + + ) : ( + + )} +
+
+ ); +}; + +export default SelectPanel; diff --git a/src/pages/_components/select-panel/list.tsx b/src/pages/_components/select-panel/list.tsx new file mode 100644 index 00000000..48a92116 --- /dev/null +++ b/src/pages/_components/select-panel/list.tsx @@ -0,0 +1,70 @@ +import { OverlayScroller } from '@/components/overlay-scroller'; +import { Checkbox } from 'antd'; +import React from 'react'; +import styled from 'styled-components'; +import AutoTooltip from '../../../components/auto-tooltip'; + +interface ListProps { + maxHeight?: number; + dataList: Array<{ key: string; title: string }>; + selectedKeys: string[]; + renderTitle?: (item: { key: string; title: string }) => React.ReactNode; + onSelectChange: (selectedKeys: string[]) => void; +} + +const UL = styled.ul` + list-style: none; + margin: 0; + padding: 0; +`; + +const LI = styled.li<{ selected: boolean }>` + display: flex; + align-items: center; + padding: 5px 12px; + cursor: pointer; + border-radius: 2px; + gap: 8px; + &:hover { + background-color: var(--ant-control-item-bg-hover); + } +`; + +const List: React.FC = ({ + maxHeight, + dataList, + selectedKeys, + onSelectChange, + renderTitle +}) => { + const handleClickItem = (item: { key: string; title: string }) => { + const itemKey = item.key; + const newSelectedKeys = selectedKeys.includes(itemKey) + ? selectedKeys.filter((key) => key !== itemKey) + : [...selectedKeys, itemKey]; + onSelectChange(newSelectedKeys); + }; + + return ( + +
    + {dataList.map((item) => ( +
  • handleClickItem(item)} + > + + {renderTitle ? ( + renderTitle(item) + ) : ( + {item.title} + )} +
  • + ))} +
+
+ ); +}; + +export default List; diff --git a/src/pages/_components/select-panel/selected-list.tsx b/src/pages/_components/select-panel/selected-list.tsx new file mode 100644 index 00000000..2830fcb4 --- /dev/null +++ b/src/pages/_components/select-panel/selected-list.tsx @@ -0,0 +1,60 @@ +import AutoTooltip from '@/components/auto-tooltip'; +import { OverlayScroller } from '@/components/overlay-scroller'; +import { Tag } from 'antd'; +import React from 'react'; +import styled from 'styled-components'; + +const TagInner = styled(Tag)` + border-radius: 12px; + margin: 0; +`; + +const Content = styled.div` + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 8px 0; +`; + +interface SelectedProps { + maxHeight?: number; + selectedList: { key: string; title: string }[]; + onUnselect: ( + key: string, + newSelectedKeys: { key: string; title: string }[] + ) => void; +} + +const SelectedList: React.FC = ({ + maxHeight, + selectedList, + onUnselect +}) => { + const handleOnUnselect = (key: string) => { + const newSelectedKeys = selectedList.filter((item) => item.key !== key); + onUnselect(key, newSelectedKeys); + }; + + return ( + + + {selectedList.map((item) => ( + { + e.preventDefault(); + handleOnUnselect(item.key); + }} + closable + > + {item.title} + + ))} + + + ); +}; + +export default SelectedList; diff --git a/src/pages/_components/transfer.tsx b/src/pages/_components/transfer.tsx index 4b40885d..d762325f 100644 --- a/src/pages/_components/transfer.tsx +++ b/src/pages/_components/transfer.tsx @@ -1,20 +1,51 @@ import { MoreOutlined } from '@ant-design/icons'; -import { Pagination, Transfer, TransferProps } from 'antd'; -import { useState } from 'react'; +import { Transfer, TransferProps } from 'antd'; import styled from 'styled-components'; type TransferKey = string | number | bigint; -const PaginationWrapper = styled.div` - padding: 4px 16px; -`; - const TransferWrap = styled.div` .ant-transfer-list { width: 100%; - height: 360px; + height: 300px; + .ant-transfer-list-header-dropdown { + display: none; + } + .ant-input-outlined { + height: 32px; + padding-block: 4px; + border-radius: 4px; + } + } + .ant-transfer-operation { + margin: 0 16px; + gap: 12px; + .ant-btn-icon-only { + width: 32px; + height: 32px; + border-radius: 50%; + } } .ant-transfer-list-content { + &::-webkit-scrollbar { + width: var(--scrollbar-size); + } + + &::-webkit-scrollbar-thumb { + background-color: transparent; + border-radius: 4px; + } + + &::-webkit-scrollbar-track { + background-color: transparent; + } + + &:hover { + &::-webkit-scrollbar-thumb { + background-color: var(--color-scrollbar-thumb); + border-radius: 4px; + } + } .ant-transfer-list-content-item { &:hover { background-color: var(--ant-control-item-bg-hover); @@ -39,38 +70,28 @@ interface TransferInnerProps extends TransferProps { dataSource?: Array<{ key: TransferKey; title: string }>; targetKeys?: TransferKey[]; } + const TransferInner: React.FC = (props) => { - const [page, setPage] = useState(1); - const { onPageChange, total, perPage = 30 } = props; - - const handleOnPageChange = (page: number, perPage?: number) => { - setPage(page); - onPageChange?.(page, perPage); - }; - - const renderFooter = (TransferProps: any, { direction }: any) => { - if (direction === 'left' && total && total > perPage!) { + const renderAllLabels = (info: { + selectedCount: number; + totalCount: number; + }) => { + if (info.selectedCount) { return ( - - - + + {info.selectedCount} selected + ); } return null; }; - return ( } diff --git a/src/pages/api-keys/components/add-apikey-modal/allow-models.tsx b/src/pages/api-keys/components/add-apikey-modal/allow-models.tsx index c321a278..4de2b13b 100644 --- a/src/pages/api-keys/components/add-apikey-modal/allow-models.tsx +++ b/src/pages/api-keys/components/add-apikey-modal/allow-models.tsx @@ -1,11 +1,29 @@ -import TransferInner from '@/pages/_components/transfer'; +import { PageAction } from '@/config'; +import { PageActionType } from '@/config/types'; +import SelectPanel from '@/pages/_components/select-panel'; import { queryModelsList } from '@/pages/llmodels/apis'; -import { Form } from 'antd'; +import { Divider, Form, Radio } from 'antd'; import { useEffect, useState } from 'react'; +import styled from 'styled-components'; +import { ListItem } from '../../config/types'; -const AllowModelsForm: React.FC = () => { +const Label = styled.div` + font-weight: 500; + margin-block: -8px 12px; + font-size: 14px; + margin-left: 4px; +`; + +const AllowModelsForm: React.FC<{ + currentData?: Partial | null; + action: PageActionType; +}> = ({ currentData, action }) => { const form = Form.useFormInstance(); - const targetKeys = Form.useWatch('allowed_model_names', form); + const allowedModelNames = Form.useWatch( + 'allowed_model_names', + form + ) as string[]; + const allowedType = Form.useWatch('allowed_type', form); const [modelList, setModelList] = useState<{ key: string; title: string }[]>( [] ); @@ -17,36 +35,53 @@ const AllowModelsForm: React.FC = () => { const getModelList = async () => { try { const res = await queryModelsList(queryParams); - const options = res.items.map((item) => ({ - title: item.name, - key: item.name - })); - setModelList(options); + const options = res.items.map((item) => item.name); + if (action === PageAction.EDIT && currentData) { + const list = new Set([ + ...options, + ...(currentData.allowed_model_names?.map((item) => item) || []) + ]); + setModelList( + Array.from(list).map((item) => ({ key: item, title: item })) + ); + } else { + setModelList(options.map((item) => ({ key: item, title: item }))); + } } catch (error) {} }; useEffect(() => { getModelList(); - }, []); + }, [action, currentData]); return ( - - { - form.setFieldsValue({ allowed_model_names: nextTargetKeys }); - }} - render={(item) => item.title} - titles={['Available Models', 'Allowed Models']} - showSearch={{ - placeholder: 'Filter by model name' - }} - filterOption={(inputValue, item) => - item.title.toLowerCase().includes(inputValue.toLowerCase()) - } - > - +
+ + + + + + +
); }; diff --git a/src/pages/api-keys/components/add-apikey-modal/form.tsx b/src/pages/api-keys/components/add-apikey-modal/form.tsx index 9dbd6c53..f9e3cea2 100644 --- a/src/pages/api-keys/components/add-apikey-modal/form.tsx +++ b/src/pages/api-keys/components/add-apikey-modal/form.tsx @@ -1,13 +1,18 @@ import SealInput from '@/components/seal-form/seal-input'; import SealSelect from '@/components/seal-form/seal-select'; +import { PageAction } from '@/config'; +import { PageActionType } from '@/config/types'; import { useIntl } from '@umijs/max'; import { Form } from 'antd'; import React from 'react'; import { expirationOptions } from '../../config'; -import { FormData } from '../../config/types'; +import { FormData, ListItem } from '../../config/types'; import AllowModelsForm from './allow-models'; -const APIKeyForm: React.FC = () => { +const APIKeyForm: React.FC<{ + action: PageActionType; + currentData?: Partial | null; +}> = ({ action, currentData }) => { const intl = useIntl(); return ( @@ -27,6 +32,8 @@ const APIKeyForm: React.FC = () => { ]} > @@ -48,18 +55,22 @@ const APIKeyForm: React.FC = () => { ]} > - name="description" rules={[{ required: false }]}> + ); }; diff --git a/src/pages/api-keys/components/add-apikey-modal/index.tsx b/src/pages/api-keys/components/add-apikey-modal/index.tsx index 7f93031e..74e8b582 100644 --- a/src/pages/api-keys/components/add-apikey-modal/index.tsx +++ b/src/pages/api-keys/components/add-apikey-modal/index.tsx @@ -1,17 +1,18 @@ import CopyButton from '@/components/copy-button'; import ModalFooter from '@/components/modal-footer'; -import ScrollerModal from '@/components/scroller-modal'; +import GSDrawer from '@/components/scroller-modal/gs-drawer'; import SealInput from '@/components/seal-form/seal-input'; import { PageAction } from '@/config'; import { PageActionType } from '@/config/types'; +import ColumnWrapper from '@/pages/_components/column-wrapper'; import { useIntl } from '@umijs/max'; import { Button, Form, Tag } from 'antd'; import dayjs from 'dayjs'; +import _ from 'lodash'; import { useEffect, useState } from 'react'; import { createApisKey, updateApisKey } from '../../apis'; import { expirationOptions } from '../../config'; import { FormData, ListItem } from '../../config/types'; -import AllowModelsForm from './allow-models'; import APIKeyForm from './form'; type AddModalProps = { @@ -37,25 +38,6 @@ const AddModal: React.FC = ({ const [apikeyValue, setAPIKeyValue] = useState(''); const [loading, setLoading] = useState(false); - const initValues = () => { - if (action === PageAction.CREATE && open) { - form.setFieldsValue({ - expires_in: 1 - }); - } - if (action === PageAction.EDIT && currentData && open) { - form.setFieldsValue({ - name: currentData.name, - description: currentData.description, - allowed_model_names: currentData.allowed_model_names || [] - }); - } - }; - - useEffect(() => { - initValues(); - }, [open]); - const getExpireValue = (val: number | null) => { const expires_in = val; if (expires_in === -1) { @@ -74,6 +56,28 @@ const AddModal: React.FC = ({ return res; }; + // 7d, 1m, 6m, -1 + const parseExpireValue = (data: ListItem) => { + const createdAt = dayjs(data.created_at); + const expiresAt = dayjs(data.expires_at); + + if (!data.expires_at) { + return -1; + } + + const diffInDays = expiresAt.diff(createdAt, 'day'); + + if (diffInDays < 10) { + return 7; + } + + if (diffInDays < 60) { + return 1; + } + + return 6; + }; + const createAPIKey = async (data: FormData) => { const params = { ...data, @@ -89,13 +93,22 @@ const AddModal: React.FC = ({ onOk(); }; - const handleOnOk = async (data: FormData) => { + const handleOnOk = async (formdata: FormData) => { try { setLoading(true); + const data = { + ..._.omit(formdata, ['allowed_type']), + allowed_model_names: + formdata.allowed_type === 'all' + ? [] + : formdata.allowed_model_names || [] + }; if (action === PageAction.CREATE) { await createAPIKey(data); } else if (action === PageAction.EDIT && currentData?.id) { - await updateAPIKey(data); + await updateAPIKey({ + ..._.omit(data, ['expires_in']) + }); } setLoading(false); } catch (error) { @@ -115,66 +128,115 @@ const AddModal: React.FC = ({ setShowKey(false); }; + const initValues = () => { + if (action === PageAction.CREATE && open) { + form.setFieldsValue({ + expires_in: 1 + }); + } + if (action === PageAction.EDIT && currentData && open) { + parseExpireValue(currentData as ListItem); + form.setFieldsValue({ + name: currentData.name, + description: currentData.description, + allowed_type: currentData.allowed_model_names?.length + ? 'custom' + : 'all', + expires_in: parseExpireValue(currentData as ListItem), + allowed_model_names: currentData.allowed_model_names || [] + }); + } + }; + + useEffect(() => { + initValues(); + }, [open]); + return ( - - ) : ( - - ) - } + styles={{ + body: { + height: 'calc(100vh - 57px)', + padding: '16px 0', + overflowX: 'hidden' + }, + content: { + borderRadius: '6px 0 0 6px' + } + }} + width={600} + footer={false} > -
- {action === PageAction.EDIT && } - {!showKey && action === PageAction.CREATE && } - {showKey && action === PageAction.CREATE && ( - -
- - {intl.formatMessage({ id: 'apikeys.table.save.tips' })} - -
- - } - > -
- )} -
-
+ + ) : ( + + ) + } + > +
+ {!showKey && ( + + )} + {showKey && action === PageAction.CREATE && ( + +
+ + {intl.formatMessage({ id: 'apikeys.table.save.tips' })} + +
+ + } + > +
+ )} +
+
+ ); }; diff --git a/src/pages/api-keys/config/types.ts b/src/pages/api-keys/config/types.ts index 85e882c5..ea11d467 100644 --- a/src/pages/api-keys/config/types.ts +++ b/src/pages/api-keys/config/types.ts @@ -11,6 +11,7 @@ export interface ListItem { export interface FormData { name: string; + allowed_type: 'all' | 'custom'; description: string; allowed_model_names: string[]; expires_in: number | null; diff --git a/src/pages/api-keys/hooks/use-keys-columns.tsx b/src/pages/api-keys/hooks/use-keys-columns.tsx index a0203974..39953f4a 100644 --- a/src/pages/api-keys/hooks/use-keys-columns.tsx +++ b/src/pages/api-keys/hooks/use-keys-columns.tsx @@ -16,7 +16,7 @@ interface ColumnsHookProps { const actionList: Global.ActionItem[] = [ { - label: 'Edit Allowed Models', + label: 'common.button.edit', key: 'edit', icon: icons.EditOutlined }, diff --git a/src/pages/api-keys/index.tsx b/src/pages/api-keys/index.tsx index 534c5274..ecd7eb80 100644 --- a/src/pages/api-keys/index.tsx +++ b/src/pages/api-keys/index.tsx @@ -58,7 +58,7 @@ const APIKeys: React.FC = () => { const handleEditKey = (record: ListItem) => { setOpenAddModal({ open: true, - title: 'Edit Allowed Models', + title: 'Edit API Key', action: PageAction.EDIT, currentData: record }); diff --git a/src/pages/llmodels/components/access-control-modal/form.tsx b/src/pages/llmodels/components/access-control-modal/form.tsx index 4caad31d..4220e46b 100644 --- a/src/pages/llmodels/components/access-control-modal/form.tsx +++ b/src/pages/llmodels/components/access-control-modal/form.tsx @@ -1,7 +1,6 @@ -import CheckboxField from '@/components/seal-form/checkbox-field'; import TransferInner from '@/pages/_components/transfer'; import { queryUsersList } from '@/pages/users/apis'; -import { Form } from 'antd'; +import { Empty, Form, Radio } from 'antd'; import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'; import styled from 'styled-components'; import { queryModelAccessUserList } from '../../apis'; @@ -11,9 +10,9 @@ type TransferKey = string | number | bigint; const Label = styled.div` font-weight: 500; - margin-bottom: 16px; - margin-top: 16px; + margin-block: 8px 12px; font-size: 14px; + color: var(--ant-color-text-tertiary); `; interface AccessControlFormProps { @@ -40,7 +39,8 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => { const res = await queryUsersList(query); const options = res.items.map((item) => ({ title: item.username, - key: item.id + key: item.id, + is_admin: item.is_admin })); console.log('options', options); setTotalPages(res.pagination.totalPage); @@ -86,7 +86,7 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => { const keys = res.items.map((item) => item.id); setTargetKeys(keys); form.setFieldsValue({ - set_public: res.items.length > 0, + set_public: currentData.public, users: res.items.map((item) => ({ id: item.id })) }); }); @@ -105,43 +105,56 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => { clearOnDestroy={true} scrollToFirstError={true} initialValues={{ - public: true + set_public: true }} > - - valuePropName="checked" - name="set_public" - style={{ marginBottom: 24, paddingLeft: 6 }} - > - + + name="set_public" noStyle> + - {setPublic && ( + {!setPublic && ( <> - - - name="users" - rules={[ - { - required: true, - message: 'Please select at least one user' - } - ]} - > + + name="users"> , + + ] + }} showSearch={{ placeholder: 'Filter by username' }} - render={(item) => item.title} - selectAllLabels={[]} + filterOption={(inputValue, item) => + item.title.toLowerCase().includes(inputValue.toLowerCase()) + } + render={(item) => ( + + {item.title} + + {item.is_admin ? '(Admin)' : ''} + + + )} onSearch={onSearch} onChange={handleOnChange} /> diff --git a/src/pages/llmodels/components/access-control-modal/index.tsx b/src/pages/llmodels/components/access-control-modal/index.tsx index ee1bbe4b..35b49b54 100644 --- a/src/pages/llmodels/components/access-control-modal/index.tsx +++ b/src/pages/llmodels/components/access-control-modal/index.tsx @@ -18,11 +18,10 @@ const AccessControlModal: React.FC< }; const handleOnFinish = async (values: AccessControlFormData) => { - console.log('onFinish', values); try { const data = { - set_public: !values.set_public, - users: values.users || [] + set_public: values.set_public, + users: values.set_public ? [] : values.users || [] }; await updateModelAccessUser({ id: currentData?.id as number, diff --git a/src/pages/llmodels/components/table-list.tsx b/src/pages/llmodels/components/table-list.tsx index 6780dcd3..88d3105c 100644 --- a/src/pages/llmodels/components/table-list.tsx +++ b/src/pages/llmodels/components/table-list.tsx @@ -467,7 +467,7 @@ const Models: React.FC = ({ if (val === 'accessControl') { setOpenAccessControlModal({ - title: 'Edit Access Control', + title: 'Access Settings', action: PageAction.EDIT, currentData: row, open: true diff --git a/src/pages/llmodels/config/button-actions.ts b/src/pages/llmodels/config/button-actions.ts index 860b71ec..b30a5f2d 100644 --- a/src/pages/llmodels/config/button-actions.ts +++ b/src/pages/llmodels/config/button-actions.ts @@ -62,7 +62,7 @@ export const ActionList: ActionItem[] = [ icon: icons.ApiOutlined }, { - label: 'Access Control', + label: 'Access Settings', key: 'accessControl', icon: icons.Private }, diff --git a/src/pages/llmodels/config/types.ts b/src/pages/llmodels/config/types.ts index 81576db3..f3072240 100644 --- a/src/pages/llmodels/config/types.ts +++ b/src/pages/llmodels/config/types.ts @@ -24,6 +24,7 @@ export interface ListItem { local_path?: string; created_at: string; updated_at: string; + public?: boolean; gpu_selector?: { gpu_ids: string[]; };