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 { 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 |
@ -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 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 |
@ -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 { 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 |
@ -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> |
|||
) |
|||
} |
|||
|
|||
|
@ -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 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 |
|||
} |
|||
|
|||
// 非叶节点,递归检查其子节点
|
|||
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