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 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 ( |
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> |
</PageContainer> |
||||
) |
) |
||||
} |
} |
||||
|
|
||||
export const Route = createLazyFileRoute("/system/departments")({ |
|
||||
component: Departments |
|
||||
}) |
|
||||
|
|
||||
export default 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