chore: model access statement

This commit is contained in:
jialin
2025-10-13 15:21:17 +08:00
parent 663b6c7b4e
commit bb7c8e9c85
18 changed files with 976 additions and 357 deletions
+1
View File
@@ -29,6 +29,7 @@ interface AutoTooltipProps extends Omit<TagProps, 'title'> {
} }
const StyledTag = styled(Tag)` const StyledTag = styled(Tag)`
margin: 0;
&.tag-filled { &.tag-filled {
border: none; border: none;
background-color: var(--ant-color-fill-secondary); background-color: var(--ant-color-fill-secondary);
@@ -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<any>(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 (
<SelectWrapper>
<Wrapper
className="seal-select-wrapper"
status={status}
label={label}
isFocus={isFocus}
required={required}
description={description}
disabled={props.disabled}
onClick={handleClickWrapper}
>
<SimpleSelect
{...rest}
ref={inputRef}
options={children ? null : _options}
onFocus={handleOnFocus}
onBlur={handleOnBlur}
onChange={handleChange}
notFoundContent={notFoundContent}
>
{children}
</SimpleSelect>
</Wrapper>
</SelectWrapper>
);
};
export default SealSelect;
+208 -187
View File
@@ -2,7 +2,7 @@ import { useIntl } from '@umijs/max';
import type { SelectProps } from 'antd'; import type { SelectProps } from 'antd';
import { Checkbox, Tag } from 'antd'; import { Checkbox, Tag } from 'antd';
import { CheckboxChangeEvent } from 'antd/es/checkbox'; import { CheckboxChangeEvent } from 'antd/es/checkbox';
import React, { useEffect } from 'react'; import React, { forwardRef, useEffect, useImperativeHandle } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import AutoTooltip from '../auto-tooltip'; import AutoTooltip from '../auto-tooltip';
import BaseSelect from './base/select'; import BaseSelect from './base/select';
@@ -43,218 +43,239 @@ const TagWrapper = styled(Tag)`
border-radius: 12px; border-radius: 12px;
`; `;
const SimpleSelect: React.FC<SelectProps> = (props) => { const SimpleSelect: React.FC<SelectProps & { ref?: any; showTags?: boolean }> =
const intl = useIntl(); forwardRef((props, ref) => {
const { options = [], ...restProps } = props; const intl = useIntl();
const { options = [], showTags, ...restProps } = props;
const [allSelection, setAllSelection] = React.useState<{ const [allSelection, setAllSelection] = React.useState<{
checked: boolean; checked: boolean;
indeterminate: boolean; indeterminate: boolean;
}>({ }>({
checked: false, checked: false,
indeterminate: false
});
const [optionsList, setOptionsList] = React.useState<any[]>(options || []);
const selectRef = React.useRef<any>(null);
useEffect(() => {
setOptionsList(options || []);
}, [options]);
const optionRender = (option: any, info: any) => {
const { value, label } = option;
return (
<OptionWrapper>
{restProps.value?.includes(value) ? (
<Checkbox checked></Checkbox>
) : (
<Checkbox></Checkbox>
)}
<AutoTooltip ghost>{label}</AutoTooltip>
</OptionWrapper>
);
};
const handleOnCheckboxChange = (e: CheckboxChangeEvent) => {
const isChecked = e.target.checked;
const allValues = optionsList?.map((opt: any) => opt.value) || [];
setAllSelection({
checked: isChecked,
indeterminate: false indeterminate: false
}); });
const [optionsList, setOptionsList] = React.useState<any[]>(options || []);
const selectRef = React.useRef<any>(null);
const selectorRef = React.useRef<any>(null);
let allSelectedValues = [...(restProps.value || [])]; useEffect(() => {
setOptionsList(options || []);
}, [options]);
if (isChecked) { const optionRender = (option: any, info: any) => {
// Select all options const { value, label } = option;
allSelectedValues = Array.from( return (
new Set([...allSelectedValues, ...allValues]) <OptionWrapper>
{restProps.value?.includes?.(value) ? (
<Checkbox checked></Checkbox>
) : (
<Checkbox></Checkbox>
)}
<AutoTooltip ghost>{label}</AutoTooltip>
</OptionWrapper>
); );
} 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 (
<DropdownWrapper>
{restProps.mode === 'multiple' && (
<SelectAllWrapper>
<Checkbox
checked={allSelection.checked}
indeterminate={allSelection.indeterminate}
onChange={handleOnCheckboxChange}
>
{intl.formatMessage({ id: 'common.checbox.all' })}
</Checkbox>
</SelectAllWrapper>
)}
{originPanel}
</DropdownWrapper>
);
};
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<string | number>[]) => {
if (
!restProps.value ||
!Array.isArray(restProps.value) ||
list.length === 0
) {
setAllSelection({ setAllSelection({
checked: false, checked: isChecked,
indeterminate: false indeterminate: false
}); });
return;
}
const selectedValues = new Set(restProps.value);
const allValues = list?.map((opt: any) => opt.value) || [];
const isAllSelected = allValues.every((val: any) => let allSelectedValues = [...(restProps.value || [])];
selectedValues.has(val)
);
const isSomeSelected = allValues.some((val: any) => if (isChecked) {
selectedValues.has(val) // Select all options
); allSelectedValues = Array.from(
new Set([...allSelectedValues, ...allValues])
);
} else {
// Deselect all options
allSelectedValues = allSelectedValues.filter(
(value) => !allValues.includes(value)
);
}
setAllSelection({ restProps.onChange?.(allSelectedValues, optionsList || []);
checked: isAllSelected, };
indeterminate: isSomeSelected && !isAllSelected
});
};
const TagRender = (props: any) => { const dropdownRender = (originPanel: React.ReactNode) => {
const { label } = props; return (
const count = props.isMaxTag ? label.slice(0, -3).slice(1) : label; <DropdownWrapper>
{restProps.mode === 'multiple' && (
<SelectAllWrapper>
<Checkbox
checked={allSelection.checked}
indeterminate={allSelection.indeterminate}
onChange={handleOnCheckboxChange}
>
{intl.formatMessage({ id: 'common.checbox.all' })}
</Checkbox>
</SelectAllWrapper>
)}
{originPanel}
</DropdownWrapper>
);
};
return ( const handleOnChange = (value: any, option: any) => {
<TagWrapper const selectedValues = Array.isArray(value) ? value : [value];
bordered={false} const allSelected = optionsList?.map((opt: any) => opt.value) || [];
style={{ const isAllSelected = selectedValues.length === allSelected?.length;
height: 24,
backgroundColor: 'var(--ant-color-fill-tertiary)',
fontSize: 'var(--ant-font-size)'
}}
className="flex-center"
>
{intl.formatMessage({ id: 'common.select.count' }, { count: count })}
</TagWrapper>
);
};
const handleOnSearch = (value: string) => { setAllSelection({
if (restProps.onSearch) { checked: isAllSelected,
restProps.onSearch(value); indeterminate: !isAllSelected && selectedValues.length > 0
} else { });
const filteredOptions = options?.filter((option: any) =>
option.label.toLowerCase().includes(value.toLowerCase())
) as Global.BaseOption<string | number>[];
setOptionsList(filteredOptions || []);
checkAllSelection(filteredOptions || []);
}
};
const handleOnBlur = (e: any) => { restProps.onChange?.(selectedValues, option);
restProps.onBlur?.(e); };
};
const handleOnFocus = (e: any) => { const filterOption = (inputValue: string, option: any) => {
restProps.onFocus?.(e); if (!option || !option.label) return false;
}; return option.label.toLowerCase().includes(inputValue.toLowerCase());
};
const handleOnOpenChange = (open: boolean) => { const checkAllSelection = (list: Global.BaseOption<string | number>[]) => {
if (!open) {
checkAllSelection(options as Global.BaseOption<string | number>[]);
setOptionsList(options || []);
}
};
useEffect(() => {
const input = selectRef.current?.querySelector?.('input');
if (!input) return;
const handler = (event: KeyboardEvent) => {
if ( if (
event.key === 'Backspace' && !restProps.value ||
(input as HTMLInputElement).value === '' !Array.isArray(restProps.value) ||
list.length === 0
) { ) {
event.stopPropagation(); setAllSelection({
event.preventDefault(); 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 (
<TagWrapper
bordered={false}
closable={props.closable}
onClose={props.onClose}
style={{
height: 24,
backgroundColor: 'var(--ant-color-fill-tertiary)',
fontSize: 'var(--ant-font-size)'
}}
className="flex-center"
>
{showTags
? label
: intl.formatMessage(
{ id: 'common.select.count' },
{ count: count }
)}
</TagWrapper>
);
};
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<string | number>[];
setOptionsList(filteredOptions || []);
checkAllSelection(filteredOptions || []);
} }
}; };
input.addEventListener('keydown', handler); const handleOnBlur = (e: any) => {
restProps.onBlur?.(e);
return () => {
input.removeEventListener('keydown', handler);
}; };
}, [selectRef.current]);
return ( const handleOnFocus = (e: any) => {
<div ref={selectRef}> restProps.onFocus?.(e);
<BaseSelect };
{...restProps}
options={optionsList} const handleOnOpenChange = (open: boolean) => {
maxTagCount={0} if (!open) {
defaultActiveFirstOption={false} checkAllSelection(options as Global.BaseOption<string | number>[]);
popupRender={dropdownRender} setOptionsList(options || []);
optionRender={optionRender} }
menuItemSelectedIcon={false} };
onChange={handleOnChange}
tagRender={TagRender} useEffect(() => {
onBlur={handleOnBlur} const input = selectRef.current?.querySelector?.('input');
onFocus={handleOnFocus}
onSearch={handleOnSearch} if (!input) return;
filterOption={filterOption}
onOpenChange={handleOnOpenChange} const handler = (event: KeyboardEvent) => {
></BaseSelect> if (
</div> 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 (
<div ref={selectRef} style={{ width: 'inherit' }}>
<BaseSelect
{...restProps}
ref={selectorRef}
options={optionsList}
maxTagCount={restProps.maxTagCount || 0}
defaultActiveFirstOption={false}
popupRender={dropdownRender}
optionRender={optionRender}
menuItemSelectedIcon={false}
onChange={handleOnChange}
tagRender={TagRender}
onBlur={handleOnBlur}
onFocus={handleOnFocus}
onSearch={handleOnSearch}
filterOption={filterOption}
onOpenChange={handleOnOpenChange}
>
{props.children}
</BaseSelect>
</div>
);
});
export default SimpleSelect; export default SimpleSelect;
@@ -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<SelectPanelProps> = ({
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<HTMLInputElement>) => {
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 (
<Right>
<Header>
<span>({selectedKeys.length}) selected</span>
<Button type="text" size="small" onClick={handleClearSelection}>
Clear
</Button>
</Header>
<SelectedList
maxHeight={height - 40}
selectedList={options.filter((item) =>
selectedKeys.includes(item.key)
)}
onUnselect={handleOnUnselect}
/>
</Right>
);
};
return (
<PanelWrapper $maxHeight={height} $leftWidth={leftWidth}>
<Left>
<Header>
<span
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
color: 'var(--ant-color-text-tertiary)'
}}
>
<Checkbox
checked={checkAll}
indeterminate={indeterminate}
onChange={handleCheckAllChange}
></Checkbox>
<span> {selectedKeys.length} selected</span>
</span>
<Input
prefix={
<SearchOutlined
style={{ color: 'var(--ant-color-text-quaternary)' }}
/>
}
size="small"
allowClear
placeholder={searchPlaceholder}
style={{
width: 300,
height: 32,
borderRadius: 4,
backgroundColor: 'var(--ant-color-bg-container) !important'
}}
onChange={handleSearch}
/>
</Header>
{showOptions.length > 0 ? (
<List
maxHeight={height - 40}
dataList={showOptions}
selectedKeys={selectedKeys}
onSelectChange={handleSelectChange}
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="No models found"
></Empty>
)}
</Left>
</PanelWrapper>
);
};
export default SelectPanel;
@@ -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<ListProps> = ({
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 (
<OverlayScroller style={{ paddingInline: 0 }} maxHeight={maxHeight}>
<UL>
{dataList.map((item) => (
<LI
key={item.key}
selected={selectedKeys.includes(item.key)}
onClick={() => handleClickItem(item)}
>
<Checkbox checked={selectedKeys.includes(item.key)}></Checkbox>
{renderTitle ? (
renderTitle(item)
) : (
<AutoTooltip ghost>{item.title}</AutoTooltip>
)}
</LI>
))}
</UL>
</OverlayScroller>
);
};
export default List;
@@ -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<SelectedProps> = ({
maxHeight,
selectedList,
onUnselect
}) => {
const handleOnUnselect = (key: string) => {
const newSelectedKeys = selectedList.filter((item) => item.key !== key);
onUnselect(key, newSelectedKeys);
};
return (
<OverlayScroller maxHeight={maxHeight}>
<Content>
{selectedList.map((item) => (
<AutoTooltip
title={item.title}
key={item.key}
maxWidth={200}
onClose={(e) => {
e.preventDefault();
handleOnUnselect(item.key);
}}
closable
>
{item.title}
</AutoTooltip>
))}
</Content>
</OverlayScroller>
);
};
export default SelectedList;
+50 -29
View File
@@ -1,20 +1,51 @@
import { MoreOutlined } from '@ant-design/icons'; import { MoreOutlined } from '@ant-design/icons';
import { Pagination, Transfer, TransferProps } from 'antd'; import { Transfer, TransferProps } from 'antd';
import { useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
type TransferKey = string | number | bigint; type TransferKey = string | number | bigint;
const PaginationWrapper = styled.div`
padding: 4px 16px;
`;
const TransferWrap = styled.div` const TransferWrap = styled.div`
.ant-transfer-list { .ant-transfer-list {
width: 100%; 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 { .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 { .ant-transfer-list-content-item {
&:hover { &:hover {
background-color: var(--ant-control-item-bg-hover); background-color: var(--ant-control-item-bg-hover);
@@ -39,38 +70,28 @@ interface TransferInnerProps extends TransferProps {
dataSource?: Array<{ key: TransferKey; title: string }>; dataSource?: Array<{ key: TransferKey; title: string }>;
targetKeys?: TransferKey[]; targetKeys?: TransferKey[];
} }
const TransferInner: React.FC<TransferInnerProps> = (props) => { const TransferInner: React.FC<TransferInnerProps> = (props) => {
const [page, setPage] = useState(1); const renderAllLabels = (info: {
const { onPageChange, total, perPage = 30 } = props; selectedCount: number;
totalCount: number;
const handleOnPageChange = (page: number, perPage?: number) => { }) => {
setPage(page); if (info.selectedCount) {
onPageChange?.(page, perPage);
};
const renderFooter = (TransferProps: any, { direction }: any) => {
if (direction === 'left' && total && total > perPage!) {
return ( return (
<PaginationWrapper> <span style={{ color: 'var(--ant-color-text-secondary)' }}>
<Pagination {info.selectedCount} selected
simple={{ readOnly: true }} </span>
size="small"
total={total}
onChange={handleOnPageChange}
pageSize={perPage}
current={page}
showSizeChanger={false}
/>
</PaginationWrapper>
); );
} }
return null; return null;
}; };
return ( return (
<TransferWrap> <TransferWrap>
<Transfer <Transfer
{...props} {...props}
selectAllLabels={
props.selectAllLabels || [renderAllLabels, renderAllLabels]
}
selectionsIcon={ selectionsIcon={
<MoreOutlined style={{ fontSize: 14, marginBottom: 3 }} /> <MoreOutlined style={{ fontSize: 14, marginBottom: 3 }} />
} }
@@ -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 { queryModelsList } from '@/pages/llmodels/apis';
import { Form } from 'antd'; import { Divider, Form, Radio } from 'antd';
import { useEffect, useState } from 'react'; 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<ListItem> | null;
action: PageActionType;
}> = ({ currentData, action }) => {
const form = Form.useFormInstance(); 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 }[]>( const [modelList, setModelList] = useState<{ key: string; title: string }[]>(
[] []
); );
@@ -17,36 +35,53 @@ const AllowModelsForm: React.FC = () => {
const getModelList = async () => { const getModelList = async () => {
try { try {
const res = await queryModelsList(queryParams); const res = await queryModelsList(queryParams);
const options = res.items.map((item) => ({ const options = res.items.map((item) => item.name);
title: item.name, if (action === PageAction.EDIT && currentData) {
key: item.name const list = new Set([
})); ...options,
setModelList(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) {} } catch (error) {}
}; };
useEffect(() => { useEffect(() => {
getModelList(); getModelList();
}, []); }, [action, currentData]);
return ( return (
<Form.Item name="allowed_model_names"> <div>
<TransferInner <Divider></Divider>
dataSource={modelList} <Label>Model Access</Label>
targetKeys={targetKeys} <Form.Item
onChange={(nextTargetKeys) => { name="allowed_type"
form.setFieldsValue({ allowed_model_names: nextTargetKeys }); initialValue="all"
}} style={{ marginBottom: 8 }}
render={(item) => item.title} >
titles={['Available Models', 'Allowed Models']} <Radio.Group
showSearch={{ options={[
placeholder: 'Filter by model name' { label: 'All models', value: 'all' },
}} { label: 'Selected models', value: 'custom' }
filterOption={(inputValue, item) => ]}
item.title.toLowerCase().includes(inputValue.toLowerCase()) ></Radio.Group>
} </Form.Item>
></TransferInner> <Form.Item name="allowed_model_names" hidden={allowedType === 'all'}>
</Form.Item> <SelectPanel
height={300}
searchPlaceholder="Search by model name"
options={modelList}
selectedKeys={allowedModelNames || []}
onSelectChange={(selectedKeys) => {
form.setFieldsValue({ allowed_model_names: selectedKeys });
}}
/>
</Form.Item>
</div>
); );
}; };
@@ -1,13 +1,18 @@
import SealInput from '@/components/seal-form/seal-input'; import SealInput from '@/components/seal-form/seal-input';
import SealSelect from '@/components/seal-form/seal-select'; import SealSelect from '@/components/seal-form/seal-select';
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Form } from 'antd'; import { Form } from 'antd';
import React from 'react'; import React from 'react';
import { expirationOptions } from '../../config'; import { expirationOptions } from '../../config';
import { FormData } from '../../config/types'; import { FormData, ListItem } from '../../config/types';
import AllowModelsForm from './allow-models'; import AllowModelsForm from './allow-models';
const APIKeyForm: React.FC = () => { const APIKeyForm: React.FC<{
action: PageActionType;
currentData?: Partial<ListItem> | null;
}> = ({ action, currentData }) => {
const intl = useIntl(); const intl = useIntl();
return ( return (
@@ -27,6 +32,8 @@ const APIKeyForm: React.FC = () => {
]} ]}
> >
<SealInput.Input <SealInput.Input
trim
disabled={action === PageAction.EDIT}
label={intl.formatMessage({ id: 'common.table.name' })} label={intl.formatMessage({ id: 'common.table.name' })}
required required
></SealInput.Input> ></SealInput.Input>
@@ -48,18 +55,22 @@ const APIKeyForm: React.FC = () => {
]} ]}
> >
<SealSelect <SealSelect
disabled={action === PageAction.EDIT}
options={expirationOptions} options={expirationOptions}
label={intl.formatMessage({ id: 'apikeys.form.expiretime' })} label={intl.formatMessage({ id: 'apikeys.form.expiretime' })}
required required
></SealSelect> ></SealSelect>
</Form.Item> </Form.Item>
<AllowModelsForm></AllowModelsForm>
<Form.Item<FormData> name="description" rules={[{ required: false }]}> <Form.Item<FormData> name="description" rules={[{ required: false }]}>
<SealInput.TextArea <SealInput.TextArea
scaleSize={true} scaleSize={true}
label={intl.formatMessage({ id: 'common.table.description' })} label={intl.formatMessage({ id: 'common.table.description' })}
></SealInput.TextArea> ></SealInput.TextArea>
</Form.Item> </Form.Item>
<AllowModelsForm
currentData={currentData}
action={action}
></AllowModelsForm>
</> </>
); );
}; };
@@ -1,17 +1,18 @@
import CopyButton from '@/components/copy-button'; import CopyButton from '@/components/copy-button';
import ModalFooter from '@/components/modal-footer'; 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 SealInput from '@/components/seal-form/seal-input';
import { PageAction } from '@/config'; import { PageAction } from '@/config';
import { PageActionType } from '@/config/types'; import { PageActionType } from '@/config/types';
import ColumnWrapper from '@/pages/_components/column-wrapper';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Button, Form, Tag } from 'antd'; import { Button, Form, Tag } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import _ from 'lodash';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { createApisKey, updateApisKey } from '../../apis'; import { createApisKey, updateApisKey } from '../../apis';
import { expirationOptions } from '../../config'; import { expirationOptions } from '../../config';
import { FormData, ListItem } from '../../config/types'; import { FormData, ListItem } from '../../config/types';
import AllowModelsForm from './allow-models';
import APIKeyForm from './form'; import APIKeyForm from './form';
type AddModalProps = { type AddModalProps = {
@@ -37,25 +38,6 @@ const AddModal: React.FC<AddModalProps> = ({
const [apikeyValue, setAPIKeyValue] = useState(''); const [apikeyValue, setAPIKeyValue] = useState('');
const [loading, setLoading] = useState(false); 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 getExpireValue = (val: number | null) => {
const expires_in = val; const expires_in = val;
if (expires_in === -1) { if (expires_in === -1) {
@@ -74,6 +56,28 @@ const AddModal: React.FC<AddModalProps> = ({
return res; 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 createAPIKey = async (data: FormData) => {
const params = { const params = {
...data, ...data,
@@ -89,13 +93,22 @@ const AddModal: React.FC<AddModalProps> = ({
onOk(); onOk();
}; };
const handleOnOk = async (data: FormData) => { const handleOnOk = async (formdata: FormData) => {
try { try {
setLoading(true); setLoading(true);
const data = {
..._.omit(formdata, ['allowed_type']),
allowed_model_names:
formdata.allowed_type === 'all'
? []
: formdata.allowed_model_names || []
};
if (action === PageAction.CREATE) { if (action === PageAction.CREATE) {
await createAPIKey(data); await createAPIKey(data);
} else if (action === PageAction.EDIT && currentData?.id) { } else if (action === PageAction.EDIT && currentData?.id) {
await updateAPIKey(data); await updateAPIKey({
..._.omit(data, ['expires_in'])
});
} }
setLoading(false); setLoading(false);
} catch (error) { } catch (error) {
@@ -115,66 +128,115 @@ const AddModal: React.FC<AddModalProps> = ({
setShowKey(false); 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 ( return (
<ScrollerModal <GSDrawer
title={ title={
!showKey ? title : intl.formatMessage({ id: 'apikeys.title.save' }) !showKey ? title : intl.formatMessage({ id: 'apikeys.title.save' })
} }
open={open} open={open}
centered={true} onClose={onCancel}
onOk={handleSumit}
onCancel={onCancel}
afterOpenChange={handleAfterOpenChange} afterOpenChange={handleAfterOpenChange}
destroyOnHidden={true} destroyOnHidden={true}
closeIcon={false} closeIcon={false}
maskClosable={false} maskClosable={false}
keyboard={false} keyboard={false}
width={700} styles={{
styles={{}} body: {
footer={ height: 'calc(100vh - 57px)',
!showKey ? ( padding: '16px 0',
<ModalFooter overflowX: 'hidden'
onOk={handleSumit} },
onCancel={onCancel} content: {
loading={loading} borderRadius: '6px 0 0 6px'
></ModalFooter> }
) : ( }}
<Button type="primary" onClick={handleDone}> width={600}
{intl.formatMessage({ id: 'common.button.done' })} footer={false}
</Button>
)
}
> >
<Form name="addAPIKey" form={form} onFinish={handleOnOk} preserve={false}> <ColumnWrapper
{action === PageAction.EDIT && <AllowModelsForm></AllowModelsForm>} styles={{
{!showKey && action === PageAction.CREATE && <APIKeyForm></APIKeyForm>} container: { paddingTop: 0 }
{showKey && action === PageAction.CREATE && ( }}
<Form.Item> footer={
<div> !showKey ? (
<Tag <ModalFooter
bordered={false} onOk={handleSumit}
color="error" onCancel={onCancel}
style={{ padding: '6px 8px', marginBottom: 16 }} loading={loading}
> style={{
{intl.formatMessage({ id: 'apikeys.table.save.tips' })} padding: '16px 24px',
</Tag> display: 'flex',
</div> justifyContent: 'flex-end'
<SealInput.Input }}
label={intl.formatMessage({ id: 'apikeys.form.apikey' })} ></ModalFooter>
value={apikeyValue} ) : (
addAfter={ <Button type="primary" onClick={handleDone}>
<CopyButton {intl.formatMessage({ id: 'common.button.done' })}
text={apikeyValue} </Button>
shape="default" )
size="middle" }
type="text" >
></CopyButton> <Form
} name="addAPIKey"
></SealInput.Input> form={form}
</Form.Item> onFinish={handleOnOk}
)} preserve={false}
</Form> >
</ScrollerModal> {!showKey && (
<APIKeyForm action={action} currentData={currentData}></APIKeyForm>
)}
{showKey && action === PageAction.CREATE && (
<Form.Item>
<div>
<Tag
bordered={false}
color="error"
style={{ padding: '6px 8px', marginBottom: 16 }}
>
{intl.formatMessage({ id: 'apikeys.table.save.tips' })}
</Tag>
</div>
<SealInput.Input
label={intl.formatMessage({ id: 'apikeys.form.apikey' })}
value={apikeyValue}
addAfter={
<CopyButton
text={apikeyValue}
shape="default"
size="middle"
type="text"
></CopyButton>
}
></SealInput.Input>
</Form.Item>
)}
</Form>
</ColumnWrapper>
</GSDrawer>
); );
}; };
+1
View File
@@ -11,6 +11,7 @@ export interface ListItem {
export interface FormData { export interface FormData {
name: string; name: string;
allowed_type: 'all' | 'custom';
description: string; description: string;
allowed_model_names: string[]; allowed_model_names: string[];
expires_in: number | null; expires_in: number | null;
@@ -16,7 +16,7 @@ interface ColumnsHookProps {
const actionList: Global.ActionItem[] = [ const actionList: Global.ActionItem[] = [
{ {
label: 'Edit Allowed Models', label: 'common.button.edit',
key: 'edit', key: 'edit',
icon: icons.EditOutlined icon: icons.EditOutlined
}, },
+1 -1
View File
@@ -58,7 +58,7 @@ const APIKeys: React.FC = () => {
const handleEditKey = (record: ListItem) => { const handleEditKey = (record: ListItem) => {
setOpenAddModal({ setOpenAddModal({
open: true, open: true,
title: 'Edit Allowed Models', title: 'Edit API Key',
action: PageAction.EDIT, action: PageAction.EDIT,
currentData: record currentData: record
}); });
@@ -1,7 +1,6 @@
import CheckboxField from '@/components/seal-form/checkbox-field';
import TransferInner from '@/pages/_components/transfer'; import TransferInner from '@/pages/_components/transfer';
import { queryUsersList } from '@/pages/users/apis'; import { queryUsersList } from '@/pages/users/apis';
import { Form } from 'antd'; import { Empty, Form, Radio } from 'antd';
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'; import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { queryModelAccessUserList } from '../../apis'; import { queryModelAccessUserList } from '../../apis';
@@ -11,9 +10,9 @@ type TransferKey = string | number | bigint;
const Label = styled.div` const Label = styled.div`
font-weight: 500; font-weight: 500;
margin-bottom: 16px; margin-block: 8px 12px;
margin-top: 16px;
font-size: 14px; font-size: 14px;
color: var(--ant-color-text-tertiary);
`; `;
interface AccessControlFormProps { interface AccessControlFormProps {
@@ -40,7 +39,8 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
const res = await queryUsersList(query); const res = await queryUsersList(query);
const options = res.items.map((item) => ({ const options = res.items.map((item) => ({
title: item.username, title: item.username,
key: item.id key: item.id,
is_admin: item.is_admin
})); }));
console.log('options', options); console.log('options', options);
setTotalPages(res.pagination.totalPage); setTotalPages(res.pagination.totalPage);
@@ -86,7 +86,7 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
const keys = res.items.map((item) => item.id); const keys = res.items.map((item) => item.id);
setTargetKeys(keys); setTargetKeys(keys);
form.setFieldsValue({ form.setFieldsValue({
set_public: res.items.length > 0, set_public: currentData.public,
users: res.items.map((item) => ({ id: item.id })) users: res.items.map((item) => ({ id: item.id }))
}); });
}); });
@@ -105,43 +105,56 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
clearOnDestroy={true} clearOnDestroy={true}
scrollToFirstError={true} scrollToFirstError={true}
initialValues={{ initialValues={{
public: true set_public: true
}} }}
> >
<Form.Item<AccessControlFormData> <Label>Access Scope</Label>
valuePropName="checked" <Form.Item<AccessControlFormData> name="set_public" noStyle>
name="set_public" <Radio.Group
style={{ marginBottom: 24, paddingLeft: 6 }} style={{ marginBottom: 12 }}
> options={[
<CheckboxField { label: 'All users', value: true },
description="Only authorized users can access" { label: 'Selected users', value: false }
label={'Restricted'} ]}
></CheckboxField> ></Radio.Group>
</Form.Item> </Form.Item>
{setPublic && ( {!setPublic && (
<> <>
<Label>User Select</Label> <Label>User Selection</Label>
<Form.Item<AccessControlFormData> <Form.Item<AccessControlFormData> name="users">
name="users"
rules={[
{
required: true,
message: 'Please select at least one user'
}
]}
>
<TransferInner <TransferInner
total={100}
dataSource={userList} dataSource={userList}
targetKeys={targetKeys} targetKeys={targetKeys}
showSelectAll
pagination={false} pagination={false}
titles={['Available Users', 'Users with Access']} titles={['All Users', 'Selected Users']}
locale={{
notFoundContent: [
<Empty
key="all"
description="No users found"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>,
<Empty
key="selected"
description="No users selected"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
]
}}
showSearch={{ showSearch={{
placeholder: 'Filter by username' placeholder: 'Filter by username'
}} }}
render={(item) => item.title} filterOption={(inputValue, item) =>
selectAllLabels={[]} item.title.toLowerCase().includes(inputValue.toLowerCase())
}
render={(item) => (
<span className="flex-center gap-8">
<span>{item.title}</span>
<span className="text-tertiary">
{item.is_admin ? '(Admin)' : ''}
</span>
</span>
)}
onSearch={onSearch} onSearch={onSearch}
onChange={handleOnChange} onChange={handleOnChange}
/> />
@@ -18,11 +18,10 @@ const AccessControlModal: React.FC<
}; };
const handleOnFinish = async (values: AccessControlFormData) => { const handleOnFinish = async (values: AccessControlFormData) => {
console.log('onFinish', values);
try { try {
const data = { const data = {
set_public: !values.set_public, set_public: values.set_public,
users: values.users || [] users: values.set_public ? [] : values.users || []
}; };
await updateModelAccessUser({ await updateModelAccessUser({
id: currentData?.id as number, id: currentData?.id as number,
+1 -1
View File
@@ -467,7 +467,7 @@ const Models: React.FC<ModelsProps> = ({
if (val === 'accessControl') { if (val === 'accessControl') {
setOpenAccessControlModal({ setOpenAccessControlModal({
title: 'Edit Access Control', title: 'Access Settings',
action: PageAction.EDIT, action: PageAction.EDIT,
currentData: row, currentData: row,
open: true open: true
+1 -1
View File
@@ -62,7 +62,7 @@ export const ActionList: ActionItem[] = [
icon: icons.ApiOutlined icon: icons.ApiOutlined
}, },
{ {
label: 'Access Control', label: 'Access Settings',
key: 'accessControl', key: 'accessControl',
icon: icons.Private icon: icons.Private
}, },
+1
View File
@@ -24,6 +24,7 @@ export interface ListItem {
local_path?: string; local_path?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
public?: boolean;
gpu_selector?: { gpu_selector?: {
gpu_ids: string[]; gpu_ids: string[];
}; };