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 { Flexbox } from 'react-layout-kit' |
||||
import { useStyle } from './style.ts' |
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 { |
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 = |
export type UserPickerProps = |
||||
| { |
|
||||
type?: 'modal'; |
|
||||
/** Props for the modal component */ |
|
||||
|
| { |
||||
|
type?: 'modal'; |
||||
|
/** Props for the modal component */ |
||||
} & UserModelProps |
} & UserModelProps |
||||
| { |
|
||||
type: 'select'; |
|
||||
/** Props for the select component */ |
|
||||
|
| { |
||||
|
type: 'select'; |
||||
|
/** Props for the select component */ |
||||
} & UserSelectProps |
} & UserSelectProps |
||||
|
|
||||
const UserSelect = memo((props: 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 |
export default UserPicker |
Write
Preview
Loading…
Cancel
Save
Reference in new issue