@fe-free/core 2.5.0 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/package.json +2 -2
- package/src/crud/types.tsx +1 -1
- package/src/file/file.stories.tsx +53 -0
- package/src/file/helper.tsx +128 -0
- package/src/file/icon/AudioIcon.tsx +20 -0
- package/src/file/icon/VideoIcon.tsx +20 -0
- package/src/file/index.tsx +68 -0
- package/src/index.ts +3 -2
- package/src/tree/file_tree.stories.tsx +129 -0
- package/src/tree/file_tree.tsx +193 -0
- package/src/tree/index.tsx +5 -152
- package/src/tree/style.scss +28 -0
- package/src/tree/tree.stories.tsx +111 -34
- package/src/tree/tree.tsx +201 -0
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fe-free/core",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.1",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"author": "",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"remark-gfm": "^4.0.1",
|
|
42
42
|
"vanilla-jsoneditor": "^0.23.1",
|
|
43
43
|
"zustand": "^4.5.4",
|
|
44
|
-
"@fe-free/tool": "2.5.
|
|
44
|
+
"@fe-free/tool": "2.5.1"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"@ant-design/pro-components": "2.8.9",
|
package/src/crud/types.tsx
CHANGED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { FileCard } from '@fe-free/tool';
|
|
2
|
+
import type { StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import { PRESET_FILE_ICONS } from './helper';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: '@fe-free/file/FileCard',
|
|
7
|
+
component: FileCard,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export default meta;
|
|
12
|
+
type Story = StoryObj<typeof FileCard>;
|
|
13
|
+
|
|
14
|
+
export const Default: Story = {
|
|
15
|
+
render: () => {
|
|
16
|
+
let size = 0;
|
|
17
|
+
return (
|
|
18
|
+
<div>
|
|
19
|
+
<div className="flex flex-col gap-2">
|
|
20
|
+
{PRESET_FILE_ICONS.map((item) => (
|
|
21
|
+
<div key={item.key} className="flex gap-2">
|
|
22
|
+
<FileCard name={`这是文件名.${item.ext.join('.') || ''}`} size={(size += 1000000)} />
|
|
23
|
+
</div>
|
|
24
|
+
))}
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const Direction: Story = {
|
|
32
|
+
render: () => (
|
|
33
|
+
<div>
|
|
34
|
+
<FileCard name="这是文件名.pdf" size={10000} direction="vertical" />
|
|
35
|
+
</div>
|
|
36
|
+
),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const FileIcon: Story = {
|
|
40
|
+
render: () => (
|
|
41
|
+
<div>
|
|
42
|
+
<FileCard.FileIcon name="这是文件名.pdf" className="text-3xl" />
|
|
43
|
+
</div>
|
|
44
|
+
),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const FileIconExt: Story = {
|
|
48
|
+
render: () => (
|
|
49
|
+
<div>
|
|
50
|
+
<FileCard.FileIcon name="这是文件名.xlsx" showExt className="text-5xl" />
|
|
51
|
+
</div>
|
|
52
|
+
),
|
|
53
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FileExcelFilled,
|
|
3
|
+
FileImageFilled,
|
|
4
|
+
FileMarkdownFilled,
|
|
5
|
+
FilePdfFilled,
|
|
6
|
+
FilePptFilled,
|
|
7
|
+
FileTextFilled,
|
|
8
|
+
FileWordFilled,
|
|
9
|
+
FileZipFilled,
|
|
10
|
+
FolderFilled,
|
|
11
|
+
} from '@ant-design/icons';
|
|
12
|
+
import AudioIcon from './icon/AudioIcon';
|
|
13
|
+
import VideoIcon from './icon/VideoIcon';
|
|
14
|
+
|
|
15
|
+
type PresetIcons =
|
|
16
|
+
| 'default'
|
|
17
|
+
| 'excel'
|
|
18
|
+
| 'image'
|
|
19
|
+
| 'markdown'
|
|
20
|
+
| 'pdf'
|
|
21
|
+
| 'ppt'
|
|
22
|
+
| 'word'
|
|
23
|
+
| 'zip'
|
|
24
|
+
| 'video'
|
|
25
|
+
| 'audio'
|
|
26
|
+
| 'folder';
|
|
27
|
+
|
|
28
|
+
const IMG_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg'];
|
|
29
|
+
|
|
30
|
+
const DEFAULT_ICON_COLOR = '#8c8c8c';
|
|
31
|
+
const DEFAULT_ICON = <FileTextFilled />;
|
|
32
|
+
|
|
33
|
+
const PRESET_FILE_ICONS: {
|
|
34
|
+
key: PresetIcons;
|
|
35
|
+
ext: string[];
|
|
36
|
+
color: string;
|
|
37
|
+
icon: React.ReactElement;
|
|
38
|
+
}[] = [
|
|
39
|
+
{
|
|
40
|
+
key: 'default',
|
|
41
|
+
icon: DEFAULT_ICON,
|
|
42
|
+
color: DEFAULT_ICON_COLOR,
|
|
43
|
+
ext: [],
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
key: 'excel',
|
|
47
|
+
icon: <FileExcelFilled />,
|
|
48
|
+
color: '#22b35e',
|
|
49
|
+
ext: ['xlsx', 'xls'],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
key: 'image',
|
|
53
|
+
icon: <FileImageFilled />,
|
|
54
|
+
color: 'rgb(22, 119, 255)',
|
|
55
|
+
ext: IMG_EXTS,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
key: 'markdown',
|
|
59
|
+
icon: <FileMarkdownFilled />,
|
|
60
|
+
color: DEFAULT_ICON_COLOR,
|
|
61
|
+
ext: ['md', 'mdx'],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
key: 'pdf',
|
|
65
|
+
icon: <FilePdfFilled />,
|
|
66
|
+
color: '#ff4d4f',
|
|
67
|
+
ext: ['pdf'],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
key: 'ppt',
|
|
71
|
+
icon: <FilePptFilled />,
|
|
72
|
+
color: '#ff6e31',
|
|
73
|
+
ext: ['ppt', 'pptx'],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
key: 'word',
|
|
77
|
+
icon: <FileWordFilled />,
|
|
78
|
+
color: '#1677ff',
|
|
79
|
+
ext: ['doc', 'docx'],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
key: 'zip',
|
|
83
|
+
icon: <FileZipFilled />,
|
|
84
|
+
color: '#fab714',
|
|
85
|
+
ext: ['zip', 'rar', '7z', 'tar', 'gz'],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
key: 'video',
|
|
89
|
+
icon: <VideoIcon />,
|
|
90
|
+
color: '#ff4d4f',
|
|
91
|
+
ext: ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'],
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
key: 'audio',
|
|
95
|
+
icon: <AudioIcon />,
|
|
96
|
+
color: '#FFDC00',
|
|
97
|
+
ext: ['mp3', 'wav', 'flac', 'ape', 'aac', 'ogg'],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
key: 'folder',
|
|
101
|
+
icon: <FolderFilled />,
|
|
102
|
+
color: '#FFDC00',
|
|
103
|
+
ext: [],
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
function isImage(name: string) {
|
|
108
|
+
return IMG_EXTS.includes(name.split('.').pop() || '');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function getFileExt(name?: string) {
|
|
112
|
+
return name?.split('.').pop() || '';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getFileSize(size: number) {
|
|
116
|
+
if (size < 1024) {
|
|
117
|
+
return size + 'B';
|
|
118
|
+
}
|
|
119
|
+
if (size < 1024 * 1024) {
|
|
120
|
+
return (size / 1024).toFixed(2) + 'KB';
|
|
121
|
+
}
|
|
122
|
+
if (size < 1024 * 1024 * 1024) {
|
|
123
|
+
return (size / 1024 / 1024).toFixed(2) + 'MB';
|
|
124
|
+
}
|
|
125
|
+
return (size / 1024 / 1024 / 1024).toFixed(2) + 'GB';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export { DEFAULT_ICON, DEFAULT_ICON_COLOR, getFileExt, getFileSize, isImage, PRESET_FILE_ICONS };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export default function AudioIcon() {
|
|
2
|
+
return (
|
|
3
|
+
<svg
|
|
4
|
+
width="1em"
|
|
5
|
+
height="1em"
|
|
6
|
+
viewBox="0 0 16 16"
|
|
7
|
+
version="1.1"
|
|
8
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
9
|
+
//xmlnsXlink="http://www.w3.org/1999/xlink"
|
|
10
|
+
>
|
|
11
|
+
<title>audio</title>
|
|
12
|
+
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
|
|
13
|
+
<path
|
|
14
|
+
d="M14.1178571,4.0125 C14.225,4.11964286 14.2857143,4.26428571 14.2857143,4.41607143 L14.2857143,15.4285714 C14.2857143,15.7446429 14.0303571,16 13.7142857,16 L2.28571429,16 C1.96964286,16 1.71428571,15.7446429 1.71428571,15.4285714 L1.71428571,0.571428571 C1.71428571,0.255357143 1.96964286,0 2.28571429,0 L9.86964286,0 C10.0214286,0 10.1678571,0.0607142857 10.275,0.167857143 L14.1178571,4.0125 Z M10.7315824,7.11216117 C10.7428131,7.15148751 10.7485063,7.19218979 10.7485063,7.23309113 L10.7485063,8.07742614 C10.7484199,8.27364959 10.6183424,8.44607275 10.4296853,8.50003683 L8.32984514,9.09986306 L8.32984514,11.7071803 C8.32986605,12.5367078 7.67249692,13.217028 6.84345686,13.2454634 L6.79068592,13.2463395 C6.12766108,13.2463395 5.53916361,12.8217001 5.33010655,12.1924966 C5.1210495,11.563293 5.33842118,10.8709227 5.86959669,10.4741173 C6.40077221,10.0773119 7.12636292,10.0652587 7.67042486,10.4442027 L7.67020842,7.74937024 L7.68449368,7.74937024 C7.72405122,7.59919041 7.83988806,7.48101083 7.98924584,7.4384546 L10.1880418,6.81004755 C10.42156,6.74340323 10.6648954,6.87865515 10.7315824,7.11216117 Z M9.60714286,1.31785714 L12.9678571,4.67857143 L9.60714286,4.67857143 L9.60714286,1.31785714 Z"
|
|
15
|
+
fill="currentColor"
|
|
16
|
+
/>
|
|
17
|
+
</g>
|
|
18
|
+
</svg>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export default function VideoIcon() {
|
|
2
|
+
return (
|
|
3
|
+
<svg
|
|
4
|
+
width="1em"
|
|
5
|
+
height="1em"
|
|
6
|
+
viewBox="0 0 16 16"
|
|
7
|
+
version="1.1"
|
|
8
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
9
|
+
// xmlnsXlink="http://www.w3.org/1999/xlink"
|
|
10
|
+
>
|
|
11
|
+
<title>video</title>
|
|
12
|
+
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
|
|
13
|
+
<path
|
|
14
|
+
d="M14.1178571,4.0125 C14.225,4.11964286 14.2857143,4.26428571 14.2857143,4.41607143 L14.2857143,15.4285714 C14.2857143,15.7446429 14.0303571,16 13.7142857,16 L2.28571429,16 C1.96964286,16 1.71428571,15.7446429 1.71428571,15.4285714 L1.71428571,0.571428571 C1.71428571,0.255357143 1.96964286,0 2.28571429,0 L9.86964286,0 C10.0214286,0 10.1678571,0.0607142857 10.275,0.167857143 L14.1178571,4.0125 Z M12.9678571,4.67857143 L9.60714286,1.31785714 L9.60714286,4.67857143 L12.9678571,4.67857143 Z M10.5379461,10.3101106 L6.68957555,13.0059749 C6.59910784,13.0693494 6.47439406,13.0473861 6.41101953,12.9569184 C6.3874624,12.9232903 6.37482581,12.8832269 6.37482581,12.8421686 L6.37482581,7.45043999 C6.37482581,7.33998304 6.46436886,7.25043999 6.57482581,7.25043999 C6.61588409,7.25043999 6.65594753,7.26307658 6.68957555,7.28663371 L10.5379461,9.98249803 C10.6284138,10.0458726 10.6503772,10.1705863 10.5870027,10.2610541 C10.5736331,10.2801392 10.5570312,10.2967411 10.5379461,10.3101106 Z"
|
|
15
|
+
fill="currentColor"
|
|
16
|
+
/>
|
|
17
|
+
</g>
|
|
18
|
+
</svg>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import classNames from 'classnames';
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_ICON,
|
|
5
|
+
DEFAULT_ICON_COLOR,
|
|
6
|
+
getFileExt,
|
|
7
|
+
getFileSize,
|
|
8
|
+
PRESET_FILE_ICONS,
|
|
9
|
+
} from './helper';
|
|
10
|
+
|
|
11
|
+
function FileIcon({
|
|
12
|
+
name,
|
|
13
|
+
isDirectory,
|
|
14
|
+
className,
|
|
15
|
+
showExt = false,
|
|
16
|
+
}: {
|
|
17
|
+
name?: string;
|
|
18
|
+
isDirectory?: boolean;
|
|
19
|
+
className?: string;
|
|
20
|
+
showExt?: boolean;
|
|
21
|
+
}) {
|
|
22
|
+
const ext = getFileExt(name) || '';
|
|
23
|
+
|
|
24
|
+
const iconItem = useMemo(() => {
|
|
25
|
+
if (isDirectory) {
|
|
26
|
+
return PRESET_FILE_ICONS.find((item) => item.key === 'folder');
|
|
27
|
+
}
|
|
28
|
+
return PRESET_FILE_ICONS.find((item) => item.ext.includes(ext));
|
|
29
|
+
}, [ext, isDirectory]);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className={classNames('flex flex-col items-center', className)}>
|
|
33
|
+
<div style={{ color: iconItem?.color || DEFAULT_ICON_COLOR }}>
|
|
34
|
+
{iconItem?.icon || DEFAULT_ICON}
|
|
35
|
+
</div>
|
|
36
|
+
{showExt && <div className="text-base">{ext}</div>}
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function FileCard({
|
|
42
|
+
name,
|
|
43
|
+
size,
|
|
44
|
+
direction = 'horizontal',
|
|
45
|
+
}: {
|
|
46
|
+
name?: string;
|
|
47
|
+
size?: number;
|
|
48
|
+
direction?: 'horizontal' | 'vertical';
|
|
49
|
+
}) {
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
className={classNames('flex items-center gap-1', {
|
|
53
|
+
'flex-row items-center': direction === 'horizontal',
|
|
54
|
+
'flex-col': direction === 'vertical',
|
|
55
|
+
})}
|
|
56
|
+
>
|
|
57
|
+
<FileIcon name={name} className="text-4xl" />
|
|
58
|
+
<div className={classNames('flex flex-col', { 'items-center': direction === 'vertical' })}>
|
|
59
|
+
{name && <div className="truncate">{name}</div>}
|
|
60
|
+
{size && <div className="text-sm text-desc">{getFileSize(size)}</div>}
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
FileCard.FileIcon = FileIcon;
|
|
67
|
+
|
|
68
|
+
export { FileCard };
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ export { EditorLogs } from './editor_logs';
|
|
|
16
16
|
export type { EditorLogsProps } from './editor_logs';
|
|
17
17
|
export { EditorMention } from './editor_mention';
|
|
18
18
|
export type { EditorMentionProps } from './editor_mention';
|
|
19
|
+
export { FileCard } from './file';
|
|
19
20
|
export {
|
|
20
21
|
ProFormEditor,
|
|
21
22
|
ProFormImageUpload,
|
|
@@ -42,7 +43,7 @@ export { Table } from './table';
|
|
|
42
43
|
export type { TableProps } from './table';
|
|
43
44
|
export { Tabs } from './tabs';
|
|
44
45
|
export type { TabsProps } from './tabs';
|
|
45
|
-
export { Tree, flatToTreeData } from './tree';
|
|
46
|
-
export type { TreeProps } from './tree';
|
|
46
|
+
export { FileTree, Tree, flatToTreeData } from './tree';
|
|
47
|
+
export type { FileTreeProps, TreeProps } from './tree';
|
|
47
48
|
export { useLocalforageState } from './use_localforage_state';
|
|
48
49
|
export { CustomValueTypeEnum, customValueTypeMap } from './value_type_map';
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { FileTree } from '@fe-free/core';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof FileTree> = {
|
|
5
|
+
title: '@fe-free/core/FileTree',
|
|
6
|
+
component: FileTree,
|
|
7
|
+
tags: ['autodocs'],
|
|
8
|
+
decorators: [
|
|
9
|
+
(Story) => {
|
|
10
|
+
return (
|
|
11
|
+
<div className="c-border w-[200px] overflow-y-auto">
|
|
12
|
+
<Story />
|
|
13
|
+
</div>
|
|
14
|
+
);
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default meta;
|
|
20
|
+
type Story = StoryObj<typeof meta>;
|
|
21
|
+
|
|
22
|
+
export const Default: Story = {
|
|
23
|
+
args: {
|
|
24
|
+
title: '某某公司',
|
|
25
|
+
enableSearch: true,
|
|
26
|
+
actions: ['create', 'update', 'delete'],
|
|
27
|
+
requestCreateByValues: (values) => {
|
|
28
|
+
console.log(values);
|
|
29
|
+
return Promise.resolve();
|
|
30
|
+
},
|
|
31
|
+
requestUpdateByValues: (values) => {
|
|
32
|
+
console.log(values);
|
|
33
|
+
return Promise.resolve();
|
|
34
|
+
},
|
|
35
|
+
requestDeleteByRecord: (values) => {
|
|
36
|
+
console.log(values);
|
|
37
|
+
return Promise.resolve();
|
|
38
|
+
},
|
|
39
|
+
treeProps: {
|
|
40
|
+
defaultExpandAll: true,
|
|
41
|
+
treeData: [
|
|
42
|
+
{
|
|
43
|
+
title: '我的桌面',
|
|
44
|
+
key: '1',
|
|
45
|
+
children: [
|
|
46
|
+
{ title: '资料1', key: '1-0', children: [] },
|
|
47
|
+
{
|
|
48
|
+
title: '资料2',
|
|
49
|
+
key: '1-1',
|
|
50
|
+
children: [
|
|
51
|
+
{ title: '资料2-1资料2-1资料2-1资料2-1', key: '1-1-0' },
|
|
52
|
+
{ title: '资料2-2', key: '1-1-1' },
|
|
53
|
+
{ title: '资料2-3', key: '1-1-2' },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
{ title: '资料3', key: '1-2', children: [] },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
title: '我的文档',
|
|
62
|
+
key: '2',
|
|
63
|
+
children: [
|
|
64
|
+
{ title: '资料1', key: '2-0', children: [] },
|
|
65
|
+
{
|
|
66
|
+
title: '资料2',
|
|
67
|
+
key: '2-1',
|
|
68
|
+
children: [
|
|
69
|
+
{ title: '资料2-1', key: '2-1-0' },
|
|
70
|
+
{ title: '资料2-2', key: '2-1-1' },
|
|
71
|
+
{ title: '资料2-3', key: '2-1-2' },
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
{ title: '资料3', key: '2-0-2', children: [] },
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
title: '我的下载',
|
|
79
|
+
key: '3',
|
|
80
|
+
children: [
|
|
81
|
+
{ title: '资料1', key: '3-0', children: [] },
|
|
82
|
+
{
|
|
83
|
+
title: '资料2',
|
|
84
|
+
key: '3-1',
|
|
85
|
+
children: [],
|
|
86
|
+
},
|
|
87
|
+
{ title: '资料3', key: '3-2', children: [] },
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
title: '周杰伦.jpg',
|
|
92
|
+
key: '4',
|
|
93
|
+
children: [],
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const FileList: Story = {
|
|
101
|
+
args: {
|
|
102
|
+
title: '某某目录',
|
|
103
|
+
enableSearch: true,
|
|
104
|
+
treeProps: {
|
|
105
|
+
treeData: [
|
|
106
|
+
{
|
|
107
|
+
title: '周杰伦.jpg',
|
|
108
|
+
key: '1',
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
title: '周杰伦.mp3',
|
|
112
|
+
key: '2',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
title: '周杰伦.mp4',
|
|
116
|
+
key: '3',
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
title: '周杰伦.pdf',
|
|
120
|
+
key: '4',
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
title: '周杰伦.doc',
|
|
124
|
+
key: '5',
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { MoreOutlined, PlusOutlined } from '@ant-design/icons';
|
|
2
|
+
import { ModalForm, ProFormText } from '@ant-design/pro-components';
|
|
3
|
+
import { Dropdown } from 'antd';
|
|
4
|
+
import type { DataNode } from 'antd/es/tree';
|
|
5
|
+
import classNames from 'classnames';
|
|
6
|
+
import { useCallback, useMemo } from 'react';
|
|
7
|
+
import { OperateDelete } from '../crud/crud_delete';
|
|
8
|
+
import { FileCard } from '../file';
|
|
9
|
+
import type { TreeProps } from './tree';
|
|
10
|
+
import { Tree } from './tree';
|
|
11
|
+
|
|
12
|
+
type FileTreeAction = 'create' | 'update' | 'delete';
|
|
13
|
+
|
|
14
|
+
interface FileTreeProps<D extends DataNode> extends TreeProps<D> {
|
|
15
|
+
actions?: FileTreeAction[];
|
|
16
|
+
requestCreateByValues?: (values: { key?: string; title: string }) => Promise<false | void>;
|
|
17
|
+
requestUpdateByValues?: (values: { key: string; title: string }) => Promise<false | void>;
|
|
18
|
+
requestDeleteByRecord?: (values: { key: string }) => Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function Detail<D extends DataNode>({
|
|
22
|
+
action,
|
|
23
|
+
nodeData,
|
|
24
|
+
requestCreateByValues,
|
|
25
|
+
requestUpdateByValues,
|
|
26
|
+
trigger,
|
|
27
|
+
}: {
|
|
28
|
+
action: FileTreeAction;
|
|
29
|
+
nodeData?: D;
|
|
30
|
+
requestCreateByValues?: FileTreeProps<D>['requestCreateByValues'];
|
|
31
|
+
requestUpdateByValues?: FileTreeProps<D>['requestUpdateByValues'];
|
|
32
|
+
trigger: React.ReactElement;
|
|
33
|
+
}) {
|
|
34
|
+
const title = useMemo(() => {
|
|
35
|
+
if (action === 'create') {
|
|
36
|
+
return '新建';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return '编辑';
|
|
40
|
+
}, [action]);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<ModalForm
|
|
44
|
+
title={title}
|
|
45
|
+
trigger={trigger}
|
|
46
|
+
initialValues={nodeData}
|
|
47
|
+
onFinish={async (values) => {
|
|
48
|
+
if (action === 'update') {
|
|
49
|
+
const result = await requestUpdateByValues?.({ ...values });
|
|
50
|
+
|
|
51
|
+
if (result !== false) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
} else if (action === 'create') {
|
|
55
|
+
const result = await requestCreateByValues?.({ ...values });
|
|
56
|
+
|
|
57
|
+
if (result !== false) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}}
|
|
62
|
+
autoFocusFirstInput
|
|
63
|
+
modalProps={{
|
|
64
|
+
destroyOnHidden: true,
|
|
65
|
+
width: 400,
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
<ProFormText name="key" hidden />
|
|
69
|
+
<ProFormText name="title" label="目录名称" required rules={[{ required: true }]} />
|
|
70
|
+
</ModalForm>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function More({
|
|
75
|
+
actions,
|
|
76
|
+
nodeData,
|
|
77
|
+
requestUpdateByValues,
|
|
78
|
+
requestDeleteByRecord,
|
|
79
|
+
requestCreateByValues,
|
|
80
|
+
}) {
|
|
81
|
+
return (
|
|
82
|
+
<Dropdown
|
|
83
|
+
placement="bottomRight"
|
|
84
|
+
menu={{
|
|
85
|
+
items: [
|
|
86
|
+
actions?.includes('create') && {
|
|
87
|
+
label: (
|
|
88
|
+
<Detail
|
|
89
|
+
action="create"
|
|
90
|
+
nodeData={{ key: nodeData.key }}
|
|
91
|
+
requestCreateByValues={(values) => requestCreateByValues?.({ ...values })}
|
|
92
|
+
trigger={<div>新建子目录</div>}
|
|
93
|
+
/>
|
|
94
|
+
),
|
|
95
|
+
key: 'create',
|
|
96
|
+
},
|
|
97
|
+
actions?.includes('update') && {
|
|
98
|
+
label: (
|
|
99
|
+
<Detail
|
|
100
|
+
action="update"
|
|
101
|
+
nodeData={nodeData}
|
|
102
|
+
requestUpdateByValues={(values) => requestUpdateByValues?.({ ...values })}
|
|
103
|
+
trigger={<div>编辑</div>}
|
|
104
|
+
/>
|
|
105
|
+
),
|
|
106
|
+
key: 'update',
|
|
107
|
+
},
|
|
108
|
+
actions?.includes('delete') && {
|
|
109
|
+
label: (
|
|
110
|
+
<OperateDelete
|
|
111
|
+
name={nodeData.title}
|
|
112
|
+
onDelete={() => requestDeleteByRecord?.({ key: nodeData.key })}
|
|
113
|
+
/>
|
|
114
|
+
),
|
|
115
|
+
key: 'delete',
|
|
116
|
+
},
|
|
117
|
+
].filter(Boolean),
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
<div onClick={(e) => e.preventDefault()}>
|
|
121
|
+
<MoreOutlined />
|
|
122
|
+
</div>
|
|
123
|
+
</Dropdown>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function FileTree<D extends DataNode>(props: FileTreeProps<D>) {
|
|
128
|
+
const titleExtra = useMemo(() => {
|
|
129
|
+
if (!props.actions?.includes('create')) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<Detail
|
|
135
|
+
action="create"
|
|
136
|
+
trigger={<PlusOutlined />}
|
|
137
|
+
requestCreateByValues={props.requestCreateByValues}
|
|
138
|
+
/>
|
|
139
|
+
);
|
|
140
|
+
}, [props.actions, props.requestCreateByValues]);
|
|
141
|
+
|
|
142
|
+
const titleRender = useCallback(
|
|
143
|
+
(nodeData) => {
|
|
144
|
+
console.log(nodeData);
|
|
145
|
+
const hasMore = props.actions?.includes('update') || props.actions?.includes('delete');
|
|
146
|
+
return (
|
|
147
|
+
<div className="group flex gap-1">
|
|
148
|
+
{nodeData.children ? (
|
|
149
|
+
<FileCard.FileIcon isDirectory className="text-base" />
|
|
150
|
+
) : (
|
|
151
|
+
<FileCard.FileIcon name={nodeData.title} className="text-base" />
|
|
152
|
+
)}
|
|
153
|
+
<div className="flex-1 truncate">{nodeData.title}</div>
|
|
154
|
+
<div className={classNames('text-desc', { 'group-hover:hidden': hasMore })}>
|
|
155
|
+
{nodeData.children?.length || 0}
|
|
156
|
+
</div>
|
|
157
|
+
{hasMore && (
|
|
158
|
+
<div className="hidden group-hover:block">
|
|
159
|
+
<More
|
|
160
|
+
actions={props.actions}
|
|
161
|
+
nodeData={nodeData}
|
|
162
|
+
requestCreateByValues={props.requestCreateByValues}
|
|
163
|
+
requestUpdateByValues={props.requestUpdateByValues}
|
|
164
|
+
requestDeleteByRecord={props.requestDeleteByRecord}
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
},
|
|
171
|
+
[
|
|
172
|
+
props.actions,
|
|
173
|
+
props.requestCreateByValues,
|
|
174
|
+
props.requestDeleteByRecord,
|
|
175
|
+
props.requestUpdateByValues,
|
|
176
|
+
],
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<Tree
|
|
181
|
+
titleExtra={titleExtra}
|
|
182
|
+
{...props}
|
|
183
|
+
treeProps={{
|
|
184
|
+
titleRender,
|
|
185
|
+
...props.treeProps,
|
|
186
|
+
className: classNames('cl-file-tree', props.treeProps?.className),
|
|
187
|
+
}}
|
|
188
|
+
/>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export { FileTree };
|
|
193
|
+
export type { FileTreeProps };
|
package/src/tree/index.tsx
CHANGED
|
@@ -1,152 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
import { useCallback, useMemo, useState } from 'react';
|
|
7
|
-
import { flatToTreeData } from './helper';
|
|
8
|
-
|
|
9
|
-
interface TreeProps<T extends DataNode> extends AntdTreeProps<T> {
|
|
10
|
-
enableSearch?: boolean;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function useHighLightTreeData({ treeData, search }) {
|
|
14
|
-
return useMemo(() => {
|
|
15
|
-
if (!search) {
|
|
16
|
-
return treeData;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const loop = (data) => {
|
|
20
|
-
return data.map((item) => {
|
|
21
|
-
const strTitle = item.title as string;
|
|
22
|
-
const index = strTitle.indexOf(search);
|
|
23
|
-
const beforeStr = strTitle.substring(0, index);
|
|
24
|
-
const afterStr = strTitle.slice(index + search.length);
|
|
25
|
-
const title =
|
|
26
|
-
index > -1 ? (
|
|
27
|
-
<span key={item.key}>
|
|
28
|
-
{beforeStr}
|
|
29
|
-
<span className="text-red-500">{search}</span>
|
|
30
|
-
{afterStr}
|
|
31
|
-
</span>
|
|
32
|
-
) : (
|
|
33
|
-
<span key={item.key}>{strTitle}</span>
|
|
34
|
-
);
|
|
35
|
-
|
|
36
|
-
if (item.children) {
|
|
37
|
-
return { ...item, title, children: loop(item.children) };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return {
|
|
41
|
-
...item,
|
|
42
|
-
title,
|
|
43
|
-
};
|
|
44
|
-
});
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
return loop(treeData);
|
|
48
|
-
}, [search, treeData]);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function useFilterTreeData({ treeData, search }) {
|
|
52
|
-
return useMemo(() => {
|
|
53
|
-
if (!search || !treeData) {
|
|
54
|
-
return treeData;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const searchLower = search.toLowerCase();
|
|
58
|
-
|
|
59
|
-
// 检查节点是否匹配搜索条件
|
|
60
|
-
const isNodeMatch = (node) => {
|
|
61
|
-
const title = typeof node.title === 'string' ? node.title : '';
|
|
62
|
-
return title.toLowerCase().includes(searchLower);
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
// 递归过滤树形数据
|
|
66
|
-
const filterTree = (nodes) => {
|
|
67
|
-
if (!nodes || nodes.length === 0) {
|
|
68
|
-
return [];
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return nodes
|
|
72
|
-
.map((node) => {
|
|
73
|
-
const children = node.children ? filterTree(node.children) : [];
|
|
74
|
-
const isMatch = isNodeMatch(node);
|
|
75
|
-
const hasMatchingChildren = children.length > 0;
|
|
76
|
-
|
|
77
|
-
// 如果当前节点匹配或者有匹配的子节点,则保留该节点
|
|
78
|
-
if (isMatch || hasMatchingChildren) {
|
|
79
|
-
return {
|
|
80
|
-
...node,
|
|
81
|
-
children: hasMatchingChildren ? children : undefined,
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return null;
|
|
86
|
-
})
|
|
87
|
-
.filter(Boolean); // 过滤掉null值
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
return filterTree(treeData);
|
|
91
|
-
}, [treeData, search]);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function Tree<T extends DataNode>(props: TreeProps<T>) {
|
|
95
|
-
const { enableSearch, treeData, ...rest } = props;
|
|
96
|
-
const [search, setSearch] = useState('');
|
|
97
|
-
const debouncedSearch = useDebounce(search, { wait: 300 });
|
|
98
|
-
|
|
99
|
-
const filterTreeData = useFilterTreeData({ treeData, search: debouncedSearch });
|
|
100
|
-
const highlightedTreeData = useHighLightTreeData({
|
|
101
|
-
treeData: filterTreeData,
|
|
102
|
-
search: debouncedSearch,
|
|
103
|
-
});
|
|
104
|
-
const newTreeData = highlightedTreeData;
|
|
105
|
-
|
|
106
|
-
const handleSearch = useCallback((e) => {
|
|
107
|
-
setSearch(e.target.value);
|
|
108
|
-
}, []);
|
|
109
|
-
|
|
110
|
-
const searchExpandKeysProps = useMemo(() => {
|
|
111
|
-
if (!debouncedSearch) {
|
|
112
|
-
return {};
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const keys: string[] = [];
|
|
116
|
-
function loop(arr) {
|
|
117
|
-
arr.forEach((item) => {
|
|
118
|
-
keys.push(item.key);
|
|
119
|
-
if (item.children) {
|
|
120
|
-
loop(item.children);
|
|
121
|
-
}
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
loop(newTreeData);
|
|
125
|
-
|
|
126
|
-
return { expandedKeys: keys };
|
|
127
|
-
}, [debouncedSearch, newTreeData]);
|
|
128
|
-
|
|
129
|
-
const node = <AntdTree {...searchExpandKeysProps} {...rest} treeData={newTreeData} />;
|
|
130
|
-
|
|
131
|
-
if (!enableSearch) {
|
|
132
|
-
return node;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return (
|
|
136
|
-
<PageLayout
|
|
137
|
-
direction="vertical"
|
|
138
|
-
start={
|
|
139
|
-
enableSearch && (
|
|
140
|
-
<div className="px-2 pb-2">
|
|
141
|
-
<Input.Search placeholder="搜索" value={search} onChange={handleSearch} />
|
|
142
|
-
</div>
|
|
143
|
-
)
|
|
144
|
-
}
|
|
145
|
-
>
|
|
146
|
-
{node}
|
|
147
|
-
</PageLayout>
|
|
148
|
-
);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
export { flatToTreeData, Tree };
|
|
152
|
-
export type { TreeProps };
|
|
1
|
+
export { FileTree } from './file_tree';
|
|
2
|
+
export type { FileTreeProps } from './file_tree';
|
|
3
|
+
export { flatToTreeData } from './helper';
|
|
4
|
+
export { Tree } from './tree';
|
|
5
|
+
export type { TreeProps } from './tree';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
.cl-tree {
|
|
2
|
+
&.cl-tree-no-wrap {
|
|
3
|
+
.ant-tree-title {
|
|
4
|
+
white-space: nowrap;
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.ant-tree-switcher {
|
|
9
|
+
margin-inline-end: 0 !important;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.cl-file-tree {
|
|
14
|
+
.ant-tree-node-content-wrapper {
|
|
15
|
+
overflow: hidden;
|
|
16
|
+
padding-inline-start: 0 !important;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
&.cl-tree-all-leaf {
|
|
20
|
+
.ant-tree-switcher {
|
|
21
|
+
display: none;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.ant-tree-node-content-wrapper {
|
|
25
|
+
padding-inline-start: 8px !important;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -5,6 +5,13 @@ const meta: Meta<typeof Tree> = {
|
|
|
5
5
|
title: '@fe-free/core/Tree',
|
|
6
6
|
component: Tree,
|
|
7
7
|
tags: ['autodocs'],
|
|
8
|
+
decorators: [
|
|
9
|
+
(Story) => (
|
|
10
|
+
<div className="c-border w-[300px]">
|
|
11
|
+
<Story />
|
|
12
|
+
</div>
|
|
13
|
+
),
|
|
14
|
+
],
|
|
8
15
|
};
|
|
9
16
|
|
|
10
17
|
export default meta;
|
|
@@ -12,45 +19,115 @@ type Story = StoryObj<typeof meta>;
|
|
|
12
19
|
|
|
13
20
|
export const Default: Story = {
|
|
14
21
|
args: {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
treeProps: {
|
|
23
|
+
defaultExpandAll: true,
|
|
24
|
+
treeData: [
|
|
25
|
+
{
|
|
26
|
+
title: '0-0',
|
|
27
|
+
key: '0-0',
|
|
28
|
+
children: [
|
|
29
|
+
{ title: '0-0-0', key: '0-0-0' },
|
|
30
|
+
{ title: '0-0-1', key: '0-0-1' },
|
|
31
|
+
{ title: '0-0-2', key: '0-0-2' },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
},
|
|
27
36
|
},
|
|
28
37
|
};
|
|
29
38
|
|
|
30
39
|
export const EnableSearch: Story = {
|
|
31
40
|
args: {
|
|
32
41
|
enableSearch: true,
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
42
|
+
treeProps: {
|
|
43
|
+
defaultExpandAll: true,
|
|
44
|
+
treeData: [
|
|
45
|
+
{
|
|
46
|
+
title: '0-0',
|
|
47
|
+
key: '0-0',
|
|
48
|
+
children: [
|
|
49
|
+
{ title: '0-0-0', key: '0-0-0' },
|
|
50
|
+
{ title: '0-0-1', key: '0-0-1' },
|
|
51
|
+
{ title: '0-0-2', key: '0-0-2' },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
title: '0-1',
|
|
56
|
+
key: '0-1',
|
|
57
|
+
children: [{ title: '0-1-0', key: '0-1-0' }],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
title: '0-2',
|
|
61
|
+
key: '0-2',
|
|
62
|
+
children: [{ title: '0-2-0', key: '0-2-0' }],
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const Title: Story = {
|
|
70
|
+
args: {
|
|
71
|
+
title: '某某公司',
|
|
72
|
+
enableSearch: true,
|
|
73
|
+
treeProps: {
|
|
74
|
+
defaultExpandAll: true,
|
|
75
|
+
treeData: [
|
|
76
|
+
{
|
|
77
|
+
title: '0-0',
|
|
78
|
+
key: '0-0',
|
|
79
|
+
children: [
|
|
80
|
+
{ title: '0-0-0', key: '0-0-0' },
|
|
81
|
+
{ title: '0-0-1', key: '0-0-1' },
|
|
82
|
+
{ title: '0-0-2', key: '0-0-2' },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const TitleDescription: Story = {
|
|
91
|
+
args: {
|
|
92
|
+
title: '某某公司',
|
|
93
|
+
titleDescription: '某某公司描述',
|
|
94
|
+
enableSearch: true,
|
|
95
|
+
treeProps: {
|
|
96
|
+
defaultExpandAll: true,
|
|
97
|
+
treeData: [
|
|
98
|
+
{
|
|
99
|
+
title: '0-0',
|
|
100
|
+
key: '0-0',
|
|
101
|
+
children: [
|
|
102
|
+
{ title: '0-0-0', key: '0-0-0' },
|
|
103
|
+
{ title: '0-0-1', key: '0-0-1' },
|
|
104
|
+
{ title: '0-0-2', key: '0-0-2' },
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const TitleExtra: Story = {
|
|
113
|
+
args: {
|
|
114
|
+
title: '某某公司',
|
|
115
|
+
titleDescription: '某某公司描述',
|
|
116
|
+
titleExtra: '添加',
|
|
117
|
+
enableSearch: true,
|
|
118
|
+
treeProps: {
|
|
119
|
+
defaultExpandAll: true,
|
|
120
|
+
treeData: [
|
|
121
|
+
{
|
|
122
|
+
title: '0-0',
|
|
123
|
+
key: '0-0',
|
|
124
|
+
children: [
|
|
125
|
+
{ title: '0-0-0', key: '0-0-0' },
|
|
126
|
+
{ title: '0-0-1', key: '0-0-1' },
|
|
127
|
+
{ title: '0-0-2', key: '0-0-2' },
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
},
|
|
55
132
|
},
|
|
56
133
|
};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { PageLayout } from '@fe-free/core';
|
|
2
|
+
import { useDebounce } from 'ahooks';
|
|
3
|
+
import type { TreeProps as AntdTreeProps } from 'antd';
|
|
4
|
+
import { Tree as AntdTree, Input } from 'antd';
|
|
5
|
+
import type { DataNode } from 'antd/es/tree';
|
|
6
|
+
import classNames from 'classnames';
|
|
7
|
+
import type { ReactNode } from 'react';
|
|
8
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
9
|
+
import { flatToTreeData } from './helper';
|
|
10
|
+
import './style.scss';
|
|
11
|
+
|
|
12
|
+
interface TreeProps<T extends DataNode> {
|
|
13
|
+
/** 标题 */
|
|
14
|
+
title?: string | ReactNode;
|
|
15
|
+
/** 标题描述 */
|
|
16
|
+
titleDescription?: string | ReactNode;
|
|
17
|
+
/** 标题额外内容 */
|
|
18
|
+
titleExtra?: ReactNode;
|
|
19
|
+
/** 启用搜索 */
|
|
20
|
+
enableSearch?: boolean;
|
|
21
|
+
/** Antd 树的 props */
|
|
22
|
+
treeProps?: AntdTreeProps<T>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function useHighLightTreeData({ treeData, search }) {
|
|
26
|
+
return useMemo(() => {
|
|
27
|
+
if (!search) {
|
|
28
|
+
return treeData;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const loop = (data) => {
|
|
32
|
+
return data.map((item) => {
|
|
33
|
+
const strTitle = item.title as string;
|
|
34
|
+
const index = strTitle.indexOf(search);
|
|
35
|
+
const beforeStr = strTitle.substring(0, index);
|
|
36
|
+
const afterStr = strTitle.slice(index + search.length);
|
|
37
|
+
const title =
|
|
38
|
+
index > -1 ? (
|
|
39
|
+
<span key={item.key}>
|
|
40
|
+
{beforeStr}
|
|
41
|
+
<span className="text-red-500">{search}</span>
|
|
42
|
+
{afterStr}
|
|
43
|
+
</span>
|
|
44
|
+
) : (
|
|
45
|
+
<span key={item.key}>{strTitle}</span>
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
if (item.children) {
|
|
49
|
+
return { ...item, title, children: loop(item.children) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
...item,
|
|
54
|
+
title,
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return loop(treeData);
|
|
60
|
+
}, [search, treeData]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function useFilterTreeData({ treeData, search }) {
|
|
64
|
+
return useMemo(() => {
|
|
65
|
+
if (!search || !treeData) {
|
|
66
|
+
return treeData;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const searchLower = search.toLowerCase();
|
|
70
|
+
|
|
71
|
+
// 检查节点是否匹配搜索条件
|
|
72
|
+
const isNodeMatch = (node) => {
|
|
73
|
+
const title = typeof node.title === 'string' ? node.title : '';
|
|
74
|
+
return title.toLowerCase().includes(searchLower);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// 递归过滤树形数据
|
|
78
|
+
const filterTree = (nodes) => {
|
|
79
|
+
if (!nodes || nodes.length === 0) {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return nodes
|
|
84
|
+
.map((node) => {
|
|
85
|
+
const children = node.children ? filterTree(node.children) : [];
|
|
86
|
+
const isMatch = isNodeMatch(node);
|
|
87
|
+
const hasMatchingChildren = children.length > 0;
|
|
88
|
+
|
|
89
|
+
// 如果当前节点匹配或者有匹配的子节点,则保留该节点
|
|
90
|
+
if (isMatch || hasMatchingChildren) {
|
|
91
|
+
return {
|
|
92
|
+
...node,
|
|
93
|
+
children: hasMatchingChildren ? children : undefined,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return null;
|
|
98
|
+
})
|
|
99
|
+
.filter(Boolean); // 过滤掉null值
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return filterTree(treeData);
|
|
103
|
+
}, [treeData, search]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function useIsAllLeaf(treeData?: DataNode[]) {
|
|
107
|
+
return useMemo(() => {
|
|
108
|
+
return treeData?.every((item) => item.children === undefined) || true;
|
|
109
|
+
}, [treeData]);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function Tree<T extends DataNode>(props: TreeProps<T>) {
|
|
113
|
+
const { title, titleDescription, titleExtra, enableSearch, treeProps } = props;
|
|
114
|
+
|
|
115
|
+
const [search, setSearch] = useState('');
|
|
116
|
+
const debouncedSearch = useDebounce(search, { wait: 300 });
|
|
117
|
+
|
|
118
|
+
const isAllLeaf = useIsAllLeaf(treeProps?.treeData);
|
|
119
|
+
|
|
120
|
+
const filterTreeData = useFilterTreeData({
|
|
121
|
+
treeData: treeProps?.treeData,
|
|
122
|
+
search: debouncedSearch,
|
|
123
|
+
});
|
|
124
|
+
const highlightedTreeData = useHighLightTreeData({
|
|
125
|
+
treeData: filterTreeData,
|
|
126
|
+
search: debouncedSearch,
|
|
127
|
+
});
|
|
128
|
+
const newTreeData = highlightedTreeData;
|
|
129
|
+
|
|
130
|
+
const handleSearch = useCallback((e) => {
|
|
131
|
+
setSearch(e.target.value);
|
|
132
|
+
}, []);
|
|
133
|
+
|
|
134
|
+
const searchExpandKeysProps = useMemo(() => {
|
|
135
|
+
if (!debouncedSearch) {
|
|
136
|
+
return {};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const keys: string[] = [];
|
|
140
|
+
function loop(arr) {
|
|
141
|
+
arr.forEach((item) => {
|
|
142
|
+
keys.push(item.key);
|
|
143
|
+
if (item.children) {
|
|
144
|
+
loop(item.children);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
loop(newTreeData);
|
|
149
|
+
|
|
150
|
+
return { expandedKeys: keys };
|
|
151
|
+
}, [debouncedSearch, newTreeData]);
|
|
152
|
+
|
|
153
|
+
const node = (
|
|
154
|
+
<AntdTree
|
|
155
|
+
blockNode
|
|
156
|
+
{...searchExpandKeysProps}
|
|
157
|
+
{...treeProps}
|
|
158
|
+
treeData={newTreeData}
|
|
159
|
+
className={classNames(
|
|
160
|
+
'cl-tree cl-tree-no-wrap',
|
|
161
|
+
{
|
|
162
|
+
'cl-tree-all-leaf': isAllLeaf,
|
|
163
|
+
},
|
|
164
|
+
treeProps?.className,
|
|
165
|
+
)}
|
|
166
|
+
/>
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
if (!enableSearch) {
|
|
170
|
+
return node;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<PageLayout
|
|
175
|
+
direction="vertical"
|
|
176
|
+
start={
|
|
177
|
+
(enableSearch || title) && (
|
|
178
|
+
<div className="flex flex-col gap-2 py-2">
|
|
179
|
+
{(title || titleExtra) && (
|
|
180
|
+
<div className="flex gap-2 px-2">
|
|
181
|
+
<div className="flex-1">
|
|
182
|
+
<div className="truncate">{title}</div>
|
|
183
|
+
{titleDescription && <div className="text-desc">{titleDescription}</div>}
|
|
184
|
+
</div>
|
|
185
|
+
{titleExtra}
|
|
186
|
+
</div>
|
|
187
|
+
)}
|
|
188
|
+
<div className="px-2">
|
|
189
|
+
<Input placeholder="搜索" value={search} onChange={handleSearch} />
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
>
|
|
195
|
+
{node}
|
|
196
|
+
</PageLayout>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export { flatToTreeData, Tree };
|
|
201
|
+
export type { TreeProps };
|