dark
7 months ago
12 changed files with 476 additions and 46 deletions
-
80src/pages/system/departments/components/DepartmentTree.tsx
-
59src/pages/system/departments/components/TreeNodeRender.tsx
-
152src/pages/system/departments/index.tsx
-
89src/pages/system/departments/store.ts
-
72src/pages/system/departments/style.ts
-
2src/pages/system/menus/components/MenuTree.tsx
-
15src/pages/system/menus/index.tsx
-
36src/pages/system/menus/style.ts
-
5src/pages/system/roles/index.tsx
-
4src/pages/system/users/index.tsx
-
6src/service/system.ts
-
2src/types/department.d.ts
@ -0,0 +1,80 @@ |
|||
import { Empty, Spin, Tree } from 'antd' |
|||
import { useStyle } from '../style.ts' |
|||
import { useTranslation } from '@/i18n.ts' |
|||
import { useSetAtom } from 'jotai' |
|||
import { useDepartStore } from '../store.ts' |
|||
import { FormInstance } from 'antd/lib' |
|||
import { useAtomValue } from 'jotai/index' |
|||
import { TreeNodeRender } from './TreeNodeRender.tsx' |
|||
import { useRef } from 'react' |
|||
import { flattenTree } from '@/utils' |
|||
import { useDeepCompareEffect } from 'react-use' |
|||
import { IDepartment } from '@/types/department' |
|||
|
|||
export const DepartmentTree = ({ form }: { form: FormInstance }) => { |
|||
|
|||
const { selectedDepartAtom, departTreeAtom, batchIdsAtom } = useDepartStore() |
|||
const { styles } = useStyle() |
|||
const { t } = useTranslation() |
|||
const setIds = useSetAtom(batchIdsAtom) |
|||
const setCurrent = useSetAtom(selectedDepartAtom) |
|||
const { data = [], isLoading } = useAtomValue(departTreeAtom) |
|||
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) |
|||
// console.log(flattenMenusRef.current)
|
|||
} |
|||
|
|||
return () => { |
|||
setCurrent({} as IDepartment) |
|||
} |
|||
|
|||
}, [ data, isLoading ]) |
|||
|
|||
const renderEmpty = () => { |
|||
if ((data as any).length > 0 || isLoading) return null |
|||
return <Empty description={t('message.emptyDataAdd', '暂无数据,点击添加')}/> |
|||
} |
|||
|
|||
return (<> |
|||
<Spin spinning={isLoading} style={{ minHeight: 200 }}> |
|||
{ |
|||
renderEmpty() |
|||
} |
|||
<Tree.DirectoryTree |
|||
className={styles.tree} |
|||
treeData={data as any} |
|||
defaultExpandAll={true} |
|||
// draggable={true}
|
|||
titleRender={(node) => { |
|||
return (<TreeNodeRender node={node as any} form={form}/>) |
|||
}} |
|||
fieldNames={{ |
|||
title: 'name', |
|||
key: 'id' |
|||
}} |
|||
onSelect={(item) => { |
|||
const current = flattenMenusRef.current?.find((menu) => menu.id === item[0]) |
|||
setCurrent(current as IDepartment) |
|||
form.setFieldsValue({ ...current }) |
|||
}} |
|||
onCheck={(item) => { |
|||
setIds(item as number[]) |
|||
}} |
|||
// checkable={true}
|
|||
showIcon={false} |
|||
/> |
|||
</Spin> |
|||
</> |
|||
) |
|||
} |
|||
|
|||
export default DepartmentTree |
@ -0,0 +1,59 @@ |
|||
import { memo } from 'react' |
|||
import { MenuItem } from '@/types' |
|||
import { Popconfirm, Space, TreeDataNode } from 'antd' |
|||
import { FormInstance } from 'antd/lib' |
|||
import { useTranslation } from '@/i18n.ts' |
|||
import { useStyle } from '../style.ts' |
|||
import { useAtomValue, useSetAtom } from 'jotai/index' |
|||
import { useDepartStore } from '../store.ts' |
|||
import { PlusOutlined } from '@ant-design/icons' |
|||
import ActionIcon, { DeleteAction } from '@/components/icon/action' |
|||
|
|||
export const TreeNodeRender = memo(({ node, form }: { node: MenuItem & TreeDataNode, form: FormInstance }) => { |
|||
const { name } = node |
|||
const { t } = useTranslation() |
|||
const { styles } = useStyle() |
|||
const { selectedDepartAtom, deleteDepartAtom, defaultDepart } = useDepartStore() |
|||
const { mutate } = useAtomValue(deleteDepartAtom) |
|||
|
|||
const setCurrent = useSetAtom(selectedDepartAtom) |
|||
|
|||
return ( |
|||
<div className={styles.treeNode}> |
|||
<span>{name as any}</span> |
|||
<span className={'actions'}> |
|||
<Space size={'middle'}> |
|||
<ActionIcon |
|||
size={12} |
|||
icon={<PlusOutlined/>} |
|||
title={t('actions.add', '添加')} |
|||
onClick={(e) => { |
|||
// console.log('add')
|
|||
e.stopPropagation() |
|||
e.preventDefault() |
|||
const data = { |
|||
...defaultDepart, |
|||
parent_id: node.id, |
|||
} |
|||
setCurrent(data) |
|||
form.setFieldsValue(data) |
|||
|
|||
}}/> |
|||
<Popconfirm |
|||
title={t('message.deleteConfirm', '确定要删除吗?')} |
|||
onConfirm={() => { |
|||
mutate([ (node as any).id ]) |
|||
}} |
|||
> |
|||
<DeleteAction |
|||
size={12} |
|||
onClick={(e) => { |
|||
e.stopPropagation() |
|||
e.stopPropagation() |
|||
}}/> |
|||
</Popconfirm> |
|||
</Space> |
|||
</span> |
|||
</div> |
|||
) |
|||
}) |
@ -1,16 +1,156 @@ |
|||
import { PageContainer } from '@ant-design/pro-components' |
|||
import { createLazyFileRoute } from '@tanstack/react-router' |
|||
import { PageContainer, ProCard } from '@ant-design/pro-components' |
|||
import { Flexbox } from 'react-layout-kit' |
|||
import { DraggablePanel } from '@/components/draggable-panel' |
|||
import { useTranslation } from '@/i18n.ts' |
|||
import { useStyle } from './style.ts' |
|||
import DepartmentTree from './components/DepartmentTree.tsx' |
|||
import { Alert, Button, Divider, Form, Input, InputNumber, InputRef, notification, TreeSelect } from 'antd' |
|||
import { PlusOutlined } from '@ant-design/icons' |
|||
import { useDepartStore } from './store.ts' |
|||
import { useAtom, useAtomValue, } from 'jotai' |
|||
import Glass from '@/components/glass' |
|||
import { useEffect, useRef } from 'react' |
|||
|
|||
const Departments = () => { |
|||
|
|||
const { t } = useTranslation() |
|||
const { styles, cx } = useStyle() |
|||
const [ form ] = Form.useForm() |
|||
const inputRef = useRef<InputRef>() |
|||
const { defaultDepart, selectedDepartAtom, departTreeAtom, saveOrUpdateDepartAtom } = useDepartStore() |
|||
const { data } = useAtomValue(departTreeAtom) |
|||
const { mutate, isPending, isError, error } = useAtomValue(saveOrUpdateDepartAtom) |
|||
const [ current, setCurrent ] = useAtom(selectedDepartAtom) |
|||
|
|||
useEffect(() => { |
|||
|
|||
if (isError) { |
|||
notification.error({ |
|||
message: t('message.error', '错误'), |
|||
description: (error as any).message ?? t('message.saveFail', '保存失败'), |
|||
}) |
|||
} |
|||
}, [ isError ]) |
|||
|
|||
useEffect(() => { |
|||
if (current.id === 0 && inputRef.current) { |
|||
inputRef.current.focus() |
|||
} |
|||
}, [ current ]) |
|||
|
|||
return ( |
|||
<PageContainer breadcrumbRender={false}> |
|||
<PageContainer breadcrumbRender={false} title={false} className={styles.container}> |
|||
<Flexbox horizontal> |
|||
<DraggablePanel expandable={false} |
|||
placement="left" |
|||
defaultSize={{ width: 300 }} |
|||
maxWidth={500} |
|||
style={{ position: 'relative' }} |
|||
> |
|||
<ProCard title={t('system.departments.title', '部门')}> |
|||
<DepartmentTree form={form}/> |
|||
</ProCard> |
|||
<div className={styles.treeActions}> |
|||
<Divider style={{ flex: 1, margin: '8px 0' }}/> |
|||
<Button style={{ flex: 1 }} size={'small'} |
|||
block={true} type={'dashed'} |
|||
icon={<PlusOutlined/>} |
|||
onClick={() => { |
|||
const data = { |
|||
...defaultDepart, |
|||
parent_id: current.id ?? 0, |
|||
} |
|||
setCurrent(data) |
|||
form.setFieldsValue(data) |
|||
}} |
|||
>{t('actions.news')}</Button> |
|||
</div> |
|||
</DraggablePanel> |
|||
<Flexbox className={styles.box}> |
|||
<Glass |
|||
enabled={current.id === undefined} |
|||
description={<> |
|||
<Alert |
|||
message={t('message.infoTitle', '提示')} |
|||
description={t('system.departments.form.empty', '请从左侧选择一行数据操作')} |
|||
type="info" |
|||
/> |
|||
</>} |
|||
> |
|||
|
|||
|
|||
<ProCard title={t('system.departments.setting', '编辑')}> |
|||
<Form form={form} |
|||
initialValues={current!} |
|||
labelAlign="left" |
|||
labelWrap |
|||
labelCol={{ span: 4 }} |
|||
wrapperCol={{ span: 12 }} |
|||
colon={false} |
|||
className={cx(styles.form, { |
|||
[styles.emptyForm]: current.id === undefined |
|||
})} |
|||
> |
|||
<Form.Item hidden={true} label={'id'} name={'id'}> |
|||
<Input disabled={true}/> |
|||
</Form.Item> |
|||
<Form.Item |
|||
rules={[ |
|||
{ required: true, message: t('rules.required') } |
|||
]} |
|||
label={t('system.departments.form.name', '部门名称')} name={'name'}> |
|||
<Input ref={inputRef as any} |
|||
placeholder={t('system.departments.form.name', '部门名称')}/> |
|||
</Form.Item> |
|||
<Form.Item label={t('system.departments.form.parent', '上级部门')} |
|||
name={'parent_id'}> |
|||
<TreeSelect |
|||
treeData={[ |
|||
{ id: 0, name: '顶级', children: data as any }, |
|||
]} |
|||
treeDefaultExpandAll={true} |
|||
fieldNames={{ |
|||
label: 'name', |
|||
value: 'id' |
|||
}}/> |
|||
</Form.Item> |
|||
|
|||
<Form.Item label={t('system.departments.form.sort', '排序')} name={'sort'}> |
|||
<InputNumber/> |
|||
</Form.Item> |
|||
<Form.Item label={t('system.departments.form.manager_user_id', '负责人')} |
|||
name={'path'}> |
|||
<Input/> |
|||
</Form.Item> |
|||
|
|||
<Form.Item label={t('system.departments.form.phone', '联系电话')} |
|||
name={'phone'} |
|||
> |
|||
<Input/> |
|||
</Form.Item> |
|||
<Form.Item label={' '}> |
|||
<Button type="primary" |
|||
htmlType={'submit'} |
|||
loading={isPending} |
|||
onClick={() => { |
|||
form.validateFields().then((values) => { |
|||
mutate(values) |
|||
}) |
|||
}} |
|||
> |
|||
{t('system.departments.form.save', '保存')} |
|||
</Button> |
|||
</Form.Item> |
|||
|
|||
</Form> |
|||
</ProCard> |
|||
|
|||
</Glass> |
|||
</Flexbox> |
|||
</Flexbox> |
|||
</PageContainer> |
|||
) |
|||
} |
|||
|
|||
export const Route = createLazyFileRoute("/system/departments")({ |
|||
component: Departments |
|||
}) |
|||
|
|||
export default Departments |
@ -0,0 +1,89 @@ |
|||
import { atomWithMutation, atomWithQuery, queryClientAtom } from 'jotai-tanstack-query' |
|||
import systemServ from '@/service/system.ts' |
|||
import { IApiResult, IPage } from '@/types' |
|||
import { IDepartment } from '@/types/department' |
|||
import { atom, createStore } from 'jotai' |
|||
import { t } from 'i18next' |
|||
import { message } from 'antd' |
|||
|
|||
|
|||
const store = createStore() |
|||
|
|||
const departPageAtom = atom<IPage>({}) |
|||
|
|||
const defaultDepart = { |
|||
id: 0, |
|||
parent_id: 0, |
|||
name: '', |
|||
manager_user_id: 0, |
|||
phone: '', |
|||
sort: 0, |
|||
} as IDepartment |
|||
|
|||
const batchIdsAtom = atom<number[]>([]) |
|||
|
|||
const selectedDepartAtom = atom<IDepartment>({} as IDepartment) |
|||
|
|||
const departTreeAtom = atomWithQuery(() => { |
|||
|
|||
return { |
|||
queryKey: [ 'departTree' ], |
|||
queryFn: async () => { |
|||
return await systemServ.dept.tree() |
|||
}, |
|||
select: (res) => { |
|||
return res.data.tree ?? [] |
|||
} |
|||
} |
|||
}) |
|||
|
|||
const saveOrUpdateDepartAtom = atomWithMutation<IApiResult, IDepartment>((get) => { |
|||
|
|||
return { |
|||
mutationKey: [ 'saveOrUpdateDepart', get(selectedDepartAtom) ], |
|||
mutationFn: async (data: IDepartment) => { |
|||
if (data.id) { |
|||
return await systemServ.dept.update(data) |
|||
} |
|||
return await systemServ.dept.add(data) |
|||
}, |
|||
onSuccess: (res) => { |
|||
const isAdd = !!res.data?.id |
|||
message.success(t(isAdd ? 'message.saveSuccess' : 'message.editSuccess', '保存成功')) |
|||
store.set(selectedDepartAtom, prev => ({ |
|||
...prev, |
|||
...(isAdd ? res.data : {}) |
|||
})) |
|||
get(queryClientAtom).invalidateQueries({ queryKey: [ 'departTree', get(departPageAtom) ] }).then() |
|||
} |
|||
} |
|||
|
|||
}) |
|||
|
|||
|
|||
const deleteDepartAtom = atomWithMutation<IApiResult, number[]>((get) => { |
|||
|
|||
return { |
|||
mutationKey: [ 'deleteDepart', get(batchIdsAtom) ], |
|||
mutationFn: async (ids: number[]) => { |
|||
return await systemServ.dept.batchDelete(ids) |
|||
}, |
|||
onSuccess: () => { |
|||
message.success(t('message.deleteSuccess', '删除成功')) |
|||
store.set(batchIdsAtom, []) |
|||
get(queryClientAtom).invalidateQueries({ queryKey: [ 'departTree', get(departPageAtom) ] }).then() |
|||
|
|||
} |
|||
} |
|||
|
|||
}) |
|||
|
|||
export const useDepartStore = () => ({ |
|||
defaultDepart, |
|||
departPageAtom, |
|||
selectedDepartAtom, |
|||
departTreeAtom, |
|||
deleteDepartAtom, |
|||
batchIdsAtom, |
|||
saveOrUpdateDepartAtom, |
|||
}) |
@ -0,0 +1,72 @@ |
|||
import { createStyles } from '@/theme' |
|||
|
|||
export const useStyle = createStyles(({ token, css, cx, prefixCls }) => { |
|||
const prefix = `${prefixCls}-${token?.proPrefix}-department-page` |
|||
|
|||
|
|||
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}; |
|||
`
|
|||
|
|||
const box = css`
|
|||
flex: 1; |
|||
background: ${token.colorBgContainer}; |
|||
`
|
|||
const form = css`
|
|||
//display: flex;
|
|||
//flex-wrap: wrap;
|
|||
min-width: 300px; |
|||
max-width: 800px; |
|||
`
|
|||
const emptyForm = css`
|
|||
`
|
|||
|
|||
return { |
|||
container: cx(prefix), |
|||
box, |
|||
form, |
|||
emptyForm, |
|||
tree, |
|||
treeNode, |
|||
treeActions |
|||
} |
|||
}) |
Write
Preview
Loading…
Cancel
Save
Reference in new issue