Browse Source

增加:成员选择器组件, 部门树组件,空数据包裹组件

main
dark 5 months ago
parent
commit
c292543182
  1. 93
      src/components/department-tree/DepartmentTree.tsx
  2. 0
      src/components/department-tree/index.ts
  3. 56
      src/components/department-tree/style.ts
  4. 17
      src/components/empty/EmptyWrap.tsx
  5. 1
      src/components/empty/index.ts
  6. 22
      src/components/user-picker/Item.tsx
  7. 81
      src/components/user-picker/List.tsx
  8. 205
      src/components/user-picker/UserPicker.tsx
  9. 78
      src/components/user-picker/style.ts

93
src/components/department-tree/DepartmentTree.tsx

@ -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
src/components/department-tree/index.ts

56
src/components/department-tree/style.ts

@ -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
}
})

17
src/components/empty/EmptyWrap.tsx

@ -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

1
src/components/empty/index.ts

@ -0,0 +1 @@
export * from './EmptyWrap'

22
src/components/user-picker/Item.tsx

@ -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>
)
}

81
src/components/user-picker/List.tsx

@ -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>
)
}

205
src/components/user-picker/UserPicker.tsx

@ -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

78
src/components/user-picker/style.ts

@ -7,19 +7,87 @@ export const useStyle = createStyles(({ token, css, cx, prefixCls }) => {
const listItem = css`
padding: 5px;
height: 40px;
background-color: ${token.colorBgContainer};
height: 40px;
background-color: ${token.colorBgContainer};
display: flex;
align-items: center;
cursor: pointer;
:hover {
background-color: ${token.controlItemBgActiveHover};
:first-child {
border-radius: 0 ${token.borderRadius}px 0 0;
}
:last-child && :not(:first-child) {
border-radius: 0 0 ${token.borderRadius}px 0;
}
:hover {
background-color: ${token.controlItemBgHover};
}
&.selected {
background-color: ${token.controlItemBgActive};
}
.ant-radio-wrapper,
.ant-checkbox-wrapper {
flex: 1;
padding: 5px 10px;
}
&.view {
padding: 0 15px;
gap: 5px;
position: relative;
.del {
position: absolute;
right: 15px;
display: none;
}
:hover .del {
display: block;
}
}
`
const modal = css`
.ant-modal-body {
max-height: 600px;
overflow: auto;
}
`
const usersPanel = css`
border-left: 1px solid ${token.colorBorder};
`
const bordered = css`
border: 1px solid ${token.colorBorder};
border-radius: ${token.borderRadius}px;
`
const draggablePanel = css`
.ant-pro-draggable-panel-fixed {
background: transparent;
}
`
const selected = css`
border-bottom: 1px solid ${token.colorBorder};
padding: 8px;
`
return {
container: cx(prefix),
modal,
usersPanel,
draggablePanel,
selected,
list,
listItem,
bordered,
}
})
Loading…
Cancel
Save