Browse Source

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

main
dark 7 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. 41
      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. 10
      src/components/switch/index.tsx
  13. 68
      src/i18n.ts
  14. 20
      src/layout/TreePageLayout.tsx
  15. 39
      src/layout/TwoColPageLayout.tsx
  16. 12
      src/layout/style.ts
  17. 166
      src/pages/system/departments/index.tsx
  18. 79
      src/pages/system/departments/style.ts
  19. 144
      src/pages/system/logs/login/index.tsx
  20. 14
      src/pages/system/logs/login/style.ts
  21. 371
      src/pages/system/menus/index.tsx
  22. 150
      src/pages/system/roles/index.tsx
  23. 7
      src/pages/system/roles/style.ts
  24. 243
      src/pages/system/users/index.tsx
  25. 71
      src/pages/system/users/style.ts
  26. 63
      src/service/system.ts
  27. 58
      src/store/logs.ts
  28. 12
      src/store/route.ts
  29. 48
      src/store/system.ts
  30. 121
      src/store/user.ts
  31. 11
      src/types/logs.d.ts
  32. 1
      src/types/user.d.ts
  33. 129
      src/utils/tree.ts

1
.gitignore

@ -1,6 +1,7 @@
# Logs
logs
*.log
!src/system/logs
npm-debug.log*
yarn-debug.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 {
root?: TreeDataNode | boolean | string
onItemClick?: (item: IDepartment) => void
}
function getTopDataNode(root?: TreeDataNode | boolean | string, fieldNames?: TreeProps['fieldNames']) {
@ -50,6 +51,7 @@ export const DepartmentTree = ({ root, ...props }: DepartmentTreeProps) => {
const { t } = useTranslation()
const { data = [], isLoading } = useAtomValue(departTreeAtom, usePageStoreOptions())
const flattenMenusRef = useRef<IDepartment[]>([])
const topData = getTopDataNode(root, props.fieldNames)
useDeepCompareEffect(() => {
@ -59,17 +61,18 @@ export const DepartmentTree = ({ root, ...props }: DepartmentTreeProps) => {
// @ts-ignore array
if (data.length) {
// @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 = () => {
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 (<>
@ -84,6 +87,14 @@ export const DepartmentTree = ({ root, ...props }: DepartmentTreeProps) => {
// checkable={true}
showIcon={false}
{...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>
</>

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

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

41
src/components/error/404.tsx

@ -1,27 +1,34 @@
import { useTranslation } from '@/i18n.ts'
import { useNavigate } from '@tanstack/react-router'
import { Button, Result } from 'antd'
import { useAtomValue } from 'jotai'
import { userMenuDataAtom } from '@/store/user.ts'
const NotFound = () => {
const navigate = useNavigate()
const { t } = useTranslation()
const navigate = useNavigate()
const { t } = useTranslation()
const { data } = useAtomValue(userMenuDataAtom)
return (
<Result
className="error-page"
status="404"
title={t('error.404.title')}
subTitle={t('error.404.message')}
extra={
<Button type="primary" onClick={() => navigate({
to: '../'
})}>
{t('route.goBack')}
</Button>
}
/>
)
if (!data) {
return null
}
return (
<Result
className="error-page"
status="404"
title={t('error.404.title')}
subTitle={t('error.404.message')}
extra={
<Button type="primary" onClick={() => navigate({
to: '../'
})}>
{t('route.goBack')}
</Button>
}
/>
)
}
export default NotFound

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 * 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}/>
}
if (ALL_ICON_KEYS.indexOf(type as IconType) < 0) {
if (ALL_ICON_KEYS.indexOf(type as ParkIconType) < 0) {
return null
}

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

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

10
src/components/switch/index.tsx

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

68
src/i18n.ts

@ -1,51 +1,51 @@
import { changeLanguage } from '@/store/system.ts'
import i18n, { InitOptions, t } from 'i18next'
import i18n, { InitOptions, t } from 'i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import { initReactI18next, useTranslation, } from 'react-i18next'
import { initReactI18next, useTranslation, } from 'react-i18next'
import { zh, en } from './locales'
const detectionOptions = {
// 探测器的选项
order: [ 'querystring', 'cookie', 'localStorage', 'navigator', 'htmlTag' ],
lookupQuerystring: 'lng',
lookupCookie: 'i18next',
lookupLocalStorage: 'i18nextLng',
caches: [ 'localStorage', 'cookie' ],
excludeCacheFor: [ 'cimode' ], // 语言探测模式中排除缓存的语言
// 探测器的选项
order: [ 'querystring', 'cookie', 'localStorage', 'navigator', 'htmlTag' ],
lookupQuerystring: 'lng',
lookupCookie: 'i18next',
lookupLocalStorage: 'i18nextLng',
caches: [ 'localStorage', 'cookie' ],
excludeCacheFor: [ 'cimode' ], // 语言探测模式中排除缓存的语言
}
export const initI18n = (options?: InitOptions) => {
i18n.on('initialized', () => {
const currentLanguage = i18n.language
changeLanguage(currentLanguage)
})
return i18n
.use(initReactI18next)
.use(LanguageDetector)
.init({
resources: {
en: {
translation: en.default,
},
zh: {
translation: zh.default,
},
},
fallbackLng: 'zh',
debug: false,
detection: detectionOptions,
interpolation: {
escapeValue: false,
},
...options,
i18n.on('initialized', () => {
const currentLanguage = i18n.language
changeLanguage(currentLanguage)
})
return i18n
.use(initReactI18next)
.use(LanguageDetector)
.init({
resources: {
en: {
translation: en.default,
},
zh: {
translation: zh.default,
},
},
fallbackLng: 'zh',
debug: false,
detection: detectionOptions,
interpolation: {
escapeValue: false,
},
...options,
})
}
export {
useTranslation, t
useTranslation, t
}
export default i18n

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 = {
[prefix]: css`
.ant-pro-global-header-logo,
.ant-pro-layout-bg-list {
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 {
container: cx(container[prefix], props?.className),
box,
authHeight,
pageContext,
sideMenu,
}

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

@ -11,6 +11,7 @@ import { useAtom, useAtomValue, } from 'jotai'
import Glass from '@/components/glass'
import { useEffect, useRef } from 'react'
import UserPicker from '@/components/user-picker/UserPicker.tsx'
import TwoColPageLayout from '@/layout/TwoColPageLayout.tsx'
const Departments = () => {
@ -39,15 +40,8 @@ const Departments = () => {
}, [ current ])
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', '部门')}>
<DepartmentTree form={form}/>
</ProCard>
@ -66,90 +60,88 @@ const Departments = () => {
}}
>{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"
/>
</>}
>
</>}
>
<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>
<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={'manager_user_id'}>
<UserPicker/>
</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={'manager_user_id'}>
<UserPicker/>
</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.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>
</Form>
</ProCard>
</Glass>
</Flexbox>
</Flexbox>
</PageContainer>
</Glass>
</TwoColPageLayout>
)
}

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

@ -5,52 +5,53 @@ export const useStyle = createStyles(({ token, css, cx, prefixCls }) => {
const tree = css`
.ant-tree {
overflow: auto;
height: 100%;
border-right: 1px solid ${token.colorBorder};
background: ${token.colorBgContainer};
.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-directory .ant-tree-treenode-selected::before {
background: ${token.colorBgTextHover};
}
.ant-tree-treenode:before {
border-radius: ${token.borderRadius}px;
}
`
.ant-tree-treenode:before {
border-radius: ${token.borderRadius}px;
}
`
const treeNode = css`
display: flex;
justify-content: space-between;
align-items: center;
display: flex;
justify-content: space-between;
align-items: center;
.actions {
display: none;
padding: 0 10px;
}
.actions {
display: none;
padding: 0 10px;
}
&:hover .actions { {
display: flex;
}
&: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};
`
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};
`
flex: 1;
background: ${token.colorBgContainer};
`
const form = css`
//display: flex;
//flex-wrap: wrap;
@ -58,10 +59,14 @@ export const useStyle = createStyles(({ token, css, cx, prefixCls }) => {
max-width: 800px;
`
const emptyForm = css`
`
`
const authHeight = css`
min-height: calc(100vh - 122px);
background-color: ${token.colorBgContainer};
`
return {
container: cx(prefix),
authHeight,
box,
form,
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,
}
})

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

@ -1,211 +1,204 @@
import Glass from '@/components/glass'
import { useTranslation } from '@/i18n.ts'
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 { useAtom, useAtomValue } from 'jotai'
import { defaultMenu, menuDataAtom, saveOrUpdateMenuAtom, selectedMenuAtom } from '@/store/menu.ts'
import IconPicker from '@/components/icon/picker'
import ButtonTable from './components/ButtonTable.tsx'
import { Flexbox } from 'react-layout-kit'
import { DraggablePanel } from '@/components/draggable-panel'
import { useStyle } from './style.ts'
import { MenuItem } from '@/global'
import MenuTree from './components/MenuTree.tsx'
import BatchButton from '@/pages/system/menus/components/BatchButton.tsx'
import { useEffect, useRef } from 'react'
import TwoColPageLayout from '@/layout/TwoColPageLayout.tsx'
const Menus = () => {
const { styles, cx } = useStyle()
const { t } = useTranslation()
const [ form ] = Form.useForm()
const { mutate, isPending, error, isError } = useAtomValue(saveOrUpdateMenuAtom)
const { data = [] } = useAtomValue(menuDataAtom)
const [ currentMenu, setMenuData ] = useAtom<MenuItem>(selectedMenuAtom) ?? {}
const menuInputRef = useRef<InputRef | undefined>(undefined)
useEffect(() => {
if (isError) {
notification.error({
message: t('message.error', '错误'),
description: (error as any).message ?? t('message.saveFail', '保存失败'),
})
}
}, [ isError ])
useEffect(() => {
if (currentMenu.id === 0 && menuInputRef.current) {
menuInputRef.current.focus()
}
}, [ currentMenu ])
return (
<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.menus.title', '菜单')}
extra={
<>
<BatchButton/>
</>
}
>
<MenuTree 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 menu = {
...defaultMenu,
parent_id: currentMenu.id ?? 0,
}
setMenuData(menu)
form.setFieldsValue(menu)
}}
>{t('actions.news')}</Button>
</div>
</DraggablePanel>
<Flexbox className={styles.box}>
<Glass
enabled={currentMenu.id === undefined}
description={<>
<Alert
message={t('message.infoTitle', '提示')}
description={t('system.menus.form.empty', '请从左侧选择一行数据操作')}
type="info"
/>
</>}
>
<Form form={form}
initialValues={currentMenu!}
labelCol={{ flex: '110px' }}
labelAlign="left"
labelWrap
wrapperCol={{ flex: 1 }}
colon={false}
className={cx(styles.form, {
[styles.emptyForm]: currentMenu.id === undefined
})}
const { styles, cx } = useStyle()
const { t } = useTranslation()
const [ form ] = Form.useForm()
const { mutate, isPending, error, isError } = useAtomValue(saveOrUpdateMenuAtom)
const { data = [] } = useAtomValue(menuDataAtom)
const [ currentMenu, setMenuData ] = useAtom<MenuItem>(selectedMenuAtom) ?? {}
const menuInputRef = useRef<InputRef | undefined>(undefined)
useEffect(() => {
if (isError) {
notification.error({
message: t('message.error', '错误'),
description: (error as any).message ?? t('message.saveFail', '保存失败'),
})
}
}, [ isError ])
useEffect(() => {
if (currentMenu.id === 0 && menuInputRef.current) {
menuInputRef.current.focus()
}
}, [ currentMenu ])
return (
<TwoColPageLayout
leftPanel={<>
<ProCard title={t('system.menus.title', '菜单')}
extra={
<>
<BatchButton/>
</>
}
>
<MenuTree 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 menu = {
...defaultMenu,
parent_id: currentMenu.id ?? 0,
}
setMenuData(menu)
form.setFieldsValue(menu)
}}
>{t('actions.news')}</Button>
</div>
</>}
>
<ProCard title={t('system.menus.setting', '配置')}
className={styles.formSetting}
>
<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.menus.form.title', '菜单名称')} name={'title'}>
<Input ref={menuInputRef as any} placeholder={t('system.menus.form.title', '菜单名称')}/>
</Form.Item>
<Form.Item label={t('system.menus.form.parent', '上级菜单')} name={'parent_id'}>
<TreeSelect
treeData={[
{ id: 0, title: '顶级菜单', children: data as any },
]}
treeDefaultExpandAll={true}
fieldNames={{
label: 'title',
value: 'id'
}}/>
</Form.Item>
<Form.Item label={t('system.menus.form.type', '类型')} name={'type'}>
<Radio.Group
options={[
{
label: t('system.menus.form.typeOptions.menu', '菜单'),
value: 'menu'
},
{
label: t('system.menus.form.typeOptions.iframe', 'iframe'),
value: 'iframe'
},
{
label: t('system.menus.form.typeOptions.link', '外链'),
value: 'link'
},
{
label: t('system.menus.form.typeOptions.button', '按钮'),
value: 'button'
},
]}
optionType="button"
buttonStyle="solid"
/>
</Form.Item>
<Form.Item
rules={[
{ required: true, message: t('rules.required') }
]}
label={t('system.menus.form.name', '别名')} name={'name'}>
<Input placeholder={t('system.menus.form.name', '别名')}/>
</Form.Item>
<Form.Item label={t('system.menus.form.icon', '图标')} name={'icon'}>
<IconPicker placement={'left'}/>
</Form.Item>
<Form.Item label={t('system.menus.form.sort', '排序')} name={'sort'}>
<InputNumber/>
</Form.Item>
<Form.Item label={t('system.menus.form.path', '路由')} name={'path'}>
<Input/>
</Form.Item>
<Form.Item label={t('system.menus.form.component', '视图')}
name={'component'}
help={t('system.menus.form.componentHelp', '视图路径,相对于src/pages')}
<Glass
enabled={currentMenu.id === undefined}
description={<>
<Alert
message={t('message.infoTitle', '提示')}
description={t('system.menus.form.empty', '请从左侧选择一行数据操作')}
type="info"
/>
</>}
>
<Input addonBefore={'pages/'}/>
</Form.Item>
<Form.Item label={' '}>
<Button type="primary"
htmlType={'submit'}
loading={isPending}
onClick={() => {
form.validateFields().then((values) => {
mutate(values)
})
}}
>
{t('system.menus.form.save', '保存')}
</Button>
</Form.Item>
</ProCard>
<ProCard title={t('system.menus.button', '按钮')}
className={styles.formButtons}
colSpan={8}>
<Form.Item noStyle={true} name={'button'}
shouldUpdate={(prevValues: MenuItem, curValues) => {
return prevValues.id !== curValues.id
}}>
<ButtonTable form={form} key={(currentMenu as any).id}/>
</Form.Item>
</ProCard>
</Form>
</Glass>
</Flexbox>
</Flexbox>
</PageContainer>
)
<Form form={form}
initialValues={currentMenu!}
labelCol={{ flex: '110px' }}
labelAlign="left"
labelWrap
wrapperCol={{ flex: 1 }}
colon={false}
className={cx(styles.form, {
[styles.emptyForm]: currentMenu.id === undefined
})}
>
<ProCard title={t('system.menus.setting', '配置')}
className={styles.formSetting}
>
<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.menus.form.title', '菜单名称')} name={'title'}>
<Input ref={menuInputRef as any}
placeholder={t('system.menus.form.title', '菜单名称')}/>
</Form.Item>
<Form.Item label={t('system.menus.form.parent', '上级菜单')} name={'parent_id'}>
<TreeSelect
treeData={[
{ id: 0, title: '顶级菜单', children: data as any },
]}
treeDefaultExpandAll={true}
fieldNames={{
label: 'title',
value: 'id'
}}/>
</Form.Item>
<Form.Item label={t('system.menus.form.type', '类型')} name={'type'}>
<Radio.Group
options={[
{
label: t('system.menus.form.typeOptions.menu', '菜单'),
value: 'menu'
},
{
label: t('system.menus.form.typeOptions.iframe', 'iframe'),
value: 'iframe'
},
{
label: t('system.menus.form.typeOptions.link', '外链'),
value: 'link'
},
{
label: t('system.menus.form.typeOptions.button', '按钮'),
value: 'button'
},
]}
optionType="button"
buttonStyle="solid"
/>
</Form.Item>
<Form.Item
rules={[
{ required: true, message: t('rules.required') }
]}
label={t('system.menus.form.name', '别名')} name={'name'}>
<Input placeholder={t('system.menus.form.name', '别名')}/>
</Form.Item>
<Form.Item label={t('system.menus.form.icon', '图标')} name={'icon'}>
<IconPicker placement={'left'}/>
</Form.Item>
<Form.Item label={t('system.menus.form.sort', '排序')} name={'sort'}>
<InputNumber/>
</Form.Item>
<Form.Item label={t('system.menus.form.path', '路由')} name={'path'}>
<Input/>
</Form.Item>
<Form.Item label={t('system.menus.form.component', '视图')}
name={'component'}
help={t('system.menus.form.componentHelp', '视图路径,相对于src/pages')}
>
<Input addonBefore={'pages/'}/>
</Form.Item>
<Form.Item label={' '}>
<Button type="primary"
htmlType={'submit'}
loading={isPending}
onClick={() => {
form.validateFields().then((values) => {
mutate(values)
})
}}
>
{t('system.menus.form.save', '保存')}
</Button>
</Form.Item>
</ProCard>
<ProCard title={t('system.menus.button', '按钮')}
className={styles.formButtons}
colSpan={8}>
<Form.Item noStyle={true} name={'button'}
shouldUpdate={(prevValues: MenuItem, curValues) => {
return prevValues.id !== curValues.id
}}>
<ButtonTable form={form} key={(currentMenu as any).id}/>
</Form.Item>
</ProCard>
</Form>
</Glass>
</TwoColPageLayout>
)
}
export default Menus

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

@ -88,9 +88,12 @@ const Roles = memo(() => {
},
{
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',
@ -144,81 +147,84 @@ const Roles = memo(() => {
return (
<PageContainer breadcrumbRender={false} title={false} className={styles.container}>
<ProTable
rowKey={'id'}
actionRef={actionRef}
headerTitle={t('system.roles.title', '角色管理')}
columns={columns}
loading={isLoading || isFetching}
dataSource={data?.rows}
search={false}
rowSelection={{
onChange: (selectedRowKeys) => {
setRoleIds(selectedRowKeys as number[])
},
selectedRowKeys: roleIds,
selections: [ Table.SELECTION_ALL, Table.SELECTION_INVERT ],
}}
tableAlertOptionRender={() => {
return (
<Space size={16}>
<Popconfirm
onConfirm={() => {
deleteRole(roleIds)
<div className={styles.authHeight}>
<ProTable
rowKey={'id'}
actionRef={actionRef}
headerTitle={t('system.roles.title', '角色管理')}
columns={columns}
loading={isLoading || isFetching}
dataSource={data?.rows}
search={false}
rowSelection={{
onChange: (selectedRowKeys) => {
setRoleIds(selectedRowKeys as number[])
},
selectedRowKeys: roleIds,
selections: [ Table.SELECTION_ALL, Table.SELECTION_INVERT ],
}}
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.roles.search.placeholder')
},
actions: [
<Button
key="button"
icon={<PlusOutlined/>}
onClick={() => {
form.resetFields()
form.setFieldsValue({
id: 0,
})
setOpen(true)
}}
type="primary"
>
{t('actions.add', '添加')}
</Button>,
]
}}
pagination={{
total: data?.total,
current: page.page,
pageSize: page.pageSize,
onChange: (page) => {
tableAlertOptionRender={() => {
return (
<Space size={16}>
<Popconfirm
onConfirm={() => {
deleteRole(roleIds)
}}
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.roles.search.placeholder')
},
actions: [
<Button
key="button"
icon={<PlusOutlined/>}
onClick={() => {
form.resetFields()
form.setFieldsValue({
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 }
})
}
}}
/>
</div>
setPage((prev) => {
return { ...prev, page }
})
}
}}
/>
<BetaSchemaForm
width={600}
form={form}
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 }}
labelCol={{ span: 6 }}
wrapperCol={{ span: 16 }}

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

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

63
src/service/system.ts

@ -8,32 +8,47 @@ import { IRole } from '@/types/roles'
import { IDepartment } from '@/types/department'
const systemServ = {
dept: {
...createCURD<any, IDepartment>('/sys/dept'),
tree: () => {
return request.get<{ tree: IDepartment }>('/sys/dept/tree')
}
},
menus: {
...createCURD<any, IMenu>('/sys/menu')
},
login: (data: LoginRequest) => {
return request.post<LoginResponse>('/sys/login', data)
},
user: {
...createCURD<any, IUser>('/sys/user'),
current: () => {
return request.get<IUserInfo>('/sys/user/info')
dept: {
...createCURD<any, IDepartment>('/sys/dept'),
tree: () => {
return request.get<{ tree: IDepartment }>('/sys/dept/tree')
}
},
menus: () => {
return request.get<IPageResult<IMenu[]>>('/sys/user/menus')
menus: {
...createCURD<any, IMenu>('/sys/menu')
},
},
role: {
...createCURD<any, IRole>('/sys/role')
}
login: (data: LoginRequest) => {
return request.post<LoginResponse>('/sys/login', data)
},
logout:()=>{
//
},
user: {
...createCURD<any, IUser>('/sys/user'),
current: () => {
return request.get<IUserInfo>('/sys/user/info')
},
menus: () => {
return request.get<IPageResult<IMenu[]>>('/sys/user/menus')
},
resetPassword: (id: number) => {
return request.post<any>(`/sys/user/reset/password`, { id })
}
},
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,
})
})

48
src/store/system.ts

@ -1,8 +1,7 @@
import { IAppData } from '@/global'
import { createStore } from 'jotai'
import { createStore } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
import { changeLanguage as setLang } from 'i18next'
import { userMenuDataAtom } from '@/store/user.ts'
/**
* app全局状态
@ -10,48 +9,45 @@ import { userMenuDataAtom } from '@/store/user.ts'
export const appStore = createStore()
export const appAtom = atomWithStorage<Partial<IAppData>>('app', {
name: 'Crazy Pro',
version: '1.0.0',
language: 'zh-CN',
name: 'Crazy Pro',
version: '1.0.0',
language: 'zh-CN',
})
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
})
export const getAppData = () => {
return appStore.get(appAtom)
return appStore.get(appAtom)
}
export const changeLanguage = (lang: string, reload?: boolean) => {
if (appStore.get(appAtom).language !== lang) {
setLang(lang)
updateAppData({ language: lang })
if (reload) {
window.location.reload()
if (appStore.get(appAtom).language !== lang) {
setLang(lang)
updateAppData({ language: lang })
if (reload) {
window.location.reload()
}
}
}
}
export const updateAppData = (app: Partial<IAppData>) => {
appStore.set(appAtom, (prev) => {
return {
...prev,
...app,
}
})
appStore.set(appAtom, (prev) => {
return {
...prev,
...app,
}
})
}
export const getToken = () => {
return appStore.get(appAtom).token
return appStore.get(appAtom).token
}
export const setToken = (token: string) => {
updateAppData({ token })
console.log('settoken', 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 { IUserInfo } from '@/types/user'
import { IUser, IUserInfo } from '@/types/user'
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 { atomWithMutation, atomWithQuery } from 'jotai-tanstack-query'
import { atomWithMutation, atomWithQuery, queryClientAtom } from 'jotai-tanstack-query'
import systemServ from '@/service/system.ts'
import { formatMenuData, isDev } from '@/utils'
import { message } from 'antd'
import { t } from 'i18next'
export const authAtom = atom<IAuth>({
isLogin: false,
@ -22,17 +24,28 @@ export const loginFormAtom = atom<LoginRequest>({
...(isDev ? devLogin : {})
} as LoginRequest)
export const loginAtom = atomWithMutation<any, LoginRequest>(() => ({
export const loginAtom = atomWithMutation<any, LoginRequest>((get) => ({
mutationKey: [ 'login' ],
mutationFn: async (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,
}))
export const logoutAtom = atomWithMutation(() => ({
mutationKey: [ 'logout' ],
mutationFn: async () => {
setToken('')
return true
},
}))
export const currentUserAtom = atomWithQuery<IApiResult<IUserInfo>, any, IUserInfo>((get) => {
return {
queryKey: [ 'user_info', get(appAtom).token ],
@ -54,24 +67,100 @@ export const userMenuDataAtom = atomWithQuery<IApiResult<IPageResult<IMenu[]>>,
select: (data) => {
return formatMenuData(data.data.rows as any ?? [])
},
cacheTime: 1000 * 60,
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) => {
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) => {
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_by: number,
username: string,
role_id: number,
dept_id: number,
dept_name: string,
name: string,

129
src/utils/tree.ts

@ -1,59 +1,96 @@
import { FiledNames } from '@/global'
type TreeKey = string | number;
type TreeNode<T> = {
[key in keyof T]: T[keyof T];
[key in keyof T]: T[keyof T];
} & {
key: TreeKey;
id?: TreeKey;
children?: TreeNode<T>[];
key: TreeKey;
id?: TreeKey;
children?: TreeNode<T>[];
};
export function getTreeCheckedStatus<T>(tree: TreeNode<T>[], selectKeys: TreeKey[]): {
checked: TreeKey[],
halfChecked: TreeKey[]
checked: TreeKey[],
halfChecked: TreeKey[]
} {
const checked: TreeKey[] = []
const halfChecked: TreeKey[] = []
if (!tree || tree.length === 0) return { checked, halfChecked }
if (!selectKeys || selectKeys.length === 0) return { checked, halfChecked }
// 辅助函数来递归地检查每个节点
function checkNode(node: TreeNode<T>, ancestors: TreeKey[]): void {
const key = node.key ?? node.id
const isLeaf = !node.children || node.children.length === 0
const isSelected = selectKeys.includes(key)
// 如果是叶节点并且被选中,则直接加入到checked数组
if (isLeaf && isSelected) {
checked.push(key)
// 标记所有祖先为半选状态,除非它们已经被完全选中
ancestors.forEach(ancestorKey => {
if (!halfChecked.includes(ancestorKey) && !checked.includes(ancestorKey)) {
halfChecked.push(ancestorKey)
const checked: TreeKey[] = []
const halfChecked: TreeKey[] = []
if (!tree || tree.length === 0) return { checked, halfChecked }
if (!selectKeys || selectKeys.length === 0) return { checked, halfChecked }
// 辅助函数来递归地检查每个节点
function checkNode(node: TreeNode<T>, ancestors: TreeKey[]): void {
const key = node.key ?? node.id
const isLeaf = !node.children || node.children.length === 0
const isSelected = selectKeys.includes(key)
// 如果是叶节点并且被选中,则直接加入到checked数组
if (isLeaf && isSelected) {
checked.push(key)
// 标记所有祖先为半选状态,除非它们已经被完全选中
ancestors.forEach(ancestorKey => {
if (!halfChecked.includes(ancestorKey) && !checked.includes(ancestorKey)) {
halfChecked.push(ancestorKey)
}
})
return
}
})
return
}
// 非叶节点,递归检查其子节点
if (node.children) {
const childAncestors = [ ...ancestors, key ]
node.children.forEach(child => checkNode(child, childAncestors))
// 检查当前节点的所有子节点是否全部或部分被选中
const childSelectedCount = node.children.filter(child => checked.includes(child.key ?? child.id)).length
if (childSelectedCount === node.children.length) {
// 如果所有子节点都被选中,将当前节点标为全选
checked.push(key)
} else if (childSelectedCount > 0) {
// 如果部分子节点被选中,将当前节点标为半选
halfChecked.push(key)
}
// 非叶节点,递归检查其子节点
if (node.children) {
const childAncestors = [ ...ancestors, key ]
node.children.forEach(child => checkNode(child, childAncestors))
// 检查当前节点的所有子节点是否全部或部分被选中
const childSelectedCount = node.children.filter(child => checked.includes(child.key ?? child.id)).length
if (childSelectedCount === node.children.length) {
// 如果所有子节点都被选中,将当前节点标为全选
checked.push(key)
} else if (childSelectedCount > 0) {
// 如果部分子节点被选中,将当前节点标为半选
halfChecked.push(key)
}
}
}
}
// 遍历每一个根节点
tree.forEach(node => checkNode(node, []))
return { checked, halfChecked }
// 遍历每一个根节点
tree.forEach(node => checkNode(node, []))
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