@alicloud/appflow-chat 0.0.1-beta.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/README-ZH.md +188 -0
- package/README.md +190 -0
- package/dist/appflow-chat.cjs.js +1903 -0
- package/dist/appflow-chat.esm.js +36965 -0
- package/dist/types/index.d.ts +862 -0
- package/package.json +87 -0
- package/src/components/DocReferences.tsx +64 -0
- package/src/components/HumanVerify/CustomParamsRenderer/ArrayField.tsx +394 -0
- package/src/components/HumanVerify/CustomParamsRenderer/FieldRenderer.tsx +202 -0
- package/src/components/HumanVerify/CustomParamsRenderer/ObjectField.tsx +126 -0
- package/src/components/HumanVerify/CustomParamsRenderer/index.tsx +166 -0
- package/src/components/HumanVerify/CustomParamsRenderer/types.ts +203 -0
- package/src/components/HumanVerify/HistoryCard.tsx +156 -0
- package/src/components/HumanVerify/HumanVerify.tsx +184 -0
- package/src/components/HumanVerify/index.ts +11 -0
- package/src/components/MarkdownRenderer.tsx +195 -0
- package/src/components/MessageBubble.tsx +400 -0
- package/src/components/RichMessageBubble.tsx +283 -0
- package/src/components/WebSearchPanel.tsx +68 -0
- package/src/context/RichBubble.tsx +21 -0
- package/src/core/BubbleContent.tsx +75 -0
- package/src/core/RichBubbleContent.tsx +324 -0
- package/src/core/SourceContent.tsx +285 -0
- package/src/core/WebSearchContent.tsx +219 -0
- package/src/core/index.ts +16 -0
- package/src/hooks/usePreSignUpload.ts +36 -0
- package/src/index.ts +80 -0
- package/src/markdown/components/Chart.tsx +120 -0
- package/src/markdown/components/Error.tsx +39 -0
- package/src/markdown/components/FileDisplay.tsx +246 -0
- package/src/markdown/components/Loading.tsx +41 -0
- package/src/markdown/components/SyntaxHighlight.tsx +182 -0
- package/src/markdown/index.tsx +250 -0
- package/src/markdown/styled.ts +234 -0
- package/src/markdown/utils/dataProcessor.ts +89 -0
- package/src/services/ChatService.ts +926 -0
- package/src/utils/fetchEventSource.ts +65 -0
- package/src/utils/loadEcharts.ts +32 -0
- package/src/utils/loadPrism.ts +156 -0
- package/src/vite-env.d.ts +1 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
|
|
2
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { useRichBubbleContext } from '@/context/RichBubble';
|
|
4
|
+
import loadEchartsScript from '@/utils/loadEcharts';
|
|
5
|
+
|
|
6
|
+
interface Iprops {
|
|
7
|
+
options: any;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const Chart: React.FC<Iprops> = ({
|
|
11
|
+
options,
|
|
12
|
+
}) => {
|
|
13
|
+
const chartContainerRef = useRef(null);
|
|
14
|
+
const chartInstanceRef = useRef(null);
|
|
15
|
+
const [isEchartsLoaded, setIsEchartsLoaded] = useState(false);
|
|
16
|
+
|
|
17
|
+
const { activeKey } = useRichBubbleContext();
|
|
18
|
+
|
|
19
|
+
// 加载echarts
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const initEcharts = async () => {
|
|
22
|
+
try {
|
|
23
|
+
await loadEchartsScript();
|
|
24
|
+
setIsEchartsLoaded(true);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
console.error('echarts加载失败:', error);
|
|
27
|
+
setIsEchartsLoaded(false);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
initEcharts();
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
// 确保echarts已加载且DOM元素存在
|
|
36
|
+
if (!isEchartsLoaded || !chartContainerRef.current || !options) return;
|
|
37
|
+
|
|
38
|
+
const echarts = (window as any).echarts;
|
|
39
|
+
if (!echarts) {
|
|
40
|
+
console.error('echarts未正确加载');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 清理之前的实例
|
|
45
|
+
if (chartInstanceRef.current) {
|
|
46
|
+
// @ts-ignore
|
|
47
|
+
chartInstanceRef.current.dispose();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 创建新的图表实例
|
|
51
|
+
const chartInstance = echarts.init(chartContainerRef.current);
|
|
52
|
+
// @ts-ignore
|
|
53
|
+
chartInstanceRef.current = chartInstance;
|
|
54
|
+
|
|
55
|
+
// 设置图表选项
|
|
56
|
+
chartInstance.setOption(options);
|
|
57
|
+
|
|
58
|
+
// 处理窗口大小变化
|
|
59
|
+
const handleResize = () => {
|
|
60
|
+
chartInstance.resize();
|
|
61
|
+
};
|
|
62
|
+
window.addEventListener('resize', handleResize);
|
|
63
|
+
|
|
64
|
+
// 清理函数
|
|
65
|
+
return () => {
|
|
66
|
+
window.removeEventListener('resize', handleResize);
|
|
67
|
+
chartInstance.dispose();
|
|
68
|
+
chartInstanceRef.current = null;
|
|
69
|
+
};
|
|
70
|
+
}, [options, isEchartsLoaded]);
|
|
71
|
+
|
|
72
|
+
// 在步骤消息折叠框改变时更新图表
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (chartInstanceRef.current && activeKey && isEchartsLoaded) {
|
|
75
|
+
// 确保折叠动画完成后再resize
|
|
76
|
+
const timer = setTimeout(() => {
|
|
77
|
+
// @ts-ignore
|
|
78
|
+
chartInstanceRef.current?.resize();
|
|
79
|
+
}, 50);
|
|
80
|
+
|
|
81
|
+
return () => clearTimeout(timer);
|
|
82
|
+
}
|
|
83
|
+
}, [activeKey, isEchartsLoaded]);
|
|
84
|
+
|
|
85
|
+
// 等待echarts加载完成
|
|
86
|
+
if (!isEchartsLoaded) {
|
|
87
|
+
return (
|
|
88
|
+
<div
|
|
89
|
+
style={{
|
|
90
|
+
width: '100%',
|
|
91
|
+
height: '400px',
|
|
92
|
+
marginTop: '20px',
|
|
93
|
+
marginBottom: '20px',
|
|
94
|
+
display: 'flex',
|
|
95
|
+
alignItems: 'center',
|
|
96
|
+
justifyContent: 'center',
|
|
97
|
+
border: '1px dashed #d9d9d9',
|
|
98
|
+
borderRadius: '6px',
|
|
99
|
+
color: '#666'
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
图表加载中...
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div
|
|
109
|
+
ref={chartContainerRef}
|
|
110
|
+
style={{
|
|
111
|
+
width: '100%',
|
|
112
|
+
height: '400px',
|
|
113
|
+
marginTop: '20px',
|
|
114
|
+
marginBottom: '20px'
|
|
115
|
+
}}
|
|
116
|
+
/>
|
|
117
|
+
);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export default Chart;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import styled from 'styled-components';
|
|
3
|
+
|
|
4
|
+
// ==================== Styled Components ====================
|
|
5
|
+
|
|
6
|
+
const ChartErrorContainer = styled.div`
|
|
7
|
+
margin: 10px 0;
|
|
8
|
+
padding: 10px;
|
|
9
|
+
border-radius: 4px;
|
|
10
|
+
text-align: center;
|
|
11
|
+
background: #ffeeee;
|
|
12
|
+
color: rgb(200, 0, 0);
|
|
13
|
+
border: 1px solid #ffcccc;
|
|
14
|
+
|
|
15
|
+
span {
|
|
16
|
+
font-size: 14px;
|
|
17
|
+
line-height: 1.5;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
&:hover {
|
|
21
|
+
background: #f5f5f5;
|
|
22
|
+
}
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
// ==================== Component ====================
|
|
26
|
+
|
|
27
|
+
interface ChartErrorProps {
|
|
28
|
+
text?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ChartError: React.FC<ChartErrorProps> = ({ text = '' }) => {
|
|
32
|
+
return (
|
|
33
|
+
<ChartErrorContainer>
|
|
34
|
+
<span>{text}</span>
|
|
35
|
+
</ChartErrorContainer>
|
|
36
|
+
);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export default ChartError;
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import styled from 'styled-components';
|
|
3
|
+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
4
|
+
import {
|
|
5
|
+
faFilePdf,
|
|
6
|
+
faFileImage,
|
|
7
|
+
faFileExcel,
|
|
8
|
+
faFilePowerpoint,
|
|
9
|
+
faFileWord,
|
|
10
|
+
faFileArchive,
|
|
11
|
+
faFileCode,
|
|
12
|
+
faFileVideo,
|
|
13
|
+
faFileAudio,
|
|
14
|
+
faFile,
|
|
15
|
+
faFileLines
|
|
16
|
+
} from '@fortawesome/free-solid-svg-icons';
|
|
17
|
+
|
|
18
|
+
// Styled Components
|
|
19
|
+
const FileDisplayContainer = styled.div`
|
|
20
|
+
display: flex;
|
|
21
|
+
align-items: center;
|
|
22
|
+
background-color: #f8f9fa;
|
|
23
|
+
border-radius: 6px;
|
|
24
|
+
border: 1px solid #e9ecef;
|
|
25
|
+
transition: all 0.3s ease;
|
|
26
|
+
padding: 8px 12px;
|
|
27
|
+
|
|
28
|
+
@media (max-width: 500px) {
|
|
29
|
+
flex-direction: column;
|
|
30
|
+
text-align: center;
|
|
31
|
+
}
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
const FileIconWrapper = styled.div<{ $color: string }>`
|
|
35
|
+
display: flex;
|
|
36
|
+
align-items: center;
|
|
37
|
+
justify-content: center;
|
|
38
|
+
width: 48px;
|
|
39
|
+
height: 48px;
|
|
40
|
+
min-width: 48px;
|
|
41
|
+
color: ${props => props.$color};
|
|
42
|
+
background-color: #e8f4fc;
|
|
43
|
+
border-radius: 8px;
|
|
44
|
+
margin-right: 12px;
|
|
45
|
+
|
|
46
|
+
@media (max-width: 500px) {
|
|
47
|
+
margin-right: 0;
|
|
48
|
+
margin-bottom: 10px;
|
|
49
|
+
}
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
const FileInfo = styled.div`
|
|
53
|
+
flex: 1;
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
const FileName = styled.div`
|
|
57
|
+
font-weight: bold;
|
|
58
|
+
font-size: 12px;
|
|
59
|
+
margin-bottom: 4px;
|
|
60
|
+
color: #2d3436;
|
|
61
|
+
max-width: 200px;
|
|
62
|
+
white-space: nowrap;
|
|
63
|
+
overflow: hidden;
|
|
64
|
+
text-overflow: ellipsis;
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
const FileDetails = styled.div`
|
|
68
|
+
display: flex;
|
|
69
|
+
font-size: 14px;
|
|
70
|
+
color: #636e72;
|
|
71
|
+
|
|
72
|
+
@media (max-width: 500px) {
|
|
73
|
+
justify-content: center;
|
|
74
|
+
}
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
const FileSize = styled.span`
|
|
78
|
+
margin-right: 12px;
|
|
79
|
+
`;
|
|
80
|
+
|
|
81
|
+
const FileType = styled.span`
|
|
82
|
+
background-color: rgba(0, 0, 0, 0.08);
|
|
83
|
+
padding: 2px 6px;
|
|
84
|
+
border-radius: 4px;
|
|
85
|
+
font-size: 12px;
|
|
86
|
+
font-weight: 500;
|
|
87
|
+
`;
|
|
88
|
+
|
|
89
|
+
interface Iprops {
|
|
90
|
+
fileInfo: any;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const FileDisplay: React.FC<Iprops> = ({
|
|
94
|
+
fileInfo
|
|
95
|
+
}) => {
|
|
96
|
+
const { name, size, type = 'doc' } = fileInfo;
|
|
97
|
+
|
|
98
|
+
const getFileIcon = () => {
|
|
99
|
+
switch (type.toLowerCase()) {
|
|
100
|
+
case 'pdf':
|
|
101
|
+
return faFilePdf;
|
|
102
|
+
case 'image':
|
|
103
|
+
case 'img':
|
|
104
|
+
case 'jpg':
|
|
105
|
+
case 'jpeg':
|
|
106
|
+
case 'png':
|
|
107
|
+
case 'gif':
|
|
108
|
+
return faFileImage;
|
|
109
|
+
case 'excel':
|
|
110
|
+
case 'xlsx':
|
|
111
|
+
case 'xls':
|
|
112
|
+
case 'csv':
|
|
113
|
+
return faFileExcel;
|
|
114
|
+
case 'powerpoint':
|
|
115
|
+
case 'ppt':
|
|
116
|
+
case 'pptx':
|
|
117
|
+
return faFilePowerpoint;
|
|
118
|
+
case 'word':
|
|
119
|
+
case 'doc':
|
|
120
|
+
case 'docx':
|
|
121
|
+
return faFileWord;
|
|
122
|
+
case 'archive':
|
|
123
|
+
case 'zip':
|
|
124
|
+
case 'rar':
|
|
125
|
+
case 'tar':
|
|
126
|
+
case '7z':
|
|
127
|
+
return faFileArchive;
|
|
128
|
+
case 'code':
|
|
129
|
+
case 'js':
|
|
130
|
+
case 'jsx':
|
|
131
|
+
case 'ts':
|
|
132
|
+
case 'tsx':
|
|
133
|
+
case 'html':
|
|
134
|
+
case 'css':
|
|
135
|
+
case 'json':
|
|
136
|
+
case 'py':
|
|
137
|
+
case 'java':
|
|
138
|
+
case 'cpp':
|
|
139
|
+
return faFileCode;
|
|
140
|
+
case 'video':
|
|
141
|
+
case 'mp4':
|
|
142
|
+
case 'avi':
|
|
143
|
+
case 'mov':
|
|
144
|
+
case 'webm':
|
|
145
|
+
return faFileVideo;
|
|
146
|
+
case 'audio':
|
|
147
|
+
case 'mp3':
|
|
148
|
+
case 'wav':
|
|
149
|
+
case 'ogg':
|
|
150
|
+
return faFileAudio;
|
|
151
|
+
case 'md':
|
|
152
|
+
case 'markdown':
|
|
153
|
+
case 'txt':
|
|
154
|
+
case 'text':
|
|
155
|
+
return faFileLines;
|
|
156
|
+
default:
|
|
157
|
+
return faFile;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const getFileTypeColor = () => {
|
|
162
|
+
switch (type.toLowerCase()) {
|
|
163
|
+
case 'pdf':
|
|
164
|
+
return '#e74c3c'; // 红色
|
|
165
|
+
case 'image':
|
|
166
|
+
case 'img':
|
|
167
|
+
case 'jpg':
|
|
168
|
+
case 'jpeg':
|
|
169
|
+
case 'png':
|
|
170
|
+
case 'gif':
|
|
171
|
+
return '#3498db'; // 蓝色
|
|
172
|
+
case 'excel':
|
|
173
|
+
case 'xlsx':
|
|
174
|
+
case 'xls':
|
|
175
|
+
case 'csv':
|
|
176
|
+
return '#27ae60'; // 绿色
|
|
177
|
+
case 'powerpoint':
|
|
178
|
+
case 'ppt':
|
|
179
|
+
case 'pptx':
|
|
180
|
+
return '#e67e22'; // 橙色
|
|
181
|
+
case 'word':
|
|
182
|
+
case 'doc':
|
|
183
|
+
case 'docx':
|
|
184
|
+
return '#2980b9'; // 深蓝色
|
|
185
|
+
case 'archive':
|
|
186
|
+
case 'zip':
|
|
187
|
+
case 'rar':
|
|
188
|
+
case 'tar':
|
|
189
|
+
case '7z':
|
|
190
|
+
return '#8e44ad'; // 紫色
|
|
191
|
+
case 'code':
|
|
192
|
+
case 'js':
|
|
193
|
+
case 'jsx':
|
|
194
|
+
case 'ts':
|
|
195
|
+
case 'tsx':
|
|
196
|
+
case 'html':
|
|
197
|
+
case 'css':
|
|
198
|
+
case 'json':
|
|
199
|
+
case 'py':
|
|
200
|
+
case 'java':
|
|
201
|
+
case 'cpp':
|
|
202
|
+
return '#f1c40f'; // 黄色
|
|
203
|
+
case 'video':
|
|
204
|
+
case 'mp4':
|
|
205
|
+
case 'avi':
|
|
206
|
+
case 'mov':
|
|
207
|
+
case 'webm':
|
|
208
|
+
return '#e84393'; // 粉色
|
|
209
|
+
case 'audio':
|
|
210
|
+
case 'mp3':
|
|
211
|
+
case 'wav':
|
|
212
|
+
case 'ogg':
|
|
213
|
+
return '#00cec9'; // 青色
|
|
214
|
+
case 'md':
|
|
215
|
+
case 'markdown':
|
|
216
|
+
case 'txt':
|
|
217
|
+
case 'text':
|
|
218
|
+
return '#636e72'; // 深灰色
|
|
219
|
+
default:
|
|
220
|
+
return '#7f8c8d'; // 灰色
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const formatSize = (bytes: number) => {
|
|
225
|
+
if (bytes < 1024) return bytes + ' B';
|
|
226
|
+
if (bytes < 1048576) return (bytes/1024).toFixed(1) + ' KB';
|
|
227
|
+
return (bytes/1048576).toFixed(1) + ' MB';
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<FileDisplayContainer>
|
|
232
|
+
<FileIconWrapper $color={getFileTypeColor()}>
|
|
233
|
+
<FontAwesomeIcon icon={getFileIcon()} style={{ fontSize: '24px' }} />
|
|
234
|
+
</FileIconWrapper>
|
|
235
|
+
<FileInfo>
|
|
236
|
+
<FileName>{name}</FileName>
|
|
237
|
+
<FileDetails>
|
|
238
|
+
<FileSize>{formatSize(size)}</FileSize>
|
|
239
|
+
<FileType>{type.toUpperCase()}</FileType>
|
|
240
|
+
</FileDetails>
|
|
241
|
+
</FileInfo>
|
|
242
|
+
</FileDisplayContainer>
|
|
243
|
+
);
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
export default FileDisplay;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import styled from 'styled-components';
|
|
3
|
+
|
|
4
|
+
// ==================== Styled Components ====================
|
|
5
|
+
|
|
6
|
+
const ChartLoadingContainer = styled.div`
|
|
7
|
+
padding: 10px;
|
|
8
|
+
text-align: center;
|
|
9
|
+
background: #f9f9f9;
|
|
10
|
+
border-radius: 4px;
|
|
11
|
+
color: #666;
|
|
12
|
+
min-height: 100px;
|
|
13
|
+
display: flex;
|
|
14
|
+
align-items: center;
|
|
15
|
+
justify-content: center;
|
|
16
|
+
|
|
17
|
+
span {
|
|
18
|
+
font-size: 14px;
|
|
19
|
+
line-height: 1.5;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
&:hover {
|
|
23
|
+
background: #f5f5f5;
|
|
24
|
+
}
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
// ==================== Component ====================
|
|
28
|
+
|
|
29
|
+
interface ChartLoadingProps {
|
|
30
|
+
text?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ChartLoading: React.FC<ChartLoadingProps> = ({ text = '图表加载中...' }) => {
|
|
34
|
+
return (
|
|
35
|
+
<ChartLoadingContainer>
|
|
36
|
+
<span>{text}</span>
|
|
37
|
+
</ChartLoadingContainer>
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export default ChartLoading;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
2
|
+
import { loadPrism, loadPrismLanguage } from '@/utils/loadPrism';
|
|
3
|
+
|
|
4
|
+
interface ASyntaxHighLightProps {
|
|
5
|
+
language?: string;
|
|
6
|
+
children: any;
|
|
7
|
+
style?: React.CSSProperties;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const CopyIcon = () => (
|
|
11
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
|
12
|
+
<path d="M16 1H4C2.9 1 2 1.9 2 3V17H4V3H16V1ZM19 5H8C6.9 5 6 5.9 6 7V21C6 22.1 6.9 23 8 23H19C20.1 23 21 22.1 21 21V7C21 5.9 20.1 5 19 5ZM19 21H8V7H19V21Z" />
|
|
13
|
+
</svg>
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const CheckIcon = () => (
|
|
17
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
|
18
|
+
<path d="M9 16.17L4.83 12L3.41 13.41L9 19L21 7L19.59 5.59L9 16.17Z" />
|
|
19
|
+
</svg>
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
export const ASyntaxHighLight: React.FC<ASyntaxHighLightProps> = ({
|
|
23
|
+
language = 'javascript',
|
|
24
|
+
children,
|
|
25
|
+
style
|
|
26
|
+
}) => {
|
|
27
|
+
const [copied, setCopied] = useState(false);
|
|
28
|
+
const [highlightedCode, setHighlightedCode] = useState<string>('');
|
|
29
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
30
|
+
const codeRef = useRef<HTMLElement>(null);
|
|
31
|
+
|
|
32
|
+
// 获取代码文本
|
|
33
|
+
const codeText = typeof children === 'string'
|
|
34
|
+
? children
|
|
35
|
+
: Array.isArray(children)
|
|
36
|
+
? children.join('')
|
|
37
|
+
: String(children || '');
|
|
38
|
+
|
|
39
|
+
// 加载 Prism 并高亮代码
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
let isMounted = true;
|
|
42
|
+
|
|
43
|
+
const highlight = async () => {
|
|
44
|
+
try {
|
|
45
|
+
setIsLoading(true);
|
|
46
|
+
const Prism = await loadPrism();
|
|
47
|
+
await loadPrismLanguage(language);
|
|
48
|
+
|
|
49
|
+
if (!isMounted) return;
|
|
50
|
+
|
|
51
|
+
// 获取语法定义
|
|
52
|
+
const grammar = Prism.languages[language] || Prism.languages.javascript;
|
|
53
|
+
const highlighted = Prism.highlight(codeText.replace(/\n$/, ''), grammar, language);
|
|
54
|
+
|
|
55
|
+
setHighlightedCode(highlighted);
|
|
56
|
+
setIsLoading(false);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error('Failed to highlight code:', error);
|
|
59
|
+
if (isMounted) {
|
|
60
|
+
setHighlightedCode(escapeHtml(codeText));
|
|
61
|
+
setIsLoading(false);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
highlight();
|
|
67
|
+
|
|
68
|
+
return () => {
|
|
69
|
+
isMounted = false;
|
|
70
|
+
};
|
|
71
|
+
}, [codeText, language]);
|
|
72
|
+
|
|
73
|
+
// HTML 转义函数
|
|
74
|
+
const escapeHtml = (text: string) => {
|
|
75
|
+
const div = document.createElement('div');
|
|
76
|
+
div.textContent = text;
|
|
77
|
+
return div.innerHTML;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const handleCopy = async () => {
|
|
81
|
+
try {
|
|
82
|
+
await navigator.clipboard.writeText(codeText);
|
|
83
|
+
setCopied(true);
|
|
84
|
+
setTimeout(() => setCopied(false), 2000);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
console.error('Failed to copy:', err);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// 计算行号
|
|
91
|
+
const lines = codeText.split('\n');
|
|
92
|
+
const lineCount = lines[lines.length - 1] === '' ? lines.length - 1 : lines.length;
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div style={{ position: 'relative', ...style }}>
|
|
96
|
+
<pre
|
|
97
|
+
className="line-numbers"
|
|
98
|
+
style={{
|
|
99
|
+
margin: 0,
|
|
100
|
+
padding: '1em',
|
|
101
|
+
paddingLeft: '3.8em',
|
|
102
|
+
overflow: 'auto',
|
|
103
|
+
background: '#e3eaf2',
|
|
104
|
+
borderRadius: '4px',
|
|
105
|
+
fontSize: '13px',
|
|
106
|
+
lineHeight: '1.5',
|
|
107
|
+
fontFamily: 'Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace',
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
{/* 行号 */}
|
|
111
|
+
<span
|
|
112
|
+
className="line-numbers-rows"
|
|
113
|
+
style={{
|
|
114
|
+
position: 'absolute',
|
|
115
|
+
left: 0,
|
|
116
|
+
top: '1em',
|
|
117
|
+
width: '3em',
|
|
118
|
+
borderRight: '1px solid #8da1b9',
|
|
119
|
+
textAlign: 'right',
|
|
120
|
+
paddingRight: '0.8em',
|
|
121
|
+
userSelect: 'none',
|
|
122
|
+
color: '#8da1b9',
|
|
123
|
+
fontSize: '13px',
|
|
124
|
+
lineHeight: '1.5',
|
|
125
|
+
}}
|
|
126
|
+
>
|
|
127
|
+
{Array.from({ length: lineCount }, (_, i) => (
|
|
128
|
+
<span key={i} style={{ display: 'block' }}>{i + 1}</span>
|
|
129
|
+
))}
|
|
130
|
+
</span>
|
|
131
|
+
|
|
132
|
+
<code
|
|
133
|
+
ref={codeRef}
|
|
134
|
+
className={`language-${language}`}
|
|
135
|
+
style={{
|
|
136
|
+
whiteSpace: 'pre-wrap',
|
|
137
|
+
wordBreak: 'break-word',
|
|
138
|
+
}}
|
|
139
|
+
dangerouslySetInnerHTML={
|
|
140
|
+
isLoading
|
|
141
|
+
? { __html: escapeHtml(codeText) }
|
|
142
|
+
: { __html: highlightedCode }
|
|
143
|
+
}
|
|
144
|
+
/>
|
|
145
|
+
</pre>
|
|
146
|
+
|
|
147
|
+
{/* 复制按钮 */}
|
|
148
|
+
<button
|
|
149
|
+
onClick={handleCopy}
|
|
150
|
+
style={{
|
|
151
|
+
position: 'absolute',
|
|
152
|
+
right: '8px',
|
|
153
|
+
top: '8px',
|
|
154
|
+
padding: '4px 8px',
|
|
155
|
+
background: 'rgba(255,255,255,0.8)',
|
|
156
|
+
border: '1px solid #ccc',
|
|
157
|
+
borderRadius: '4px',
|
|
158
|
+
cursor: 'pointer',
|
|
159
|
+
opacity: 0.7,
|
|
160
|
+
transition: 'all 0.2s',
|
|
161
|
+
display: 'flex',
|
|
162
|
+
alignItems: 'center',
|
|
163
|
+
justifyContent: 'center',
|
|
164
|
+
fontSize: '12px',
|
|
165
|
+
color: '#333',
|
|
166
|
+
}}
|
|
167
|
+
onMouseEnter={e => {
|
|
168
|
+
e.currentTarget.style.opacity = '1';
|
|
169
|
+
e.currentTarget.style.background = 'rgba(255,255,255,1)';
|
|
170
|
+
}}
|
|
171
|
+
onMouseLeave={e => {
|
|
172
|
+
e.currentTarget.style.opacity = '0.7';
|
|
173
|
+
e.currentTarget.style.background = 'rgba(255,255,255,0.8)';
|
|
174
|
+
}}
|
|
175
|
+
title={copied ? 'Copied!' : 'Copy code'}
|
|
176
|
+
>
|
|
177
|
+
{copied ? <CheckIcon /> : <CopyIcon />}
|
|
178
|
+
<span style={{ marginLeft: '4px' }}>{copied ? 'Copied' : 'Copy'}</span>
|
|
179
|
+
</button>
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
};
|