@wzyjs/uis 0.3.8
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/package.json +49 -0
- package/src/antd/form/CheckboxButton/index.module.scss +24 -0
- package/src/antd/form/CheckboxButton/index.tsx +31 -0
- package/src/antd/form/RadioButton/index.tsx +30 -0
- package/src/antd/form/Upload/index.tsx +65 -0
- package/src/antd/form/UploadImage/index.tsx +338 -0
- package/src/antd/form/index.ts +5 -0
- package/src/antd/index.ts +43 -0
- package/src/antd/pro/Alert/index.tsx +24 -0
- package/src/antd/pro/Button/components/Confirm.tsx +24 -0
- package/src/antd/pro/Button/components/Copy.tsx +46 -0
- package/src/antd/pro/Button/components/Drawer.tsx +37 -0
- package/src/antd/pro/Button/components/Group.tsx +26 -0
- package/src/antd/pro/Button/index.tsx +11 -0
- package/src/antd/pro/Card/index.tsx +92 -0
- package/src/antd/pro/Collapse/components/Item.tsx +30 -0
- package/src/antd/pro/Collapse/index.tsx +27 -0
- package/src/antd/pro/Image/index.tsx +17 -0
- package/src/antd/pro/Input/components/Range.tsx +46 -0
- package/src/antd/pro/Input/index.tsx +61 -0
- package/src/antd/pro/Popconfirm/index.tsx +16 -0
- package/src/antd/pro/Radio/components/Cancel.tsx +30 -0
- package/src/antd/pro/Radio/index.tsx +7 -0
- package/src/antd/pro/Space/index.tsx +15 -0
- package/src/antd/pro/Typography/components/String.tsx +72 -0
- package/src/antd/pro/Typography/index.tsx +9 -0
- package/src/antd/pro/index.ts +10 -0
- package/src/components/BottomBar/index.tsx +28 -0
- package/src/components/CodeView/index.tsx +85 -0
- package/src/components/Collapse/index.tsx +26 -0
- package/src/components/Com2Canvas/index.tsx +60 -0
- package/src/components/CompileHtml/index.tsx +26 -0
- package/src/components/DateSwitcher/index.module.scss +10 -0
- package/src/components/DateSwitcher/index.tsx +75 -0
- package/src/components/DownloadLink/index.tsx +36 -0
- package/src/components/DragSort/index.tsx +77 -0
- package/src/components/DynamicSelect/index.tsx +77 -0
- package/src/components/DynamicSelect/utils.ts +47 -0
- package/src/components/EnumTag/index.tsx +24 -0
- package/src/components/FetchSelect/index.tsx +57 -0
- package/src/components/Fold/index.tsx +52 -0
- package/src/components/FormPro/index.tsx +28 -0
- package/src/components/GroupLayout/index.tsx +45 -0
- package/src/components/HtmlPro/index.tsx +18 -0
- package/src/components/IframePro/index.tsx +52 -0
- package/src/components/JsonRenderer/index.tsx +115 -0
- package/src/components/JsonView/index.tsx +21 -0
- package/src/components/Markdown/index.tsx +152 -0
- package/src/components/Markdown/style.ts +106 -0
- package/src/components/MultiImageDisplay/index.tsx +63 -0
- package/src/components/SectorButton/index.tsx +247 -0
- package/src/components/TextInput/index.tsx +61 -0
- package/src/components/Video/index.tsx +37 -0
- package/src/components/index.ts +22 -0
- package/src/web.ts +2 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Divider,
|
|
4
|
+
Flex,
|
|
5
|
+
Space,
|
|
6
|
+
Typography,
|
|
7
|
+
Steps,
|
|
8
|
+
Tabs,
|
|
9
|
+
Collapse,
|
|
10
|
+
Descriptions,
|
|
11
|
+
Image,
|
|
12
|
+
List,
|
|
13
|
+
Popover,
|
|
14
|
+
Tooltip,
|
|
15
|
+
Card,
|
|
16
|
+
QRCode,
|
|
17
|
+
Segmented,
|
|
18
|
+
Table,
|
|
19
|
+
Tag,
|
|
20
|
+
Timeline,
|
|
21
|
+
Tree,
|
|
22
|
+
Alert,
|
|
23
|
+
Progress,
|
|
24
|
+
} from 'antd'
|
|
25
|
+
|
|
26
|
+
const ComponentMap = {
|
|
27
|
+
// 布局结构类
|
|
28
|
+
Divider,
|
|
29
|
+
Flex,
|
|
30
|
+
Space,
|
|
31
|
+
|
|
32
|
+
// 信息展示类
|
|
33
|
+
Typography,
|
|
34
|
+
'Typography.Title': Typography.Title,
|
|
35
|
+
'Typography.Text': Typography.Text,
|
|
36
|
+
'Typography.Paragraph': Typography.Paragraph,
|
|
37
|
+
'Typography.Link': Typography.Link,
|
|
38
|
+
Steps,
|
|
39
|
+
Tabs,
|
|
40
|
+
'Tabs.TabPane': Tabs.TabPane,
|
|
41
|
+
Collapse,
|
|
42
|
+
'Collapse.Panel': Collapse.Panel,
|
|
43
|
+
Description: Descriptions,
|
|
44
|
+
Image,
|
|
45
|
+
List,
|
|
46
|
+
Popover,
|
|
47
|
+
Tooltip,
|
|
48
|
+
Card,
|
|
49
|
+
QRCode,
|
|
50
|
+
Segmented,
|
|
51
|
+
Table,
|
|
52
|
+
Tag,
|
|
53
|
+
Timeline,
|
|
54
|
+
Tree,
|
|
55
|
+
Alert,
|
|
56
|
+
Progress,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface Content {
|
|
60
|
+
component: keyof typeof ComponentMap // 对应 Ant Design 组件的枚举值
|
|
61
|
+
props?: Record<string, any> // 对应组件的 props 配置
|
|
62
|
+
children?: Content[] | string // 子节点,可递归嵌套,或直接是字符串
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface JsonRendererProps {
|
|
66
|
+
content: Content[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const JsonRenderer = (props: JsonRendererProps) => {
|
|
70
|
+
const { content } = props
|
|
71
|
+
|
|
72
|
+
// 递归渲染组件的函数
|
|
73
|
+
const renderContent = (item: Content | string) => {
|
|
74
|
+
// 如果是字符串,直接返回
|
|
75
|
+
if (typeof item === 'string') {
|
|
76
|
+
return item
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const { component, props = {}, children } = item
|
|
80
|
+
|
|
81
|
+
// 获取对应的组件
|
|
82
|
+
const Component = ComponentMap[component] as React.ComponentType<any>
|
|
83
|
+
|
|
84
|
+
if (!Component) {
|
|
85
|
+
return <Alert type='error' message={`未找到组件: ${component}`} />
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 处理子节点
|
|
89
|
+
let childrenContent
|
|
90
|
+
if (children) {
|
|
91
|
+
if (Array.isArray(children)) {
|
|
92
|
+
childrenContent = children.map((child, index) => (
|
|
93
|
+
<React.Fragment key={index}>
|
|
94
|
+
{renderContent(child)}
|
|
95
|
+
</React.Fragment>
|
|
96
|
+
))
|
|
97
|
+
} else {
|
|
98
|
+
childrenContent = renderContent(children)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 渲染组件,传递 props 和 children
|
|
103
|
+
return <Component {...props}>{childrenContent}</Component>
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<>
|
|
108
|
+
{content.map((item, index) => (
|
|
109
|
+
<React.Fragment key={index}>
|
|
110
|
+
{renderContent(item)}
|
|
111
|
+
</React.Fragment>
|
|
112
|
+
))}
|
|
113
|
+
</>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import ReactJson, { ReactJsonViewProps } from 'react-json-view'
|
|
5
|
+
|
|
6
|
+
export type JsonViewProps = ReactJsonViewProps
|
|
7
|
+
|
|
8
|
+
export const JsonView = (props: JsonViewProps) => {
|
|
9
|
+
const { style } = props
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<ReactJson
|
|
13
|
+
enableClipboard={false}
|
|
14
|
+
collapsed={true}
|
|
15
|
+
displayObjectSize={false}
|
|
16
|
+
displayDataTypes={false}
|
|
17
|
+
{...props}
|
|
18
|
+
style={{ overflow: 'auto', ...style }}
|
|
19
|
+
/>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react'
|
|
4
|
+
import ReactMarkdown from 'react-markdown'
|
|
5
|
+
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
|
6
|
+
import { oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'
|
|
7
|
+
import remarkGfm from 'remark-gfm'
|
|
8
|
+
import rehypeSlug from 'rehype-slug'
|
|
9
|
+
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
|
|
10
|
+
|
|
11
|
+
import 'github-markdown-css/github-markdown-light.css'
|
|
12
|
+
import { style } from './style'
|
|
13
|
+
|
|
14
|
+
interface MarkdownProps {
|
|
15
|
+
content: string
|
|
16
|
+
showToc?: boolean // 是否显示目录
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface TocItem {
|
|
20
|
+
id: string
|
|
21
|
+
text: string
|
|
22
|
+
level: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const Markdown = (props: MarkdownProps) => {
|
|
26
|
+
const { content = '', showToc = true } = props
|
|
27
|
+
|
|
28
|
+
// 提取标题生成目录
|
|
29
|
+
const tocItems = useMemo(() => {
|
|
30
|
+
if (!showToc) {
|
|
31
|
+
return []
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const lines = content.split('\n')
|
|
35
|
+
const items: TocItem[] = []
|
|
36
|
+
|
|
37
|
+
lines.forEach((line) => {
|
|
38
|
+
const match = line.match(/^(#{2,4})\s+(.+)$/)
|
|
39
|
+
if (match && match[1] && match[2]) {
|
|
40
|
+
const level = match[1].length
|
|
41
|
+
const text = match[2].trim()
|
|
42
|
+
const id = text
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
.replace(/[^\w\u4e00-\u9fa5\s-]/g, '') // 保留中文、英文、数字、空格和连字符
|
|
45
|
+
.replace(/\s+/g, '-') // 空格替换为连字符
|
|
46
|
+
|
|
47
|
+
items.push({ id, text, level })
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
return items
|
|
52
|
+
}, [content, showToc])
|
|
53
|
+
|
|
54
|
+
// 渲染目录
|
|
55
|
+
const renderToc = () => {
|
|
56
|
+
if (!showToc || tocItems.length === 0) {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
className='markdown-toc-container'
|
|
63
|
+
style={{
|
|
64
|
+
padding: '10px 20px 10px 0',
|
|
65
|
+
backgroundColor: '#f8f9fa',
|
|
66
|
+
borderLeft: '4px solid #1890ff',
|
|
67
|
+
borderRadius: '4px',
|
|
68
|
+
overflowY: 'auto',
|
|
69
|
+
height: '100%'
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
<ul style={{ margin: 0, padding: 0 }}>
|
|
73
|
+
{tocItems.map((item, index) => (
|
|
74
|
+
<li
|
|
75
|
+
key={index}
|
|
76
|
+
style={{
|
|
77
|
+
marginBottom: '4px',
|
|
78
|
+
marginLeft: `${(item.level - 1) * 16}px`,
|
|
79
|
+
listStyle: 'none',
|
|
80
|
+
position: 'relative',
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
<a
|
|
84
|
+
href={`#${item.id}`}
|
|
85
|
+
style={{
|
|
86
|
+
textDecoration: 'none',
|
|
87
|
+
color: '#1890ff',
|
|
88
|
+
fontSize: item.level <= 2 ? '14px' : '13px',
|
|
89
|
+
fontWeight: item.level === 1 ? 600 : 400,
|
|
90
|
+
}}
|
|
91
|
+
onClick={(e) => {
|
|
92
|
+
e.preventDefault()
|
|
93
|
+
const element = document.getElementById(item.id)
|
|
94
|
+
if (element) {
|
|
95
|
+
element.scrollIntoView({ behavior: 'smooth' })
|
|
96
|
+
}
|
|
97
|
+
}}
|
|
98
|
+
>
|
|
99
|
+
{item.text}
|
|
100
|
+
</a>
|
|
101
|
+
</li>
|
|
102
|
+
))}
|
|
103
|
+
</ul>
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div style={{ height: '100%' }}>
|
|
110
|
+
<style>{style}</style>
|
|
111
|
+
<div className='markdown-body markdown-toc' style={{ height: '100%', display: 'flex' }}>
|
|
112
|
+
<div style={{ marginRight: 14 }}>
|
|
113
|
+
{renderToc()}
|
|
114
|
+
</div>
|
|
115
|
+
<div style={{ overflow: 'auto', flex: 1 }}>
|
|
116
|
+
<ReactMarkdown
|
|
117
|
+
remarkPlugins={[remarkGfm]}
|
|
118
|
+
rehypePlugins={[
|
|
119
|
+
rehypeSlug,
|
|
120
|
+
[rehypeAutolinkHeadings, {
|
|
121
|
+
behavior: 'wrap',
|
|
122
|
+
properties: {
|
|
123
|
+
className: ['anchor'],
|
|
124
|
+
},
|
|
125
|
+
}],
|
|
126
|
+
]}
|
|
127
|
+
components={{
|
|
128
|
+
code(props) {
|
|
129
|
+
const { children, className } = props
|
|
130
|
+
const match = /language-(\w+)/.exec(className || '')
|
|
131
|
+
return match ? (
|
|
132
|
+
<SyntaxHighlighter
|
|
133
|
+
PreTag='div'
|
|
134
|
+
children={String(children).replace(/\n$/, '')}
|
|
135
|
+
language={match[1]}
|
|
136
|
+
style={oneLight}
|
|
137
|
+
/>
|
|
138
|
+
) : (
|
|
139
|
+
<code className={className}>
|
|
140
|
+
{children}
|
|
141
|
+
</code>
|
|
142
|
+
)
|
|
143
|
+
},
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
{content}
|
|
147
|
+
</ReactMarkdown>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export const style = `
|
|
2
|
+
code {
|
|
3
|
+
color: #c7254e;
|
|
4
|
+
background-color: #f9f2f4;
|
|
5
|
+
border-radius: 4px;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/* 目录容器样式 */
|
|
9
|
+
.markdown-toc .markdown-body {
|
|
10
|
+
position: relative;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* 锚点链接样式 */
|
|
14
|
+
.markdown-toc .anchor {
|
|
15
|
+
text-decoration: none !important;
|
|
16
|
+
color: inherit !important;
|
|
17
|
+
display: contents !important;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.markdown-toc .anchor:hover {
|
|
21
|
+
text-decoration: underline !important;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* 标题悬浮效果 */
|
|
25
|
+
.markdown-toc h1:hover .anchor::after,
|
|
26
|
+
.markdown-toc h2:hover .anchor::after,
|
|
27
|
+
.markdown-toc h3:hover .anchor::after,
|
|
28
|
+
.markdown-toc h4:hover .anchor::after,
|
|
29
|
+
.markdown-toc h5:hover .anchor::after,
|
|
30
|
+
.markdown-toc h6:hover .anchor::after {
|
|
31
|
+
content: " 🔗";
|
|
32
|
+
opacity: 0.6;
|
|
33
|
+
font-size: 0.8em;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* 目录标题样式 */
|
|
37
|
+
// .markdown-toc h2:first-of-type {
|
|
38
|
+
// display: block !important;
|
|
39
|
+
// color: #24292f !important;
|
|
40
|
+
// font-size: 18px !important;
|
|
41
|
+
// font-weight: 600 !important;
|
|
42
|
+
// line-height: 1.4 !important;
|
|
43
|
+
// border-bottom: 1px solid #d1d9e0 !important;
|
|
44
|
+
// padding-bottom: 8px !important;
|
|
45
|
+
// margin: 0 0 16px 0 !important;
|
|
46
|
+
// }
|
|
47
|
+
|
|
48
|
+
/* 目录专用样式 - 只对第一个h2下的列表生效 */
|
|
49
|
+
.markdown-toc h2:first-of-type + ul {
|
|
50
|
+
background: #f8f9fa !important;
|
|
51
|
+
border: 1px solid #e1e4e8 !important;
|
|
52
|
+
border-radius: 6px !important;
|
|
53
|
+
padding: 16px !important;
|
|
54
|
+
margin: 16px 0 24px 0 !important;
|
|
55
|
+
list-style: none !important;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.markdown-toc h2:first-of-type + ul li {
|
|
59
|
+
margin: 8px 0 !important;
|
|
60
|
+
padding-left: 0 !important;
|
|
61
|
+
list-style: none !important;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.markdown-toc h2:first-of-type + ul li::before {
|
|
65
|
+
content: "📖 " !important;
|
|
66
|
+
margin-right: 8px !important;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.markdown-toc h2:first-of-type + ul li ul {
|
|
70
|
+
margin: 4px 0 0 0 !important;
|
|
71
|
+
padding-left: 20px !important;
|
|
72
|
+
list-style: none !important;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.markdown-toc h2:first-of-type + ul li ul li {
|
|
76
|
+
margin: 4px 0 !important;
|
|
77
|
+
padding-left: 0 !important;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.markdown-toc h2:first-of-type + ul li ul li::before {
|
|
81
|
+
content: "▸ " !important;
|
|
82
|
+
margin-right: 6px !important;
|
|
83
|
+
color: #666 !important;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.markdown-toc h2:first-of-type + ul li ul li ul {
|
|
87
|
+
padding-left: 16px !important;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.markdown-toc h2:first-of-type + ul li ul li ul li::before {
|
|
91
|
+
content: "◦ " !important;
|
|
92
|
+
color: #999 !important;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* 目录链接样式 */
|
|
96
|
+
.markdown-toc h2:first-of-type + ul a {
|
|
97
|
+
color: #0969da !important;
|
|
98
|
+
text-decoration: none !important;
|
|
99
|
+
font-weight: 500 !important;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.markdown-toc h2:first-of-type + ul a:hover {
|
|
103
|
+
text-decoration: underline !important;
|
|
104
|
+
color: #0550ae !important;
|
|
105
|
+
}
|
|
106
|
+
`
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import { Row, Col, Image } from 'antd'
|
|
5
|
+
|
|
6
|
+
export const MultiImageDisplay = ({ images = [], preview = false }) => {
|
|
7
|
+
const renderImages = () => {
|
|
8
|
+
const count = images.length
|
|
9
|
+
|
|
10
|
+
if (count === 1) {
|
|
11
|
+
return (
|
|
12
|
+
<Image src={images[0]} preview={preview} style={{ width: '100%', height: 'auto' }} />
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (count === 2) {
|
|
17
|
+
return (
|
|
18
|
+
<Row gutter={8}>
|
|
19
|
+
{images.map((img, index) => (
|
|
20
|
+
<Col span={12} key={index}>
|
|
21
|
+
<Image src={img} preview={preview} style={{ width: '100%', height: 'auto' }} />
|
|
22
|
+
</Col>
|
|
23
|
+
))}
|
|
24
|
+
</Row>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (count === 3) {
|
|
29
|
+
return (
|
|
30
|
+
<div>
|
|
31
|
+
<Row gutter={8}>
|
|
32
|
+
{images.slice(0, 2).map((img, index) => (
|
|
33
|
+
<Col span={12} key={index}>
|
|
34
|
+
<Image src={img} preview={preview} style={{ width: '100%', height: 'auto' }} />
|
|
35
|
+
</Col>
|
|
36
|
+
))}
|
|
37
|
+
</Row>
|
|
38
|
+
<Row gutter={8} style={{ marginTop: '8px' }}>
|
|
39
|
+
<Col span={24}>
|
|
40
|
+
<Image src={images[2]} preview={preview} style={{ width: '100%', height: 'auto' }} />
|
|
41
|
+
</Col>
|
|
42
|
+
</Row>
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (count >= 4) {
|
|
48
|
+
return (
|
|
49
|
+
<Row>
|
|
50
|
+
{images.map((img, index) => (
|
|
51
|
+
<Col span={12} key={index}>
|
|
52
|
+
<Image src={img} preview={preview} style={{ width: '100%', height: 'auto' }} />
|
|
53
|
+
</Col>
|
|
54
|
+
))}
|
|
55
|
+
</Row>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return <div>{renderImages()}</div>
|
|
63
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react'
|
|
4
|
+
|
|
5
|
+
export interface SectorButtonSector<T> {
|
|
6
|
+
id: T;
|
|
7
|
+
label?: string;
|
|
8
|
+
color?: string;
|
|
9
|
+
hoverColor?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SectorButtonProps<T> {
|
|
13
|
+
sectors: SectorButtonSector<T>[];
|
|
14
|
+
size?: number;
|
|
15
|
+
className?: string;
|
|
16
|
+
onClick?: (id: T) => void;
|
|
17
|
+
borderRadius?: number;
|
|
18
|
+
showPlusSign?: boolean;
|
|
19
|
+
plusSignColor?: string;
|
|
20
|
+
plusSignSize?: number;
|
|
21
|
+
shape?: 'circle' | 'rounded-rect';
|
|
22
|
+
rectWidth?: number;
|
|
23
|
+
rectHeight?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const SectorButton = <T = string>(props: SectorButtonProps<T>) => {
|
|
27
|
+
const {
|
|
28
|
+
shape = 'rounded-rect',
|
|
29
|
+
size = 40,
|
|
30
|
+
rectWidth = size,
|
|
31
|
+
rectHeight = size,
|
|
32
|
+
sectors,
|
|
33
|
+
className,
|
|
34
|
+
borderRadius = 12,
|
|
35
|
+
showPlusSign = true,
|
|
36
|
+
plusSignColor = '#bbb',
|
|
37
|
+
plusSignSize = 0.3,
|
|
38
|
+
onClick,
|
|
39
|
+
} = props
|
|
40
|
+
|
|
41
|
+
const [hoveredSector, setHoveredSector] = useState<T | null>(null)
|
|
42
|
+
|
|
43
|
+
const anglePerSector = 360 / sectors.length
|
|
44
|
+
const radius = size / 2
|
|
45
|
+
|
|
46
|
+
// 添加额外边距确保虚线显示完整
|
|
47
|
+
const padding = 2
|
|
48
|
+
const svgWidth = shape === 'circle' ? size + padding * 2 : rectWidth + padding * 2
|
|
49
|
+
const svgHeight = shape === 'circle' ? size + padding * 2 : rectHeight + padding * 2
|
|
50
|
+
|
|
51
|
+
const handleSectorClick = (id: T) => {
|
|
52
|
+
onClick?.(id)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 先渲染非悬停的扇形,然后渲染悬停的扇形,确保悬停的扇形在最顶层
|
|
56
|
+
const nonHoveredSectors = sectors.filter(sector => sector.id !== hoveredSector)
|
|
57
|
+
const hoveredSector1 = sectors.find(sector => sector.id === hoveredSector)
|
|
58
|
+
const orderedSectors = [...nonHoveredSectors]
|
|
59
|
+
if (hoveredSector1) {
|
|
60
|
+
orderedSectors.push(hoveredSector1)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 生成圆角矩形或扇形的路径
|
|
64
|
+
const generatePath = (sector: SectorButtonSector<T>, originalIndex: number) => {
|
|
65
|
+
const centerX = svgWidth / 2
|
|
66
|
+
const centerY = svgHeight / 2
|
|
67
|
+
|
|
68
|
+
if (shape === 'circle') {
|
|
69
|
+
// 原来的扇形路径生成逻辑
|
|
70
|
+
const startAngle = originalIndex * anglePerSector
|
|
71
|
+
const endAngle = (originalIndex + 1) * anglePerSector
|
|
72
|
+
|
|
73
|
+
const startRad = (startAngle - 90) * Math.PI / 180
|
|
74
|
+
const endRad = (endAngle - 90) * Math.PI / 180
|
|
75
|
+
|
|
76
|
+
return [
|
|
77
|
+
`M ${centerX} ${centerY}`,
|
|
78
|
+
`L ${centerX + radius * Math.cos(startRad)} ${centerY + radius * Math.sin(startRad)}`,
|
|
79
|
+
`A ${radius} ${radius} 0 ${anglePerSector > 180 ? 1 : 0} 1 ${centerX + radius * Math.cos(endRad)} ${centerY + radius * Math.sin(endRad)}`,
|
|
80
|
+
`Z`,
|
|
81
|
+
].join(' ')
|
|
82
|
+
} else {
|
|
83
|
+
// 圆角矩形的分块逻辑
|
|
84
|
+
const rectX = padding
|
|
85
|
+
const rectY = padding
|
|
86
|
+
const rectW = rectWidth
|
|
87
|
+
const rectH = rectHeight
|
|
88
|
+
|
|
89
|
+
// 当只有2个扇形时,使用左右布局
|
|
90
|
+
if (sectors.length === 2) {
|
|
91
|
+
const secWidth = rectW / 2
|
|
92
|
+
const secHeight = rectH
|
|
93
|
+
const x = rectX + originalIndex * secWidth
|
|
94
|
+
const y = rectY
|
|
95
|
+
|
|
96
|
+
// 使用圆角矩形绘制单个部分
|
|
97
|
+
const br = borderRadius > 0 ? Math.min(borderRadius, secWidth / 4, secHeight / 4) : 0
|
|
98
|
+
|
|
99
|
+
// 确定是左侧还是右侧
|
|
100
|
+
const isLeft = originalIndex === 0
|
|
101
|
+
const isRight = originalIndex === 1
|
|
102
|
+
|
|
103
|
+
// 构建路径 - 根据位置决定哪些角有圆角
|
|
104
|
+
return [
|
|
105
|
+
`M ${x + (isLeft ? br : 0)} ${y}`,
|
|
106
|
+
|
|
107
|
+
// 上边
|
|
108
|
+
`H ${x + secWidth - (isRight ? br : 0)}`,
|
|
109
|
+
isRight ? `A ${br} ${br} 0 0 1 ${x + secWidth} ${y + br}` : '',
|
|
110
|
+
|
|
111
|
+
// 右边
|
|
112
|
+
`V ${y + secHeight - (isRight ? br : 0)}`,
|
|
113
|
+
isRight ? `A ${br} ${br} 0 0 1 ${x + secWidth - br} ${y + secHeight}` : '',
|
|
114
|
+
|
|
115
|
+
// 下边
|
|
116
|
+
`H ${x + (isLeft ? br : 0)}`,
|
|
117
|
+
isLeft ? `A ${br} ${br} 0 0 1 ${x} ${y + secHeight - br}` : '',
|
|
118
|
+
|
|
119
|
+
// 左边
|
|
120
|
+
`V ${y + (isLeft ? br : 0)}`,
|
|
121
|
+
isLeft ? `A ${br} ${br} 0 0 1 ${x + br} ${y}` : '',
|
|
122
|
+
|
|
123
|
+
'Z',
|
|
124
|
+
].filter(Boolean).join(' ')
|
|
125
|
+
} else {
|
|
126
|
+
// 原有的上下布局逻辑
|
|
127
|
+
const secWidth = rectW / (sectors.length % 2 === 0 ? sectors.length / 2 : Math.ceil(sectors.length / 2))
|
|
128
|
+
const secHeight = rectH / 2
|
|
129
|
+
|
|
130
|
+
// 确定当前扇区在矩形中的位置
|
|
131
|
+
const isTopRow = originalIndex < Math.ceil(sectors.length / 2)
|
|
132
|
+
const rowIndex = isTopRow ? originalIndex : originalIndex - Math.ceil(sectors.length / 2)
|
|
133
|
+
|
|
134
|
+
const x = rectX + rowIndex * secWidth
|
|
135
|
+
const y = isTopRow ? rectY : rectY + secHeight
|
|
136
|
+
|
|
137
|
+
// 使用圆角矩形绘制单个部分
|
|
138
|
+
const br = borderRadius > 0 ? Math.min(borderRadius, secWidth / 4, secHeight / 4) : 0
|
|
139
|
+
|
|
140
|
+
// 确定哪些边需要圆角
|
|
141
|
+
const isLeftEdge = (isTopRow && rowIndex === 0) || (!isTopRow && rowIndex === 0)
|
|
142
|
+
const isRightEdge = (isTopRow && rowIndex === Math.ceil(sectors.length / 2) - 1) ||
|
|
143
|
+
(!isTopRow && rowIndex === (sectors.length - Math.ceil(sectors.length / 2)) - 1)
|
|
144
|
+
const isTopEdge = isTopRow
|
|
145
|
+
const isBottomEdge = !isTopRow
|
|
146
|
+
|
|
147
|
+
// 构建路径 - 根据位置决定哪些角有圆角
|
|
148
|
+
return [
|
|
149
|
+
`M ${x + (isLeftEdge && isTopEdge ? br : 0)} ${y}`,
|
|
150
|
+
|
|
151
|
+
// 上边
|
|
152
|
+
`H ${x + secWidth - (isRightEdge && isTopEdge ? br : 0)}`,
|
|
153
|
+
isRightEdge && isTopEdge ? `A ${br} ${br} 0 0 1 ${x + secWidth} ${y + br}` : '',
|
|
154
|
+
|
|
155
|
+
// 右边
|
|
156
|
+
`V ${y + secHeight - (isRightEdge && isBottomEdge ? br : 0)}`,
|
|
157
|
+
isRightEdge && isBottomEdge ? `A ${br} ${br} 0 0 1 ${x + secWidth - br} ${y + secHeight}` : '',
|
|
158
|
+
|
|
159
|
+
// 下边
|
|
160
|
+
`H ${x + (isLeftEdge && isBottomEdge ? br : 0)}`,
|
|
161
|
+
isLeftEdge && isBottomEdge ? `A ${br} ${br} 0 0 1 ${x} ${y + secHeight - br}` : '',
|
|
162
|
+
|
|
163
|
+
// 左边
|
|
164
|
+
`V ${y + (isLeftEdge && isTopEdge ? br : 0)}`,
|
|
165
|
+
isLeftEdge && isTopEdge ? `A ${br} ${br} 0 0 1 ${x + br} ${y}` : '',
|
|
166
|
+
|
|
167
|
+
'Z',
|
|
168
|
+
].filter(Boolean).join(' ')
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<div
|
|
175
|
+
className={`relative ${className}`}
|
|
176
|
+
style={{
|
|
177
|
+
width: shape === 'circle' ? size : rectWidth,
|
|
178
|
+
height: shape === 'circle' ? size : rectHeight,
|
|
179
|
+
borderRadius,
|
|
180
|
+
backgroundColor: 'transparent',
|
|
181
|
+
}}
|
|
182
|
+
>
|
|
183
|
+
<svg
|
|
184
|
+
width={svgWidth}
|
|
185
|
+
height={svgHeight}
|
|
186
|
+
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
|
|
187
|
+
style={{
|
|
188
|
+
position: 'absolute',
|
|
189
|
+
top: -padding,
|
|
190
|
+
left: -padding,
|
|
191
|
+
}}
|
|
192
|
+
>
|
|
193
|
+
<g>
|
|
194
|
+
{orderedSectors.map(sector => {
|
|
195
|
+
const originalIndex = sectors.findIndex(s => s.id === sector.id)
|
|
196
|
+
const pathData = generatePath(sector, originalIndex)
|
|
197
|
+
|
|
198
|
+
const isHovered = hoveredSector === sector.id
|
|
199
|
+
const fillColor = (isHovered ? sector.hoverColor : sector.color) || 'white'
|
|
200
|
+
const strokeColor = isHovered ? '#90cdf4' : '#e2e8f0'
|
|
201
|
+
const strokeWidth = isHovered ? 1.5 : 1
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<g key={sector.id as string}>
|
|
205
|
+
<path
|
|
206
|
+
d={pathData}
|
|
207
|
+
fill={fillColor}
|
|
208
|
+
stroke={strokeColor}
|
|
209
|
+
strokeWidth={strokeWidth}
|
|
210
|
+
strokeDasharray='4,1'
|
|
211
|
+
className='transition-colors duration-200 cursor-pointer'
|
|
212
|
+
onMouseEnter={() => setHoveredSector(sector.id)}
|
|
213
|
+
onMouseLeave={() => setHoveredSector(null)}
|
|
214
|
+
onClick={() => handleSectorClick(sector.id)}
|
|
215
|
+
/>
|
|
216
|
+
</g>
|
|
217
|
+
)
|
|
218
|
+
})}
|
|
219
|
+
|
|
220
|
+
{/* 在中间添加加号 */}
|
|
221
|
+
{showPlusSign && (
|
|
222
|
+
<g>
|
|
223
|
+
<line
|
|
224
|
+
x1={svgWidth / 2 - (plusSignSize * (shape === 'circle' ? size : Math.min(rectWidth, rectHeight)) / 2)}
|
|
225
|
+
y1={svgHeight / 2}
|
|
226
|
+
x2={svgWidth / 2 + (plusSignSize * (shape === 'circle' ? size : Math.min(rectWidth, rectHeight)) / 2)}
|
|
227
|
+
y2={svgHeight / 2}
|
|
228
|
+
stroke={plusSignColor}
|
|
229
|
+
strokeWidth={2}
|
|
230
|
+
strokeLinecap='round'
|
|
231
|
+
/>
|
|
232
|
+
<line
|
|
233
|
+
x1={svgWidth / 2}
|
|
234
|
+
y1={svgHeight / 2 - (plusSignSize * (shape === 'circle' ? size : Math.min(rectWidth, rectHeight)) / 2)}
|
|
235
|
+
x2={svgWidth / 2}
|
|
236
|
+
y2={svgHeight / 2 + (plusSignSize * (shape === 'circle' ? size : Math.min(rectWidth, rectHeight)) / 2)}
|
|
237
|
+
stroke={plusSignColor}
|
|
238
|
+
strokeWidth={2}
|
|
239
|
+
strokeLinecap='round'
|
|
240
|
+
/>
|
|
241
|
+
</g>
|
|
242
|
+
)}
|
|
243
|
+
</g>
|
|
244
|
+
</svg>
|
|
245
|
+
</div>
|
|
246
|
+
)
|
|
247
|
+
}
|