Browse Source

增加用户页面,登录日志页面

main
dark 5 months ago
parent
commit
efc54c60c7
  1. 1
      .gitignore
  2. 35
      src/components/cascader/Cascader.tsx
  3. 1
      src/components/cascader/index.ts
  4. 39
      src/components/department-tree/DepartmentCascader.tsx
  5. 17
      src/components/department-tree/DepartmentTree.tsx
  6. 2
      src/components/department-tree/index.ts
  7. 7
      src/components/error/404.tsx
  8. 4
      src/components/icon/index.tsx
  9. 2
      src/components/icon/picker/index.tsx
  10. 52
      src/components/role-picker/RolePicker.tsx
  11. 0
      src/components/role-picker/index.ts
  12. 4
      src/components/switch/index.tsx
  13. 20
      src/layout/TreePageLayout.tsx
  14. 39
      src/layout/TwoColPageLayout.tsx
  15. 12
      src/layout/style.ts
  16. 20
      src/pages/system/departments/index.tsx
  17. 9
      src/pages/system/departments/style.ts
  18. 144
      src/pages/system/logs/login/index.tsx
  19. 14
      src/pages/system/logs/login/style.ts
  20. 31
      src/pages/system/menus/index.tsx
  21. 14
      src/pages/system/roles/index.tsx
  22. 7
      src/pages/system/roles/style.ts
  23. 243
      src/pages/system/users/index.tsx
  24. 71
      src/pages/system/users/style.ts
  25. 19
      src/service/system.ts
  26. 58
      src/store/logs.ts
  27. 12
      src/store/route.ts
  28. 10
      src/store/system.ts
  29. 121
      src/store/user.ts
  30. 11
      src/types/logs.d.ts
  31. 1
      src/types/user.d.ts
  32. 37
      src/utils/tree.ts

1
.gitignore

@ -1,6 +1,7 @@
# Logs # Logs
logs logs
*.log *.log
!src/system/logs
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*

35
src/components/cascader/Cascader.tsx

@ -0,0 +1,35 @@
import { Cascader as AntCascader, CascaderProps as AntCascaderProps } from 'antd'
import { DefaultOptionType } from 'antd/es/cascader'
import { findValuePath } from '@/utils/tree.ts'
export type CascaderProps<T extends DefaultOptionType> = AntCascaderProps<T> & {
key?: string
}
const getValue = (options: DefaultOptionType[], value?: any, fieldNames?: any) => {
if (value === undefined || value === null) return []
if (Array.isArray(value)) return value
return findValuePath(options as any, value, fieldNames) ?? []
}
export const Cascader = ({ value, options = [], fieldNames, ...props }: CascaderProps<DefaultOptionType>) => {
const f = {
key: fieldNames?.value ?? 'value',
title: fieldNames?.label ?? 'label',
children: fieldNames?.children ?? 'children',
} as any
return (
<AntCascader {...props}
options={options}
fieldNames={fieldNames}
value={getValue(options, value, f)}>
</AntCascader>
)
}
Cascader.displayName = 'Cascader'
Cascader.SHOW_CHILD = AntCascader.SHOW_CHILD
Cascader.SHOW_PARENT = AntCascader.SHOW_PARENT
export default Cascader

1
src/components/cascader/index.ts

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

39
src/components/department-tree/DepartmentCascader.tsx

@ -0,0 +1,39 @@
import { Cascader, CascaderProps } from '@/components/cascader'
import { useAtomValue } from 'jotai/index'
import { departTreeAtom } from '@/store/department.ts'
import { usePageStoreOptions } from '@/store'
import { Spin } from 'antd'
import { useTranslation } from '@/i18n.ts'
export interface DepartmentCascaderProps extends Omit<CascaderProps<any>, 'options'> {
onChange?: (value: any) => void
}
const displayRender = (labels: string[]) => labels[labels.length - 1]
export const DepartmentCascader = (props: DepartmentCascaderProps) => {
const { t } = useTranslation()
const { data = [], isLoading } = useAtomValue(departTreeAtom, usePageStoreOptions())
const onChange = (value) => {
props?.onChange?.(value[value.length - 1])
}
return (
<Spin spinning={isLoading} size={'small'}>
<Cascader changeOnSelect={true} {...props as any}
fieldNames={{
label: 'name',
value: 'id',
}}
placeholder={t('component.DepartmentCascader.placeholder', '请选择部门')}
onChange={onChange}
displayRender={displayRender}
showCheckedStrategy={Cascader.SHOW_CHILD}
options={data as any}>
</Cascader>
</Spin>
)
}

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

@ -11,6 +11,7 @@ import { IDepartment } from '@/types/department'
export interface DepartmentTreeProps extends TreeProps { export interface DepartmentTreeProps extends TreeProps {
root?: TreeDataNode | boolean | string root?: TreeDataNode | boolean | string
onItemClick?: (item: IDepartment) => void
} }
function getTopDataNode(root?: TreeDataNode | boolean | string, fieldNames?: TreeProps['fieldNames']) { function getTopDataNode(root?: TreeDataNode | boolean | string, fieldNames?: TreeProps['fieldNames']) {
@ -50,6 +51,7 @@ export const DepartmentTree = ({ root, ...props }: DepartmentTreeProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { data = [], isLoading } = useAtomValue(departTreeAtom, usePageStoreOptions()) const { data = [], isLoading } = useAtomValue(departTreeAtom, usePageStoreOptions())
const flattenMenusRef = useRef<IDepartment[]>([]) const flattenMenusRef = useRef<IDepartment[]>([])
const topData = getTopDataNode(root, props.fieldNames)
useDeepCompareEffect(() => { useDeepCompareEffect(() => {
@ -59,17 +61,18 @@ export const DepartmentTree = ({ root, ...props }: DepartmentTreeProps) => {
// @ts-ignore array // @ts-ignore array
if (data.length) { if (data.length) {
// @ts-ignore flattenTree // @ts-ignore flattenTree
flattenMenusRef.current = flattenTree<IDepartment[]>(data as any)
flattenMenusRef.current = [ topData ].concat(flattenTree<IDepartment[]>(data as any))
} else {
flattenMenusRef.current = topData ? [ topData as unknown as IDepartment ] : [] as IDepartment[]
} }
}, [ data, isLoading ])
}, [ data, isLoading, topData ])
const renderEmpty = () => { const renderEmpty = () => {
if ((data as any).length > 0 || isLoading) return null if ((data as any).length > 0 || isLoading) return null
return <Empty description={t('message.emptyDataAdd', '暂无数据,点击添加')}/> return <Empty description={t('message.emptyDataAdd', '暂无数据,点击添加')}/>
} }
const topData = getTopDataNode(root, props.fieldNames)
const treeData = topData ? [ topData, ...data as Array<any> ] : data const treeData = topData ? [ topData, ...data as Array<any> ] : data
return (<> return (<>
@ -84,6 +87,14 @@ export const DepartmentTree = ({ root, ...props }: DepartmentTreeProps) => {
// checkable={true} // checkable={true}
showIcon={false} showIcon={false}
{...props} {...props}
onSelect={(keys, info) => {
if (props.onSelect) {
props.onSelect(keys, info)
}
const current = flattenMenusRef.current?.find((menu) => menu.id === keys[0])
props?.onItemClick?.(current!)
}}
/> />
</Spin> </Spin>
</> </>

2
src/components/department-tree/index.ts

@ -0,0 +1,2 @@
export * from './DepartmentTree'
export * from './DepartmentCascader.tsx'

7
src/components/error/404.tsx

@ -1,12 +1,19 @@
import { useTranslation } from '@/i18n.ts' import { useTranslation } from '@/i18n.ts'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { Button, Result } from 'antd' import { Button, Result } from 'antd'
import { useAtomValue } from 'jotai'
import { userMenuDataAtom } from '@/store/user.ts'
const NotFound = () => { const NotFound = () => {
const navigate = useNavigate() const navigate = useNavigate()
const { t } = useTranslation() const { t } = useTranslation()
const { data } = useAtomValue(userMenuDataAtom)
if (!data) {
return null
}
return ( return (
<Result <Result
className="error-page" className="error-page"

4
src/components/icon/index.tsx

@ -1,4 +1,4 @@
import IconAll, { ALL_ICON_KEYS, IconType, IIconAllProps } from '@icon-park/react/es/all'
import IconAll, { ALL_ICON_KEYS, IconType as ParkIconType, IIconAllProps } from '@icon-park/react/es/all'
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
import * as AntIcons from '@ant-design/icons/es/icons' import * as AntIcons from '@ant-design/icons/es/icons'
@ -35,7 +35,7 @@ export function Icon(props: IconProps) {
return <img src={type} alt="icon" width={16} height={16} {...other}/> return <img src={type} alt="icon" width={16} height={16} {...other}/>
} }
if (ALL_ICON_KEYS.indexOf(type as IconType) < 0) {
if (ALL_ICON_KEYS.indexOf(type as ParkIconType) < 0) {
return null return null
} }

2
src/components/icon/picker/index.tsx

@ -26,6 +26,8 @@ const IconPicker: FC = (props: PickerProps) => {
if (value) { if (value) {
const [ type, componentName ] = value.split(':') const [ type, componentName ] = value.split(':')
selectIcon({ type, componentName } as any) selectIcon({ type, componentName } as any)
}else{
selectIcon(null as any)
} }
}, [ value ]) }, [ value ])

52
src/components/role-picker/RolePicker.tsx

@ -0,0 +1,52 @@
import { convertToBool } from '@/utils'
import { Select, SelectProps } from 'antd'
import { useAtomValue } from 'jotai'
import { rolesAtom } from '@/store/role.ts'
import { memo } from 'react'
import { useTranslation } from '@/i18n.ts'
export interface RolePickerProps extends SelectProps {
value?: any
onChange?: (value: any) => void
view?: boolean
}
const formatValue = (value: any) => {
if (value === undefined || value === null) {
return []
}
if (Array.isArray(value) && typeof value?.[0] === 'object') {
return (value as Array<object>).map(i => i['id'])
}
return value
}
const RolePicker = memo(({ value, view, ...props }: RolePickerProps) => {
const { t } = useTranslation()
const { data: roles, isPending } = useAtomValue(rolesAtom)
if (view) {
return value
}
return (
<>
<Select showSearch={true}
{...props}
value={formatValue(value)}
mode={'multiple'}
placeholder={t('component.RolePicker.placeholder', '请选择角色')}
options={
(roles?.rows ?? []).map(i => ({
label: i.name,
value: i.id,
disabled: !convertToBool(i.status)
}))
}
loading={isPending}/>
</>
)
})
export default RolePicker

0
src/components/role-picker/index.ts

4
src/components/switch/index.tsx

@ -2,7 +2,9 @@ import { convertToBool } from '@/utils'
import { Switch as AntSwitch, SwitchProps } from 'antd' import { Switch as AntSwitch, SwitchProps } from 'antd'
export const Switch = ({ value, ...props }: SwitchProps) => {
export const Switch = ({ value, ...props }: Omit<SwitchProps, 'value'> & {
value: any
}) => {
return ( return (
<AntSwitch {...props} value={convertToBool(value)}/> <AntSwitch {...props} value={convertToBool(value)}/>
) )

20
src/layout/TreePageLayout.tsx

@ -1,20 +0,0 @@
import React from 'react'
import { createLazyRoute, Outlet } from '@tanstack/react-router'
interface ITreePageLayoutProps {
children: React.ReactNode
}
const TreePageLayout: React.FC<ITreePageLayoutProps> = (props) => {
return (
<>
{props.children}
<Outlet/>
</>
)
}
export const GenRoute = (id: string) => createLazyRoute(id)({
component: TreePageLayout,
})

39
src/layout/TwoColPageLayout.tsx

@ -0,0 +1,39 @@
import React from 'react'
import { PageContainer, PageContainerProps } from '@ant-design/pro-components'
import { Flexbox } from 'react-layout-kit'
import { DraggablePanel, DraggablePanelProps } from '@/components/draggable-panel'
import { useStyle } from './style'
interface ITreePageLayoutProps {
children?: React.ReactNode
draggableProps?: DraggablePanelProps
pageProps?: PageContainerProps
leftPanel?: React.ReactNode
}
export const TwoColPageLayout: React.FC<ITreePageLayoutProps> = (props) => {
const { styles } = useStyle({ className: 'two-col' })
return (
<PageContainer
breadcrumbRender={false} title={false} className={styles.container}
{...props.pageProps}
>
<Flexbox horizontal className={styles.authHeight}>
<DraggablePanel expandable={false}
placement="left"
defaultSize={{ width: 300 }}
maxWidth={500}
style={{ position: 'relative' }}
{...props.draggableProps}
>
{props.leftPanel}
</DraggablePanel>
<Flexbox className={styles.box}>
{props.children}
</Flexbox>
</Flexbox>
</PageContainer>
)
}
export default TwoColPageLayout

12
src/layout/style.ts

@ -5,6 +5,8 @@ export const useStyle = createStyles(({ token, css, cx, prefixCls }, props: any)
const container = { const container = {
[prefix]: css` [prefix]: css`
.ant-pro-global-header-logo, .ant-pro-global-header-logo,
.ant-pro-layout-bg-list { .ant-pro-layout-bg-list {
user-select: none; user-select: none;
@ -27,9 +29,19 @@ export const useStyle = createStyles(({ token, css, cx, prefixCls }, props: any)
} }
` `
const box = css`
flex: 1;
background: ${token.colorBgContainer};
`
const authHeight = css`
min-height: calc(100vh - 122px);
background-color: ${token.colorBgContainer};
`
return { return {
container: cx(container[prefix], props?.className), container: cx(container[prefix], props?.className),
box,
authHeight,
pageContext, pageContext,
sideMenu, sideMenu,
} }

20
src/pages/system/departments/index.tsx

@ -11,6 +11,7 @@ import { useAtom, useAtomValue, } from 'jotai'
import Glass from '@/components/glass' import Glass from '@/components/glass'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import UserPicker from '@/components/user-picker/UserPicker.tsx' import UserPicker from '@/components/user-picker/UserPicker.tsx'
import TwoColPageLayout from '@/layout/TwoColPageLayout.tsx'
const Departments = () => { const Departments = () => {
@ -39,15 +40,8 @@ const Departments = () => {
}, [ current ]) }, [ current ])
return ( return (
<PageContainer breadcrumbRender={false} title={false} className={styles.container}>
<Flexbox horizontal>
<DraggablePanel expandable={false}
placement="left"
defaultSize={{ width: 300 }}
maxWidth={500}
style={{ position: 'relative' }}
>
<TwoColPageLayout
leftPanel={<>
<ProCard title={t('system.departments.title', '部门')}> <ProCard title={t('system.departments.title', '部门')}>
<DepartmentTree form={form}/> <DepartmentTree form={form}/>
</ProCard> </ProCard>
@ -66,8 +60,8 @@ const Departments = () => {
}} }}
>{t('actions.news')}</Button> >{t('actions.news')}</Button>
</div> </div>
</DraggablePanel>
<Flexbox className={styles.box}>
</>}
>
<Glass <Glass
enabled={current.id === undefined} enabled={current.id === undefined}
description={<> description={<>
@ -147,9 +141,7 @@ const Departments = () => {
</ProCard> </ProCard>
</Glass> </Glass>
</Flexbox>
</Flexbox>
</PageContainer>
</TwoColPageLayout>
) )
} }

9
src/pages/system/departments/style.ts

@ -32,7 +32,8 @@ export const useStyle = createStyles(({ token, css, cx, prefixCls }) => {
padding: 0 10px; padding: 0 10px;
} }
&:hover .actions { {
&:hover .actions {
{
display: flex; display: flex;
} }
@ -59,9 +60,13 @@ export const useStyle = createStyles(({ token, css, cx, prefixCls }) => {
` `
const emptyForm = css` const emptyForm = css`
` `
const authHeight = css`
min-height: calc(100vh - 122px);
background-color: ${token.colorBgContainer};
`
return { return {
container: cx(prefix), container: cx(prefix),
authHeight,
box, box,
form, form,
emptyForm, emptyForm,

144
src/pages/system/logs/login/index.tsx

@ -0,0 +1,144 @@
import Switch from '@/components/switch'
import {
ActionType,
PageContainer,
ProColumns,
ProTable,
} from '@ant-design/pro-components'
import { useStyle } from './style.ts'
import { memo, useMemo, useRef, useState } from 'react'
import { useAtom, useAtomValue } from 'jotai'
import { useTranslation } from '@/i18n.ts'
import { Button, Space, Table, Popconfirm } from 'antd'
import { deleteLoginLogAtom, loginLogPageAtom, loginLogsAtom, loginLogSearchAtom } from '@/store/logs.ts'
const LoginLog = memo(() => {
const { t } = useTranslation()
const { styles } = useStyle()
const actionRef = useRef<ActionType>()
const [ page, setPage ] = useAtom(loginLogPageAtom)
const [ search, setSearch ] = useAtom(loginLogSearchAtom)
const { data, isLoading, isFetching, refetch } = useAtomValue(loginLogsAtom)
const { mutate: deleteLog, isPending: isDeleting } = useAtomValue(deleteLoginLogAtom)
const [ ids, setIds ] = useState<number[]>([])
const columns = useMemo(() => {
return [
{
title: 'id', dataIndex: 'id',
hideInTable: true,
hideInSearch: true,
formItemProps: {
hidden: true
}
},
{
title: t('system.logs.login.columns.username', '登录帐号'), dataIndex: 'username', valueType: 'text',
},
{
title: t('system.logs.login.columns.ip', '登录IP'), dataIndex: 'ip', valueType: 'text',
},
{
title: t('system.logs.login.columns.user_agent', '浏览器'), dataIndex: 'user_agent', valueType: 'text',
},
{
title: t('system.logs.login.columns.status', '状态'), dataIndex: 'status', valueType: 'switch',
render: (_, record) => {
return <Switch value={record.status} size={'small'}/>
},
},
{
title: t('system.logs.login.columns.created_at', '登录时间'),
dataIndex: 'created_at',
valueType: 'dateTime',
},
{
title: t('system.logs.login.columns.option','操作'), valueType: 'option',
key: 'option',
render: (_, record) => [
<Popconfirm
key={'del_confirm'}
onConfirm={() => {
deleteLog([ record.id ])
}}
title={t('message.deleteConfirm')}>
<a key="del">
{t('actions.delete', '删除')}
</a>
</Popconfirm>
,
],
},
] as ProColumns[]
}, [])
return (
<PageContainer breadcrumbRender={false} title={false} className={styles.container}>
<div className={styles.authHeight}>
<ProTable
rowKey={'id'}
actionRef={actionRef}
headerTitle={t('system.logs.login.title', '登录日志')}
columns={columns}
loading={isLoading || isFetching}
dataSource={data?.rows}
search={false}
rowSelection={{
onChange: (selectedRowKeys) => {
setIds(selectedRowKeys as number[])
},
selectedRowKeys: ids,
selections: [ Table.SELECTION_ALL, Table.SELECTION_INVERT ],
}}
tableAlertOptionRender={() => {
return (
<Space size={16}>
<Popconfirm
onConfirm={() => {
deleteLog(ids)
}}
title={t('message.batchDelete')}>
<Button type={'link'}
loading={isDeleting}>{t('actions.batchDel')}</Button>
</Popconfirm>
</Space>
)
}}
options={{
reload: () => {
refetch()
},
}}
toolbar={{
search: {
loading: isFetching && !!search.key,
onSearch: (value: string) => {
setSearch({ key: value })
},
placeholder: t('system.logs.login.search.placeholder','请输入用户名查询')
},
actions: []
}}
pagination={{
total: data?.total,
current: page.page,
pageSize: page.pageSize,
onChange: (page) => {
setPage((prev) => {
return { ...prev, page }
})
}
}}
/>
</div>
</PageContainer>
)
})
export default LoginLog

14
src/pages/system/logs/login/style.ts

@ -0,0 +1,14 @@
import { createStyles } from '@/theme'
export const useStyle = createStyles(({ token, css, cx, prefixCls }) => {
const prefix = `${prefixCls}-${token?.proPrefix}-logs-login-page`
const authHeight = css`
min-height: calc(100vh - 122px);
background-color: ${token.colorBgContainer};
`
return {
container: cx(prefix),
authHeight,
}
})

31
src/pages/system/menus/index.tsx

@ -1,20 +1,18 @@
import Glass from '@/components/glass' import Glass from '@/components/glass'
import { useTranslation } from '@/i18n.ts' import { useTranslation } from '@/i18n.ts'
import { PlusOutlined } from '@ant-design/icons' import { PlusOutlined } from '@ant-design/icons'
import { PageContainer, ProCard } from '@ant-design/pro-components'
import { ProCard } from '@ant-design/pro-components'
import { Button, Form, Input, Radio, TreeSelect, InputNumber, notification, Alert, InputRef, Divider } from 'antd' import { Button, Form, Input, Radio, TreeSelect, InputNumber, notification, Alert, InputRef, Divider } from 'antd'
import { useAtom, useAtomValue } from 'jotai' import { useAtom, useAtomValue } from 'jotai'
import { defaultMenu, menuDataAtom, saveOrUpdateMenuAtom, selectedMenuAtom } from '@/store/menu.ts' import { defaultMenu, menuDataAtom, saveOrUpdateMenuAtom, selectedMenuAtom } from '@/store/menu.ts'
import IconPicker from '@/components/icon/picker' import IconPicker from '@/components/icon/picker'
import ButtonTable from './components/ButtonTable.tsx' import ButtonTable from './components/ButtonTable.tsx'
import { Flexbox } from 'react-layout-kit'
import { DraggablePanel } from '@/components/draggable-panel'
import { useStyle } from './style.ts' import { useStyle } from './style.ts'
import { MenuItem } from '@/global' import { MenuItem } from '@/global'
import MenuTree from './components/MenuTree.tsx' import MenuTree from './components/MenuTree.tsx'
import BatchButton from '@/pages/system/menus/components/BatchButton.tsx' import BatchButton from '@/pages/system/menus/components/BatchButton.tsx'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import TwoColPageLayout from '@/layout/TwoColPageLayout.tsx'
const Menus = () => { const Menus = () => {
@ -43,15 +41,8 @@ const Menus = () => {
}, [ currentMenu ]) }, [ currentMenu ])
return ( return (
<PageContainer breadcrumbRender={false} title={false} className={styles.container}>
<Flexbox horizontal>
<DraggablePanel expandable={false}
placement="left"
defaultSize={{ width: 300 }}
maxWidth={500}
style={{ position: 'relative' }}
>
<TwoColPageLayout
leftPanel={<>
<ProCard title={t('system.menus.title', '菜单')} <ProCard title={t('system.menus.title', '菜单')}
extra={ extra={
<> <>
@ -77,8 +68,10 @@ const Menus = () => {
}} }}
>{t('actions.news')}</Button> >{t('actions.news')}</Button>
</div> </div>
</DraggablePanel>
<Flexbox className={styles.box}>
</>}
>
<Glass <Glass
enabled={currentMenu.id === undefined} enabled={currentMenu.id === undefined}
description={<> description={<>
@ -113,7 +106,8 @@ const Menus = () => {
{ required: true, message: t('rules.required') } { required: true, message: t('rules.required') }
]} ]}
label={t('system.menus.form.title', '菜单名称')} name={'title'}> label={t('system.menus.form.title', '菜单名称')} name={'title'}>
<Input ref={menuInputRef as any} placeholder={t('system.menus.form.title', '菜单名称')}/>
<Input ref={menuInputRef as any}
placeholder={t('system.menus.form.title', '菜单名称')}/>
</Form.Item> </Form.Item>
<Form.Item label={t('system.menus.form.parent', '上级菜单')} name={'parent_id'}> <Form.Item label={t('system.menus.form.parent', '上级菜单')} name={'parent_id'}>
<TreeSelect <TreeSelect
@ -202,9 +196,8 @@ const Menus = () => {
</ProCard> </ProCard>
</Form> </Form>
</Glass> </Glass>
</Flexbox>
</Flexbox>
</PageContainer>
</TwoColPageLayout>
) )
} }

14
src/pages/system/roles/index.tsx

@ -88,9 +88,12 @@ const Roles = memo(() => {
}, },
{ {
title: t('system.roles.columns.status'), dataIndex: 'status', valueType: 'switch', title: t('system.roles.columns.status'), dataIndex: 'status', valueType: 'switch',
render: (text) => {
return <Switch value={!!text} size={'small'}/>
}
render: (_,record) => {
return <Switch value={record.status} size={'small'}/>
},
renderFormItem: (item, config) => {
return <Switch {...item.fieldProps as any} {...config}/>
},
}, },
{ {
title: t('system.roles.columns.sort'), dataIndex: 'sort', valueType: 'digit', title: t('system.roles.columns.sort'), dataIndex: 'sort', valueType: 'digit',
@ -144,6 +147,7 @@ const Roles = memo(() => {
return ( return (
<PageContainer breadcrumbRender={false} title={false} className={styles.container}> <PageContainer breadcrumbRender={false} title={false} className={styles.container}>
<div className={styles.authHeight}>
<ProTable <ProTable
rowKey={'id'} rowKey={'id'}
actionRef={actionRef} actionRef={actionRef}
@ -214,11 +218,13 @@ const Roles = memo(() => {
} }
}} }}
/> />
</div>
<BetaSchemaForm <BetaSchemaForm
width={600} width={600}
form={form} form={form}
layout={'horizontal'} layout={'horizontal'}
title={t('system.roles.edit.title', '角色编辑')}
title={t(`system.roles.title_${form.getFieldValue('id') !== 0 ? 'edit' : 'add'}`, form.getFieldValue('id') !== 0 ? '角色编辑' : '角色添加')}
colProps={{ span: 24 }} colProps={{ span: 24 }}
labelCol={{ span: 6 }} labelCol={{ span: 6 }}
wrapperCol={{ span: 16 }} wrapperCol={{ span: 16 }}

7
src/pages/system/roles/style.ts

@ -1,7 +1,7 @@
import { createStyles } from '@/theme' import { createStyles } from '@/theme'
export const useStyle = createStyles(({ token, css, cx, prefixCls }) => { export const useStyle = createStyles(({ token, css, cx, prefixCls }) => {
const prefix = `${prefixCls}-${token?.proPrefix}-role-page`;
const prefix = `${prefixCls}-${token?.proPrefix}-role-page`
const box = css` const box = css`
@ -13,9 +13,14 @@ export const useStyle = createStyles(({ token, css, cx, prefixCls }) => {
flex-wrap: wrap; flex-wrap: wrap;
min-width: 500px; min-width: 500px;
` `
const authHeight = css`
min-height: calc(100vh - 122px);
background-color: ${token.colorBgContainer};
`
return { return {
container: cx(prefix), container: cx(prefix),
authHeight,
box, box,
form, form,
} }

243
src/pages/system/users/index.tsx

@ -1,10 +1,245 @@
import { PageContainer } from '@ant-design/pro-components'
import TwoColPageLayout from '@/layout/TwoColPageLayout.tsx'
import {
ActionType,
BetaSchemaForm,
ProCard,
ProColumns,
ProFormColumnsType,
ProTable
} from '@ant-design/pro-components'
import { Button, Form, Popconfirm } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import { useTranslation } from '@/i18n.ts'
import DepartmentTree from '@/components/department-tree/DepartmentTree.tsx'
import { useAtom, useAtomValue } from 'jotai'
import {
deleteUserAtom, resetPasswordAtom,
saveOrUpdateUserAtom,
userListAtom,
userPageAtom,
userSearchAtom,
userSelectedAtom
} from '@/store/user.ts'
import { useMemo, useRef, useState } from 'react'
import Switch from '@/components/switch'
import { DepartmentCascader } from '@/components/department-tree'
import RolePicker from '@/components/role-picker/RolePicker.tsx'
const Users = () => { const Users = () => {
return (
<PageContainer breadcrumbRender={false}>
</PageContainer>
const { t } = useTranslation()
const [ page, setPage ] = useAtom(userPageAtom)
const [ search, setSearch ] = useAtom(userSearchAtom)
const [ , setCurrent ] = useAtom(userSelectedAtom)
const { mutate: saveOrUpdate, isPending: isSubmitting } = useAtomValue(saveOrUpdateUserAtom)
const { data, isFetching, isLoading, refetch } = useAtomValue(userListAtom)
const { mutate: deleteUser, isPending, } = useAtomValue(deleteUserAtom)
const { mutate: resetPass, isPending: isResetting } = useAtomValue(resetPasswordAtom)
const [ form ] = Form.useForm()
const actionRef = useRef<ActionType>()
const [ open, setOpen ] = useState(false)
const columns = useMemo(() => {
return [
{
title: 'id', dataIndex: 'id',
hideInTable: true,
hideInSearch: true,
formItemProps: {
hidden: true
}
},
{
title: t('system.users.columns.name', '姓名'), dataIndex: 'name', valueType: 'text',
formItemProps: {
rules: [ { required: true, message: t('message.required') } ]
}
},
{
title: t('system.users.columns.username', '用户名'), dataIndex: 'username', valueType: 'text',
formItemProps: {
rules: [ { required: true, message: t('message.required') } ]
}
},
{
title: t('system.users.columns.roles', '角色'), dataIndex: 'roles', valueType: 'select',
render: (_, record) => {
return record.roles?.map(role => role.name).join(',') || ''
},
renderFormItem: (item, config) => {
const { mode, ...other } = config as any
return <RolePicker {...item.fieldProps as any}
view={mode !== 'edit'} {...other}/>
},
},
{
title: t('system.users.columns.dept_id', '所属部门'), dataIndex: 'dept_id',
render: (_, record) => {
return record.dept_name || ''
},
renderFormItem: (item, config) => {
return <DepartmentCascader {...item.fieldProps as any} {...config} />
}
},
{
title: t('system.users.columns.status', '状态'), dataIndex: 'status', valueType: 'switch',
render: (_, record) => {
return <Switch value={record.status} size={'small'}/>
},
renderFormItem: (item, config) => {
return <Switch {...item.fieldProps as any} {...config}/>
},
},
{
title: t('system.users.columns.update_at', '更新时间'),
hideInTable: true,
hideInSearch: true,
hideInForm: true,
dataIndex: 'update_at',
valueType: 'dateTime',
},
{
title: t('system.users.columns.option', '操作'), valueType: 'option',
key: 'option',
render: (_, record) => [
<a key="editable"
onClick={() => {
setCurrent(record)
setOpen(true)
form.setFieldsValue(record)
}}
>
{t('actions.edit', '编辑')}
</a>,
<Popconfirm
disabled={isResetting}
key={'reset_password_confirm'}
onConfirm={() => {
resetPass(record.id)
}}
title={
<span dangerouslySetInnerHTML={{
__html: t('message.resetPassConfirm', '密码会重置为{{password}}操作不可回滚,是否继续?', {
password: '<span style="color:red;">kk123456</span>'
})
}}></span>
}>
<a key="del">
{t('actions.resetPass', '重置密码')}
</a>
</Popconfirm>,
<Popconfirm
key={'del_confirm'}
disabled={isPending}
onConfirm={() => {
deleteUser([ record.id ])
}}
title={t('message.deleteConfirm')}>
<a key="del">
{t('actions.delete', '删除')}
</a>
</Popconfirm>
,
],
},
] as ProColumns[]
}, [])
return (
<TwoColPageLayout leftPanel={
<>
<ProCard title={t('system.users.title', '部门')}>
<DepartmentTree
root={true}
fieldNames={{
title: 'name',
key: 'id',
}}
onItemClick={(item) => {
setSearch({
dept_id: item.id,
})
}}
/>
</ProCard>
</>
}>
<ProTable
rowKey={'id'}
actionRef={actionRef}
headerTitle={t('system.users.title', '用户管理')}
columns={columns}
loading={isLoading || isFetching}
dataSource={data?.rows}
search={false}
options={{
reload: () => {
refetch()
},
}}
toolbar={{
search: {
loading: isFetching && !!search.key,
onSearch: (value: string) => {
setSearch({ key: value })
},
placeholder: t('system.users.search.placeholder', '输入用户名')
},
actions: [
<Button
key="button"
icon={<PlusOutlined/>}
onClick={() => {
form.resetFields()
form.setFieldsValue({
id: 0,
dept_id: search.dept_id ?? 0,
})
setOpen(true)
}}
type="primary"
>
{t('actions.add', '添加')}
</Button>,
]
}}
pagination={{
total: data?.total,
current: page.page,
pageSize: page.pageSize,
onChange: (page) => {
setPage((prev) => {
return { ...prev, page }
})
}
}}
/>
<BetaSchemaForm
width={600}
form={form}
layout={'horizontal'}
title={t(`system.users.title_${form.getFieldValue('id') !== 0 ? 'edit' : 'add'}`, form.getFieldValue('id') !== 0 ? '用户编辑' : '用户添加')}
colProps={{ span: 24 }}
labelCol={{ span: 6 }}
wrapperCol={{ span: 16 }}
layoutType={'ModalForm'}
open={open}
modalProps={{
maskClosable: false,
}}
onOpenChange={(open) => {
setOpen(open)
}}
loading={isSubmitting}
onFinish={async (values) => {
// console.log('values', values)
saveOrUpdate(values)
return true
}}
columns={columns as ProFormColumnsType[]}/>
</TwoColPageLayout>
) )
} }

71
src/pages/system/users/style.ts

@ -0,0 +1,71 @@
import { createStyles } from '@/theme'
export const useStyle = createStyles(({ token, css, cx, prefixCls }) => {
const prefix = `${prefixCls}-${token?.proPrefix}-user-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
}
})

19
src/service/system.ts

@ -20,6 +20,9 @@ const systemServ = {
login: (data: LoginRequest) => { login: (data: LoginRequest) => {
return request.post<LoginResponse>('/sys/login', data) return request.post<LoginResponse>('/sys/login', data)
}, },
logout:()=>{
//
},
user: { user: {
...createCURD<any, IUser>('/sys/user'), ...createCURD<any, IUser>('/sys/user'),
current: () => { current: () => {
@ -28,11 +31,23 @@ const systemServ = {
menus: () => { menus: () => {
return request.get<IPageResult<IMenu[]>>('/sys/user/menus') return request.get<IPageResult<IMenu[]>>('/sys/user/menus')
}, },
resetPassword: (id: number) => {
return request.post<any>(`/sys/user/reset/password`, { id })
}
}, },
role: { role: {
...createCURD<any, IRole>('/sys/role') ...createCURD<any, IRole>('/sys/role')
},
logs: {
login: {
...createCURD<any, ILoginLog>('/sys/log/login'),
clear: (params: {
start: string,
end: string
}) => {
return request.post<any>('/sys/log/login/clear', params)
}
}
} }

58
src/store/logs.ts

@ -0,0 +1,58 @@
import { atomWithMutation, atomWithQuery } from 'jotai-tanstack-query'
import { atom } from 'jotai/index'
import { IPage } from '@/global'
import systemServ from '@/service/system.ts'
import { message } from 'antd'
import { t } from '@/i18n.ts'
export const loginLogPageAtom = atom<IPage>({
page: 1,
pageSize: 10,
})
type LogSearch = {
key?: string,
}
export const loginLogSearchAtom = atom<LogSearch>({
key: ''
})
export const loginLogsAtom = atomWithQuery((get) => ({
queryKey: [ 'loginLogs', get(loginLogPageAtom), get(loginLogSearchAtom) ],
queryFn: async ({ queryKey: [ , page, search ] }) => {
return await systemServ.logs.login.list({
...page as any,
...search as any,
})
},
select: (data) => {
return data.data
},
}))
export const loginLogIdsAtom = atom<number[]>([])
export const deleteLoginLogAtom = atomWithMutation<any, number[]>((get) => ({
mutationKey: [ 'deleteLoginLog' ],
mutationFn: async (ids) => {
return await systemServ.logs.login.batchDelete(ids)
},
onSuccess: () => {
message.success(t('message.deleteSuccess', '删除成功'))
get(loginLogsAtom).refetch()
},
}))
//clear
export const clearLoginLogAtom = atomWithMutation<any, { start: string, end: string }>((get) => ({
mutationKey: [ 'clearLoginLog' ],
mutationFn: async (params) => {
return await systemServ.logs.login.clear(params)
},
onSuccess: () => {
message.success(t('message.deleteSuccess', '删除成功'))
get(loginLogsAtom).refetch()
}
}))

12
src/store/route.ts

@ -1,12 +0,0 @@
import { IRootContext } from '@/global'
import { atom } from 'jotai'
export const routeContextAtom = atom<IRootContext>({})
export const updateRouteContextAtom = atom(null, (set, get, update) => {
console.log(update)
set(routeContextAtom, {
...get(routeContextAtom),
...update,
})
})

10
src/store/system.ts

@ -2,7 +2,6 @@ import { IAppData } from '@/global'
import { createStore } from 'jotai' import { createStore } from 'jotai'
import { atomWithStorage } from 'jotai/utils' import { atomWithStorage } from 'jotai/utils'
import { changeLanguage as setLang } from 'i18next' import { changeLanguage as setLang } from 'i18next'
import { userMenuDataAtom } from '@/store/user.ts'
/** /**
* app全局状态 * app全局状态
@ -16,12 +15,8 @@ export const appAtom = atomWithStorage<Partial<IAppData>>('app', {
}) })
appStore.sub(appAtom, () => { appStore.sub(appAtom, () => {
const token = appStore.get(appAtom).token
const { data = [], refetch } = appStore.get(userMenuDataAtom)
//如果没有menus数据,则请求
if (token && data.length === 0) {
refetch()
}
// const token = appStore.get(appAtom).token
}) })
@ -53,5 +48,6 @@ export const getToken = () => {
} }
export const setToken = (token: string) => { export const setToken = (token: string) => {
console.log('settoken', token)
updateAppData({ token }) updateAppData({ token })
} }

121
src/store/user.ts

@ -1,12 +1,14 @@
import { appAtom } from '@/store/system.ts'
import { appAtom, setToken } from '@/store/system.ts'
import { IMenu } from '@/types/menus' import { IMenu } from '@/types/menus'
import { IUserInfo } from '@/types/user'
import { IUser, IUserInfo } from '@/types/user'
import { atom } from 'jotai/index' import { atom } from 'jotai/index'
import { IApiResult, IAuth, IPageResult, MenuItem } from '@/global'
import { IApiResult, IAuth, IPage, IPageResult, MenuItem } from '@/global'
import { LoginRequest } from '@/types/login' import { LoginRequest } from '@/types/login'
import { atomWithMutation, atomWithQuery } from 'jotai-tanstack-query'
import { atomWithMutation, atomWithQuery, queryClientAtom } from 'jotai-tanstack-query'
import systemServ from '@/service/system.ts' import systemServ from '@/service/system.ts'
import { formatMenuData, isDev } from '@/utils' import { formatMenuData, isDev } from '@/utils'
import { message } from 'antd'
import { t } from 'i18next'
export const authAtom = atom<IAuth>({ export const authAtom = atom<IAuth>({
isLogin: false, isLogin: false,
@ -22,17 +24,28 @@ export const loginFormAtom = atom<LoginRequest>({
...(isDev ? devLogin : {}) ...(isDev ? devLogin : {})
} as LoginRequest) } as LoginRequest)
export const loginAtom = atomWithMutation<any, LoginRequest>(() => ({
export const loginAtom = atomWithMutation<any, LoginRequest>((get) => ({
mutationKey: [ 'login' ], mutationKey: [ 'login' ],
mutationFn: async (params) => { mutationFn: async (params) => {
return await systemServ.login(params) return await systemServ.login(params)
}, },
onSuccess: () => {
// console.log('login success', data)
onSuccess: (res) => {
message.success(t('login.success'))
// console.log('login success', res)
get(userMenuDataAtom).refetch().then()
return res.data
}, },
retry: false, retry: false,
})) }))
export const logoutAtom = atomWithMutation(() => ({
mutationKey: [ 'logout' ],
mutationFn: async () => {
setToken('')
return true
},
}))
export const currentUserAtom = atomWithQuery<IApiResult<IUserInfo>, any, IUserInfo>((get) => { export const currentUserAtom = atomWithQuery<IApiResult<IUserInfo>, any, IUserInfo>((get) => {
return { return {
queryKey: [ 'user_info', get(appAtom).token ], queryKey: [ 'user_info', get(appAtom).token ],
@ -54,24 +67,100 @@ export const userMenuDataAtom = atomWithQuery<IApiResult<IPageResult<IMenu[]>>,
select: (data) => { select: (data) => {
return formatMenuData(data.data.rows as any ?? []) return formatMenuData(data.data.rows as any ?? [])
}, },
cacheTime: 1000 * 60,
retry: false, retry: false,
})) }))
export const userSearchAtom = atom<{
dept_id: number,
key: string
} | unknown>({})
export type UserSearch = {
dept_id?: any,
key?: string
}
export const userSearchAtom = atom<UserSearch>({} as UserSearch)
//=======user page store======
export const userPageAtom = atom<IPage>({
pageSize: 10,
page: 1
})
//user list
// user list
export const userListAtom = atomWithQuery((get) => { export const userListAtom = atomWithQuery((get) => {
return { return {
queryKey: [ 'user_list', get(userSearchAtom) ],
queryFn: async ({ queryKey: [ , params ] }) => {
return await systemServ.user.list(params)
queryKey: [ 'user_list', get(userSearchAtom), get(userPageAtom) ],
queryFn: async ({ queryKey: [ , params, page ] }) => {
return await systemServ.user.list({
...params as any,
...page as any,
})
}, },
select: (data) => { select: (data) => {
return data.data return data.data
}, },
} }
}) })
// user selected
export const userSelectedAtom = atom<IUser>({} as IUser)
export const defaultUserData = {
id: 0,
dept_id: 0,
role_id: 0,
} as IUser
//save or update user
export const saveOrUpdateUserAtom = atomWithMutation<IApiResult, IUser>((get) => ({
mutationKey: [ 'save_user' ],
mutationFn: async (params) => {
params.status = params.status ? '1' : '0'
const isAdd = 0 === params.id
if (isAdd) {
return await systemServ.user.add(params)
}
return await systemServ.user.update(params)
},
onSuccess: (res) => {
const isAdd = !!res.data?.id
message.success(t(isAdd ? 'message.saveSuccess' : 'message.editSuccess', '保存成功'))
//刷新userList
get(queryClientAtom).invalidateQueries({ queryKey: [ 'user_list' ] })
return res
},
}))
//delete user
export const batchUserIdsAtom = atom<number[]>([])
export const deleteUserAtom = atomWithMutation<IApiResult, number[]>((get) => ({
mutationKey: [ 'delete_user' ],
mutationFn: async (params) => {
return await systemServ.user.batchDelete(params)
},
onSuccess: () => {
message.success(t('message.deleteSuccess', '删除成功'))
//刷新userList
get(queryClientAtom).invalidateQueries({ queryKey: [ 'user_list' ] })
return true
},
}))
//reset password
export const resetPasswordAtom = atomWithMutation<IApiResult, number>(() => ({
mutationKey: [ 'reset_password' ],
mutationFn: async (id) => {
return await systemServ.user.resetPassword(id)
},
onSuccess: () => {
message.success(t('message.resetSuccess', '重置成功'))
//刷新userList
// get(queryClientAtom).invalidateQueries({ queryKey: [ 'user_list' ] })
return true
},
onError: () => {
message.error(t('message.resetError', '重置失败'))
},
}))

11
src/types/logs.d.ts

@ -0,0 +1,11 @@
interface ILoginLog {
id: string;
username: string;
ip: string;
user_agent: string;
os: string;
browser: string;
status: string;
note: string;
created_at: string;
}

1
src/types/user.d.ts

@ -7,6 +7,7 @@ export interface IUser {
updated_at: string, updated_at: string,
updated_by: number, updated_by: number,
username: string, username: string,
role_id: number,
dept_id: number, dept_id: number,
dept_name: string, dept_name: string,
name: string, name: string,

37
src/utils/tree.ts

@ -1,3 +1,5 @@
import { FiledNames } from '@/global'
type TreeKey = string | number; type TreeKey = string | number;
type TreeNode<T> = { type TreeNode<T> = {
@ -57,3 +59,38 @@ export function getTreeCheckedStatus<T>(tree: TreeNode<T>[], selectKeys: TreeKey
tree.forEach(node => checkNode(node, [])) tree.forEach(node => checkNode(node, []))
return { checked, halfChecked } return { checked, halfChecked }
} }
export function findValuePath<T>(tree: TreeNode<T>[], targetValue: string | number, filedNames?: FiledNames): (string | number)[] | null {
const f = {
key: filedNames?.key ?? 'key',
title: filedNames?.title ?? 'title',
children: filedNames?.children ?? 'children',
}
const findPathRecursive = (node: TreeNode<T>, pathSoFar: (string | number)[]): (string | number)[] | null => {
if (node[f.key] === targetValue) {
return [ ...pathSoFar, node[f.key] ]
}
if (node[f.children]) {
for (const child of node[f.children]) {
const result = findPathRecursive(child, [ ...pathSoFar, node[f.key] ])
if (result !== null) {
return result
}
}
}
return null
}
for (const root of tree) {
const result = findPathRecursive(root, [])
if (result !== null) {
return result
}
}
return null // 如果未找到目标值,则返回null
}
Loading…
Cancel
Save