dark
7 months ago
33 changed files with 1437 additions and 594 deletions
-
1.gitignore
-
35src/components/cascader/Cascader.tsx
-
1src/components/cascader/index.ts
-
39src/components/department-tree/DepartmentCascader.tsx
-
17src/components/department-tree/DepartmentTree.tsx
-
2src/components/department-tree/index.ts
-
41src/components/error/404.tsx
-
4src/components/icon/index.tsx
-
2src/components/icon/picker/index.tsx
-
52src/components/role-picker/RolePicker.tsx
-
0src/components/role-picker/index.ts
-
10src/components/switch/index.tsx
-
68src/i18n.ts
-
20src/layout/TreePageLayout.tsx
-
39src/layout/TwoColPageLayout.tsx
-
12src/layout/style.ts
-
166src/pages/system/departments/index.tsx
-
79src/pages/system/departments/style.ts
-
144src/pages/system/logs/login/index.tsx
-
14src/pages/system/logs/login/style.ts
-
371src/pages/system/menus/index.tsx
-
150src/pages/system/roles/index.tsx
-
7src/pages/system/roles/style.ts
-
243src/pages/system/users/index.tsx
-
71src/pages/system/users/style.ts
-
63src/service/system.ts
-
58src/store/logs.ts
-
12src/store/route.ts
-
48src/store/system.ts
-
121src/store/user.ts
-
11src/types/logs.d.ts
-
1src/types/user.d.ts
-
127src/utils/tree.ts
@ -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 |
@ -0,0 +1 @@ |
|||||
|
export * from './Cascader.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> |
||||
|
) |
||||
|
} |
@ -0,0 +1,2 @@ |
|||||
|
export * from './DepartmentTree' |
||||
|
export * from './DepartmentCascader.tsx' |
@ -1,27 +1,34 @@ |
|||||
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 { 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 |
export default NotFound |
@ -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 |
@ -1,51 +1,51 @@ |
|||||
import { changeLanguage } from '@/store/system.ts' |
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 LanguageDetector from 'i18next-browser-languagedetector' |
||||
import { initReactI18next, useTranslation, } from 'react-i18next' |
|
||||
|
import { initReactI18next, useTranslation, } from 'react-i18next' |
||||
import { zh, en } from './locales' |
import { zh, en } from './locales' |
||||
|
|
||||
const detectionOptions = { |
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) => { |
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 { |
export { |
||||
useTranslation, t |
|
||||
|
useTranslation, t |
||||
} |
} |
||||
export default i18n |
export default i18n |
@ -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, |
|
||||
}) |
|
@ -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 |
@ -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 |
@ -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, |
||||
|
} |
||||
|
}) |
@ -1,211 +1,204 @@ |
|||||
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 = () => { |
||||
|
|
||||
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 |
export default Menus |
@ -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> |
||||
) |
) |
||||
} |
} |
||||
|
|
||||
|
@ -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 |
||||
|
} |
||||
|
}) |
@ -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() |
||||
|
} |
||||
|
})) |
@ -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, |
|
||||
}) |
|
||||
}) |
|
@ -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,59 +1,96 @@ |
|||||
|
import { FiledNames } from '@/global' |
||||
|
|
||||
type TreeKey = string | number; |
type TreeKey = string | number; |
||||
|
|
||||
type TreeNode<T> = { |
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[]): { |
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 |
||||
|
} |
||||
|
|
||||
|
// 非叶节点,递归检查其子节点
|
||||
|
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) |
||||
|
} |
||||
} |
} |
||||
}) |
|
||||
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) |
|
||||
} |
|
||||
|
// 遍历每一个根节点
|
||||
|
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 |
||||
|
} |
||||
} |
} |
||||
} |
|
||||
|
|
||||
// 遍历每一个根节点
|
|
||||
tree.forEach(node => checkNode(node, [])) |
|
||||
return { checked, halfChecked } |
|
||||
|
return null // 如果未找到目标值,则返回null
|
||||
} |
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue