dark
7 months ago
9 changed files with 488 additions and 65 deletions
-
93src/components/department-tree/DepartmentTree.tsx
-
0src/components/department-tree/index.ts
-
56src/components/department-tree/style.ts
-
17src/components/empty/EmptyWrap.tsx
-
1src/components/empty/index.ts
-
22src/components/user-picker/Item.tsx
-
81src/components/user-picker/List.tsx
-
205src/components/user-picker/UserPicker.tsx
-
78src/components/user-picker/style.ts
@ -0,0 +1,93 @@ |
|||
import { usePageStoreOptions } from '@/store' |
|||
import { Empty, Spin, Tree, TreeDataNode, TreeProps } from 'antd' |
|||
import { useStyle } from './style.ts' |
|||
import { useTranslation } from '@/i18n.ts' |
|||
import { departTreeAtom, } from '@/store/department.ts' |
|||
import { useAtomValue } from 'jotai' |
|||
import { useRef } from 'react' |
|||
import { flattenTree } from '@/utils' |
|||
import { useDeepCompareEffect } from 'react-use' |
|||
import { IDepartment } from '@/types/department' |
|||
|
|||
export interface DepartmentTreeProps extends TreeProps { |
|||
root?: TreeDataNode | boolean | string |
|||
} |
|||
|
|||
function getTopDataNode(root?: TreeDataNode | boolean | string, fieldNames?: TreeProps['fieldNames']) { |
|||
if (!root) { |
|||
return null |
|||
} |
|||
fieldNames = { key: 'key', title: 'title', ...fieldNames } |
|||
let data: TreeDataNode | null = {} as TreeDataNode |
|||
data[fieldNames.key!] = '' |
|||
|
|||
switch (typeof root) { |
|||
case 'boolean': { |
|||
if (!root) { |
|||
return null |
|||
} |
|||
data[fieldNames.title!] = '所有' |
|||
break |
|||
} |
|||
case 'string': { |
|||
data[fieldNames.title!] = root |
|||
break |
|||
} |
|||
case 'object': { |
|||
data = root |
|||
break |
|||
} |
|||
default: { |
|||
data = null |
|||
} |
|||
} |
|||
return data |
|||
} |
|||
|
|||
export const DepartmentTree = ({ root, ...props }: DepartmentTreeProps) => { |
|||
|
|||
const { styles } = useStyle() |
|||
const { t } = useTranslation() |
|||
const { data = [], isLoading } = useAtomValue(departTreeAtom, usePageStoreOptions()) |
|||
const flattenMenusRef = useRef<IDepartment[]>([]) |
|||
|
|||
useDeepCompareEffect(() => { |
|||
|
|||
if (isLoading) return |
|||
|
|||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|||
// @ts-ignore array
|
|||
if (data.length) { |
|||
// @ts-ignore flattenTree
|
|||
flattenMenusRef.current = flattenTree<IDepartment[]>(data as any) |
|||
} |
|||
|
|||
}, [ data, isLoading ]) |
|||
|
|||
|
|||
const renderEmpty = () => { |
|||
if ((data as any).length > 0 || isLoading) return null |
|||
return <Empty description={t('message.emptyDataAdd', '暂无数据,点击添加')}/> |
|||
} |
|||
const topData = getTopDataNode(root, props.fieldNames) |
|||
const treeData = topData ? [ topData, ...data as Array<any> ] : data |
|||
|
|||
return (<> |
|||
<Spin spinning={isLoading} style={{ minHeight: 200 }}> |
|||
{ |
|||
renderEmpty() |
|||
} |
|||
<Tree.DirectoryTree |
|||
className={styles.tree} |
|||
treeData={treeData as any} |
|||
defaultExpandAll={true} |
|||
// checkable={true}
|
|||
showIcon={false} |
|||
{...props} |
|||
/> |
|||
</Spin> |
|||
</> |
|||
) |
|||
} |
|||
|
|||
export default DepartmentTree |
@ -0,0 +1,56 @@ |
|||
import { createStyles } from '@/theme' |
|||
|
|||
export const useStyle = createStyles(({ token, css, cx, prefixCls }) => { |
|||
const prefix = `${prefixCls}-${token?.proPrefix}-department-tree` |
|||
|
|||
|
|||
const tree = css`
|
|||
.ant-tree { |
|||
overflow: auto; |
|||
height: 100%; |
|||
border-right: 1px solid ${token.colorBorder}; |
|||
background: ${token.colorBgContainer}; |
|||
|
|||
} |
|||
|
|||
.ant-tree-directory .ant-tree-treenode-selected::before { |
|||
background: ${token.colorBgTextHover}; |
|||
} |
|||
|
|||
.ant-tree-treenode:before { |
|||
border-radius: ${token.borderRadius}px; |
|||
} |
|||
`
|
|||
|
|||
const treeNode = css`
|
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
|
|||
.actions { |
|||
display: none; |
|||
padding: 0 10px; |
|||
} |
|||
|
|||
&:hover .actions { { |
|||
display: flex; |
|||
} |
|||
|
|||
`
|
|||
const treeActions = css`
|
|||
padding: 0 24px 16px; |
|||
display: flex; |
|||
flex-direction: column; |
|||
position: sticky; |
|||
bottom: 0; |
|||
z-index: 10; |
|||
background: ${token.colorBgContainer}; |
|||
`
|
|||
|
|||
return { |
|||
container: cx(prefix), |
|||
tree, |
|||
treeNode, |
|||
treeActions |
|||
} |
|||
}) |
@ -0,0 +1,17 @@ |
|||
import { Empty, EmptyProps } from 'antd' |
|||
|
|||
export interface EmptyWrapProps extends EmptyProps { |
|||
//用于判断是否为空
|
|||
isEmpty: boolean |
|||
} |
|||
|
|||
const EmptyWrap = ({ isEmpty, children, ...props }: EmptyWrapProps) => { |
|||
if (!isEmpty) { |
|||
return children |
|||
} |
|||
return ( |
|||
<Empty {...props} style={{ marginTop: 20}} /> |
|||
) |
|||
} |
|||
|
|||
export default EmptyWrap |
@ -0,0 +1 @@ |
|||
export * from './EmptyWrap' |
@ -1,22 +0,0 @@ |
|||
import { IUser } from '@/types/user' |
|||
import { useAtom } from 'jotai' |
|||
import { useStyle } from './style.ts' |
|||
import { Checkbox } from 'antd' |
|||
|
|||
export const Item = ( props: { |
|||
value: IUser, |
|||
onChange:( value: IUser)=>void |
|||
} )=>{ |
|||
|
|||
const { styles } = useStyle() |
|||
|
|||
|
|||
return ( |
|||
<div className={styles.listItem}> |
|||
<Checkbox value={props.value} onChange={e=>}> |
|||
<span>{props.value.name}</span> |
|||
</Checkbox> |
|||
</div> |
|||
) |
|||
|
|||
} |
@ -0,0 +1,81 @@ |
|||
import { useStyle } from './style.ts' |
|||
import { Avatar, Button, Checkbox, CheckboxProps, Radio } from 'antd' |
|||
import { createContext, useContext } from 'react' |
|||
import { IUser } from '@/types' |
|||
import { DeleteOutlined } from '@ant-design/icons' |
|||
|
|||
export const ListItem = (props: CheckboxProps) => { |
|||
|
|||
const { styles, cx } = useStyle() |
|||
const ctx = useContext(ListContext) |
|||
|
|||
const Com = ctx?.multiple ? Checkbox : Radio |
|||
const selected = ctx?.multiple ? ctx?.value?.includes(props.value) : ctx?.value == props.value |
|||
return ( |
|||
<div className={cx(styles.listItem, { |
|||
['selected']: selected |
|||
})} |
|||
> |
|||
<Com {...props} |
|||
checked={selected}> |
|||
<span>{props?.name}</span> |
|||
</Com> |
|||
</div> |
|||
) |
|||
} |
|||
|
|||
export const ListViewItem = ({ user, onDel }: { user: IUser, onDel: (user: IUser) => void }) => { |
|||
const { styles, cx, theme } = useStyle() |
|||
if (!user) { |
|||
return null |
|||
} |
|||
return ( |
|||
<div className={cx(styles.listItem, 'view')}> |
|||
<Avatar style={{ backgroundColor: `${theme.colorPrimary}`, verticalAlign: 'middle' }} size="small"> |
|||
{user.name?.charAt(0)} |
|||
</Avatar> |
|||
<span>{user.name}</span> |
|||
<span className={'del'}><Button onClick={() => onDel?.(user)} icon={<DeleteOutlined/>} type={'primary'} |
|||
size={'small'} danger={true} shape={'circle'}/> </span> |
|||
</div> |
|||
) |
|||
} |
|||
|
|||
|
|||
interface ListProps<T> { |
|||
dataSource: T[] |
|||
value?: any[] |
|||
onChange?: (value: any[]) => void |
|||
rowKey: string |
|||
multiple?: boolean |
|||
} |
|||
|
|||
const ListContext = createContext<ListProps<any> | undefined>(undefined) |
|||
|
|||
export const List = (props: ListProps<any>) => { |
|||
const { styles } = useStyle() |
|||
const { value, onChange, } = props |
|||
return ( |
|||
<ListContext.Provider value={props}> |
|||
<div className={styles.list}> |
|||
{ |
|||
props.dataSource?.map?.((item: any) => { |
|||
return <ListItem key={item.id} |
|||
{...item} |
|||
value={item.id} |
|||
onChange={e => { |
|||
const checked = e.target.checked |
|||
let val = value |
|||
if (props.multiple) { |
|||
val = checked ? [ ...(value || []), item.id ] : (value || []).filter(i => i !== item.id) |
|||
} else { |
|||
val = checked ? [ item.id ] : [] |
|||
} |
|||
onChange?.(val) |
|||
}}/> |
|||
}) |
|||
} |
|||
</div> |
|||
</ListContext.Provider> |
|||
) |
|||
} |
@ -1,61 +1,190 @@ |
|||
import { Modal, ModalProps, Select, SelectProps } from 'antd' |
|||
import { memo } from 'react' |
|||
import { Button, Input, Modal, ModalProps, Select, SelectProps, Spin } from 'antd' |
|||
import { memo, ReactNode, useEffect, useRef, useState } from 'react' |
|||
import { Flexbox } from 'react-layout-kit' |
|||
import { useStyle } from './style.ts' |
|||
import { List, ListViewItem } from './List.tsx' |
|||
import { useTranslation } from '@/i18n.ts' |
|||
import DepartmentTree from '@/components/department-tree/DepartmentTree.tsx' |
|||
import { DraggablePanel } from '@/components/draggable-panel' |
|||
import { useAtom, useAtomValue } from 'jotai' |
|||
import { userListAtom, userSearchAtom, } from '@/store/user.ts' |
|||
import { IUser } from '@/types' |
|||
import EmptyWrap from '@/components/empty/EmptyWrap.tsx' |
|||
|
|||
export interface UserSelectProps extends SelectProps { |
|||
|
|||
value?: any[] |
|||
onChange?: (value: any[]) => void |
|||
//多选
|
|||
multiple?: boolean |
|||
} |
|||
|
|||
export interface UserModelProps extends ModalProps { |
|||
export interface UserModelProps extends Pick<ModalProps, 'open'> { |
|||
value?: any[] |
|||
onChange?: (value?: any[]) => void |
|||
children?: ReactNode |
|||
//多选
|
|||
multiple?: boolean |
|||
|
|||
} |
|||
|
|||
export type UserPickerProps = |
|||
| { |
|||
type?: 'modal'; |
|||
/** Props for the modal component */ |
|||
| { |
|||
type?: 'modal'; |
|||
/** Props for the modal component */ |
|||
} & UserModelProps |
|||
| { |
|||
type: 'select'; |
|||
/** Props for the select component */ |
|||
| { |
|||
type: 'select'; |
|||
/** Props for the select component */ |
|||
} & UserSelectProps |
|||
|
|||
const UserSelect = memo((props: UserSelectProps) => { |
|||
console.log(props) |
|||
return ( |
|||
<Select {...props}> |
|||
|
|||
</Select> |
|||
) |
|||
}) |
|||
|
|||
const UserModel = memo(({ open, ...props }: UserModelProps) => { |
|||
const { styles } = useStyle() |
|||
|
|||
return ( |
|||
<Select {...props}> |
|||
|
|||
return ( |
|||
<Modal |
|||
open={open} |
|||
{...props} |
|||
> |
|||
<Flexbox horizontal={true} className={styles.container}> |
|||
<Flexbox> |
|||
|
|||
</Flexbox> |
|||
<Flexbox> |
|||
|
|||
</Flexbox> |
|||
</Flexbox> |
|||
|
|||
</Modal> |
|||
) |
|||
</Select> |
|||
) |
|||
}) |
|||
|
|||
const UserModel = memo(({ multiple, children, value, onChange, ...props }: UserModelProps) => { |
|||
const { styles, cx } = useStyle() |
|||
const { t } = useTranslation() |
|||
const [ innerValue, setValue ] = useState(() => { |
|||
|
|||
if (!multiple && !Array.isArray(value)) { |
|||
return [ value ] |
|||
} |
|||
}) |
|||
const [ , setSearch ] = useAtom(userSearchAtom) |
|||
const { data: users, isPending } = useAtomValue(userListAtom) |
|||
const [ open, setOpen ] = useState(false) |
|||
const selectUserRef = useRef<IUser[]>([]) |
|||
const [, update ] = useState({}) |
|||
|
|||
useEffect(() => { |
|||
if (value === undefined) return |
|||
|
|||
setValue(Array.isArray(value) ? value : [ value ]) |
|||
}, [ value ]) |
|||
|
|||
useEffect(() => { |
|||
|
|||
selectUserRef.current = [] |
|||
innerValue?.forEach(id => { |
|||
const item = users?.rows?.find(user => user.id === id) |
|||
if (item) { |
|||
selectUserRef.current = [ ...selectUserRef.current, item ] |
|||
} |
|||
}) |
|||
update({}) |
|||
|
|||
}, [ innerValue ]) |
|||
|
|||
useEffect(() => { |
|||
return () => { |
|||
selectUserRef.current = [] |
|||
} |
|||
|
|||
}, []) |
|||
|
|||
const renderTarget = () => { |
|||
return <span onClick={() => setOpen(true)}> |
|||
{children ?? <Button type={'primary'}>{t('component.UserPicker.targetText', '选择成员')}</Button>} |
|||
</span> |
|||
} |
|||
|
|||
return ( |
|||
<> |
|||
{renderTarget()} |
|||
<Modal |
|||
className={styles.modal} |
|||
title={t('component.UserPicker.title', '成员选择')} |
|||
width={800} |
|||
{...props} |
|||
open={open} |
|||
onOk={() => { |
|||
onChange?.(multiple ? innerValue : innerValue?.[0]) |
|||
setOpen(false) |
|||
}} |
|||
onCancel={() => { |
|||
setOpen(false) |
|||
}} |
|||
> |
|||
<Flexbox horizontal={true} className={styles.container} gap={8} height={400}> |
|||
<Flexbox flex={5} gap={10}> |
|||
<Input.Search |
|||
allowClear={true} |
|||
onSearch={value => { |
|||
setSearch({ |
|||
key: value, |
|||
}) |
|||
}} |
|||
placeholder={t('component.UserPicker.placeholder', '输入成员姓名,回车查询')}/> |
|||
<Flexbox flex={1} horizontal={true} className={styles.bordered}> |
|||
<Flexbox flex={1} padding={4}> |
|||
<DepartmentTree |
|||
root={true} |
|||
fieldNames={{ |
|||
title: 'name', |
|||
key: 'id' |
|||
}} |
|||
autoExpandParent={true} |
|||
onSelect={(keys) => { |
|||
setSearch({ |
|||
dept_id: keys?.[0] |
|||
}) |
|||
}} |
|||
/> |
|||
</Flexbox> |
|||
<Flexbox flex={1} className={styles.usersPanel}> |
|||
<Spin spinning={isPending} delay={200}> |
|||
<EmptyWrap isEmpty={!users?.rows || users?.rows.length === 0}> |
|||
<List rowKey={'id'} |
|||
multiple={multiple} |
|||
value={innerValue} |
|||
onChange={(val) => { |
|||
setValue(val) |
|||
}} |
|||
dataSource={users?.rows ?? []} |
|||
/> |
|||
</EmptyWrap> |
|||
</Spin> |
|||
</Flexbox> |
|||
</Flexbox> |
|||
</Flexbox> |
|||
<DraggablePanel expandable={false} |
|||
placement="right" |
|||
maxWidth={300} |
|||
minWidth={280} |
|||
className={cx(styles.bordered, styles.draggablePanel)} |
|||
> |
|||
<Flexbox flex={6 / 2}> |
|||
<div className={styles.selected}>{t('component.UserPicker.selected', '已选({{count}})', { count: selectUserRef.current?.length ?? 0 })}</div> |
|||
<div> |
|||
{ |
|||
selectUserRef.current?.map(user => { |
|||
return (<ListViewItem |
|||
onDel={user => { |
|||
setValue(innerValue?.filter(id => id !== user.id)) |
|||
}} |
|||
key={user.id} |
|||
user={user!}/>) |
|||
}) |
|||
} |
|||
</div> |
|||
</Flexbox> |
|||
</DraggablePanel> |
|||
|
|||
</Flexbox> |
|||
</Modal> |
|||
</> |
|||
|
|||
) |
|||
}) |
|||
|
|||
const UserPicker = memo(({ type, ...props }: UserPickerProps) => { |
|||
|
|||
return type === 'modal' ? <UserModel {...props} /> : <UserSelect {...props as UserSelectProps} /> |
|||
const UserPicker = memo(({ type = 'modal', ...props }: UserPickerProps) => { |
|||
return type === 'modal' ? <UserModel {...props as UserModelProps} /> : <UserSelect {...props as UserSelectProps} /> |
|||
}) |
|||
|
|||
export default UserPicker |
Write
Preview
Loading…
Cancel
Save
Reference in new issue