feat: Add base project structure with API and web components
This commit is contained in:
22
web/src/components/Markdown/AudioBlock.tsx
Normal file
22
web/src/components/Markdown/AudioBlock.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { memo } from 'react'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
interface AudioBlockProps {
|
||||
node: {
|
||||
children: { properties: { src: string } }[]
|
||||
}
|
||||
}
|
||||
const AudioBlock: FC<AudioBlockProps> = (props) => {
|
||||
// console.log('AudioBlock', props)
|
||||
const { children } = props.node;
|
||||
const srcs = children.map(item => item.properties?.src).filter(item => item)
|
||||
|
||||
return (
|
||||
<>
|
||||
{srcs.map(src => <audio key={src} src={src} controls />)}
|
||||
</>
|
||||
|
||||
)
|
||||
}
|
||||
export default memo(AudioBlock)
|
||||
87
web/src/components/Markdown/Code.tsx
Normal file
87
web/src/components/Markdown/Code.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { type FC, useMemo } from 'react'
|
||||
import SyntaxHighlighter from 'react-syntax-highlighter';
|
||||
import { atelierHeathLight } from 'react-syntax-highlighter/dist/esm/styles/hljs';
|
||||
import CopyBtn from './CopyBtn';
|
||||
import ReactEcharts from 'echarts-for-react';
|
||||
import Svg from './Svg'
|
||||
import MermaidChart from './MermaidChart'
|
||||
|
||||
|
||||
type ICodeProps = {
|
||||
children: string;
|
||||
className: string;
|
||||
}
|
||||
|
||||
const Code: FC<ICodeProps> = (props) => {
|
||||
const { children, className } = props;
|
||||
const language = className?.split('-')[1]
|
||||
console.log('Code', props)
|
||||
|
||||
const charData = useMemo(() => {
|
||||
if (language !== 'echarts') return null;
|
||||
try {
|
||||
return JSON.parse(String(children).replace(/\n$/, ''))
|
||||
} catch (error) {
|
||||
console.error('Error parsing JSON for ECharts:', error)
|
||||
return {"title":{"text":"ECharts error - Wrong JSON format."}}
|
||||
}
|
||||
}, [language, children])
|
||||
|
||||
if (language === 'echarts') {
|
||||
return (
|
||||
<ReactEcharts
|
||||
option={charData}
|
||||
style={{
|
||||
height: '400px',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (language === 'svg') {
|
||||
return (
|
||||
<Svg
|
||||
content={children.replace(/\n/g, '')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (language === 'mermaid') {
|
||||
return (
|
||||
<MermaidChart
|
||||
content={String(children).replace(/\n$/, '')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (className) {
|
||||
return (
|
||||
<div className="rb:relative">
|
||||
<SyntaxHighlighter
|
||||
style={atelierHeathLight}
|
||||
customStyle={{
|
||||
padding: '16px 20px 16px 24px',
|
||||
backgroundColor: '#F0F3F8',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
language={language}
|
||||
showLineNumbers={false}
|
||||
PreTag="div"
|
||||
>
|
||||
{children}
|
||||
</SyntaxHighlighter>
|
||||
<CopyBtn
|
||||
value={children}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 20,
|
||||
right: 20,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <span className="rb:bg-[#F0F3F8] rb:px-1 rb:py-0.5 rb:rounded rb:text-sm rb:font-mono">{children}</span>
|
||||
}
|
||||
|
||||
export default Code
|
||||
48
web/src/components/Markdown/CodeBlock.tsx
Normal file
48
web/src/components/Markdown/CodeBlock.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { type FC } from 'react'
|
||||
import SyntaxHighlighter from 'react-syntax-highlighter';
|
||||
import { atelierHeathLight } from 'react-syntax-highlighter/dist/esm/styles/hljs';
|
||||
import CopyBtn from './CopyBtn';
|
||||
|
||||
|
||||
type ICodeBlockProps = {
|
||||
value: string;
|
||||
}
|
||||
|
||||
// enum languageType {
|
||||
// echarts = 'echarts',
|
||||
// mermaid = 'mermaid',
|
||||
// svg = 'svg',
|
||||
// }
|
||||
|
||||
const CodeBlock: FC<ICodeBlockProps> = ({
|
||||
value,
|
||||
}) => {
|
||||
|
||||
return (
|
||||
<div className="rb:relative">
|
||||
<SyntaxHighlighter
|
||||
style={atelierHeathLight}
|
||||
customStyle={{
|
||||
padding: '16px 20px 16px 24px',
|
||||
backgroundColor: '#F0F3F8',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
language="json"
|
||||
showLineNumbers={false}
|
||||
PreTag="div"
|
||||
>
|
||||
{value}
|
||||
</SyntaxHighlighter>
|
||||
<CopyBtn
|
||||
value={value}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 20,
|
||||
right: 20,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CodeBlock
|
||||
31
web/src/components/Markdown/CopyBtn.tsx
Normal file
31
web/src/components/Markdown/CopyBtn.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { type FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { Button, App } from 'antd'
|
||||
|
||||
|
||||
type ICopyBtnProps = {
|
||||
value: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const CopyBtn: FC<ICopyBtnProps> = ({
|
||||
value,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { message } = App.useApp()
|
||||
|
||||
const handleCopy = () => {
|
||||
copy(value)
|
||||
message.success(t('common.copySuccess'))
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={handleCopy} className={className} style={style}>{t('common.copy')}</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default CopyBtn
|
||||
14
web/src/components/Markdown/Link.tsx
Normal file
14
web/src/components/Markdown/Link.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { memo } from 'react'
|
||||
|
||||
import type { FC, ReactNode } from 'react'
|
||||
|
||||
interface LinkProps {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
const Link: FC<LinkProps> = (props) => {
|
||||
// console.log('Link', props)
|
||||
const { children, href } = props;
|
||||
return <a href={href} target="_blank" rel="noopener noreferrer">{children}</a>
|
||||
}
|
||||
export default memo(Link)
|
||||
46
web/src/components/Markdown/MermaidChart.tsx
Normal file
46
web/src/components/Markdown/MermaidChart.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useRef, useEffect, useState, type FC } from 'react'
|
||||
import mermaid from 'mermaid'
|
||||
import CryptoJS from 'crypto-js'
|
||||
import { Image } from 'antd'
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: true,
|
||||
theme: 'default',
|
||||
flowchart: {
|
||||
htmlLabels: true,
|
||||
useMaxWidth: true,
|
||||
},
|
||||
})
|
||||
|
||||
const svgToBase64 = (svgGraph: string) => {
|
||||
const svgBytes = new TextEncoder().encode(svgGraph)
|
||||
const blob = new Blob([svgBytes], { type: 'image/svg+xml;charset=utf-8' })
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => resolve(reader.result)
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
const MermaidChart: FC<{ content: string }> = ({ content }) => {
|
||||
const [chartSvg, setChartSvg] = useState<string>('')
|
||||
const id = useRef(`mermaidchart_${CryptoJS.MD5(content).toString()}`)
|
||||
|
||||
useEffect(() => {
|
||||
if (!content || content === '') {
|
||||
return
|
||||
}
|
||||
drawDiagram()
|
||||
}, [content])
|
||||
|
||||
const drawDiagram = async function () {
|
||||
const { svg } = await mermaid.render(id.current, content);
|
||||
|
||||
const base64 = await svgToBase64(svg)
|
||||
setChartSvg(base64 as string)
|
||||
};
|
||||
return (
|
||||
<Image src={chartSvg} />
|
||||
)
|
||||
}
|
||||
export default MermaidChart
|
||||
17
web/src/components/Markdown/Paragraph.tsx
Normal file
17
web/src/components/Markdown/Paragraph.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { memo } from 'react'
|
||||
|
||||
import type { FC, ReactNode } from 'react'
|
||||
|
||||
interface ParagraphProps {
|
||||
node: {
|
||||
children: ReactNode;
|
||||
};
|
||||
children: string[]
|
||||
}
|
||||
const Paragraph: FC<ParagraphProps> = (props) => {
|
||||
// console.log('Paragraph', props)
|
||||
const { children } = props
|
||||
|
||||
return <p>{children}</p>
|
||||
}
|
||||
export default memo(Paragraph)
|
||||
21
web/src/components/Markdown/RbButton.tsx
Normal file
21
web/src/components/Markdown/RbButton.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { memo } from 'react'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import { Button } from 'antd'
|
||||
|
||||
interface RbButtonProps {
|
||||
node: {
|
||||
children: ReactNode;
|
||||
};
|
||||
children: string[]
|
||||
}
|
||||
const RbButton: FC<RbButtonProps> = (props) => {
|
||||
console.log('RbButton', props)
|
||||
const { children } = props;
|
||||
|
||||
return (
|
||||
<Button>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
export default memo(RbButton)
|
||||
23
web/src/components/Markdown/Svg.tsx
Normal file
23
web/src/components/Markdown/Svg.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react';
|
||||
|
||||
interface SvgProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染SVG内容的组件
|
||||
*/
|
||||
function Svg(props: SvgProps): JSX.Element {
|
||||
const { content } = props;
|
||||
// console.log('Svg', props)
|
||||
|
||||
return React.createElement(
|
||||
'div',
|
||||
{
|
||||
className: 'svg-container',
|
||||
dangerouslySetInnerHTML: { __html: content }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default Svg;
|
||||
22
web/src/components/Markdown/VideoBlock.tsx
Normal file
22
web/src/components/Markdown/VideoBlock.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { memo } from 'react'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
interface VideoBlockProps {
|
||||
node: {
|
||||
children: { properties: { src: string } }[]
|
||||
}
|
||||
}
|
||||
const VideoBlock: FC<VideoBlockProps> = (props) => {
|
||||
// console.log('VideoBlock', props)
|
||||
const { children } = props.node;
|
||||
const srcs = children.map(item => item.properties?.src).filter(item => item)
|
||||
|
||||
return (
|
||||
<>
|
||||
{srcs.map(src => <video key={src} src={src} controls />)}
|
||||
</>
|
||||
|
||||
)
|
||||
}
|
||||
export default memo(VideoBlock)
|
||||
147
web/src/components/Markdown/index.tsx
Normal file
147
web/src/components/Markdown/index.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { Image, Input, Select, Form, Checkbox, Radio, ColorPicker, DatePicker, TimePicker, InputNumber, Slider } from 'antd'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import RemarkGfm from 'remark-gfm'
|
||||
import RemarkMath from 'remark-math'
|
||||
import RemarkBreaks from 'remark-breaks'
|
||||
import RehypeKatex from 'rehype-katex'
|
||||
import RehypeRaw from 'rehype-raw'
|
||||
import type { FC } from 'react'
|
||||
|
||||
import Code from './Code'
|
||||
import VideoBlock from './VideoBlock'
|
||||
import AudioBlock from './AudioBlock'
|
||||
import Link from './Link'
|
||||
import RbButton from './RbButton'
|
||||
|
||||
interface RbMarkdownProps {
|
||||
content: string;
|
||||
showHtmlComments?: boolean; // 是否显示 HTML 注释,默认为 false(隐藏)
|
||||
}
|
||||
|
||||
const components = {
|
||||
h1: ({ children }: { children: string }) => <h1 className="rb:text-2xl rb:font-bold rb:mb-2">{children}</h1>,
|
||||
h2: ({ children }: { children: string }) => <h2 className="rb:text-xl rb:font-bold rb:mb-2">{children}</h2>,
|
||||
h3: ({ children }: { children: string }) => <h3 className="rb:text-lg rb:font-bold rb:mb-2">{children}</h3>,
|
||||
h4: ({ children }: { children: string }) => <h4 className="rb:text-md rb:font-bold rb:mb-2">{children}</h4>,
|
||||
h5: ({ children }: { children: string }) => <h5 className="rb:text-sm rb:font-bold rb:mb-2">{children}</h5>,
|
||||
h6: ({ children }: { children: string }) => <h6 className="rb:text-xs rb:font-bold rb:mb-2">{children}</h6>,
|
||||
ul: ({ children }: { children: string }) => <ul className="rb:list-disc rb:ml-6 rb:mb-2">{children}</ul>,
|
||||
ol: ({ children }: { children: string }) => <ol className="rb:list-decimal rb:ml-6 rb:mb-2">{children}</ol>,
|
||||
li: ({ children }: { children: string }) => <li className="rb:mb-1">{children}</li>,
|
||||
blockquote: ({ children }: { children: string }) => <blockquote className="rb:border-l-4 rb:border-[#D9D9D9] rb:pl-4 rb:mb-2">{children}</blockquote>,
|
||||
p: ({ children }: { children: string }) => <p className="rb:mb-2">{children}</p>,
|
||||
strong: ({ children }: { children: string }) => <strong className="rb:font-bold">{children}</strong>,
|
||||
em: ({ children }: { children: string }) => <em className="rb:italic">{children}</em>,
|
||||
del: ({ children }: { children: string }) => <del className="rb:line-through">{children}</del>,
|
||||
span: ({ children, ...props }: any) => {
|
||||
// 如果是 HTML 注释的 span,应用特殊样式
|
||||
if (props.style?.color === '#999') {
|
||||
return <span style={{ color: '#999', fontSize: '0.9em' }}>{children}</span>
|
||||
}
|
||||
return <span {...props}>{children}</span>
|
||||
},
|
||||
|
||||
code: Code,
|
||||
img: Image,
|
||||
video: VideoBlock,
|
||||
audio: AudioBlock,
|
||||
a: Link,
|
||||
button: RbButton,
|
||||
table: ({ children }: { children: string }) => <table className="rb:border rb:border-[#D9D9D9] rb:mb-2">{children}</table>,
|
||||
tr: ({ children }: { children: string }) => <tr className="rb:border rb:border-[#D9D9D9]">{children}</tr>,
|
||||
th: ({ children }: { children: string }) => <th className="rb:border rb:border-[#D9D9D9] rb:px-2 rb:py-1 rb:text-left rb:font-bold">{children}</th>,
|
||||
td: ({ children }: { children: string }) => <td className="rb:border rb:border-[#D9D9D9] rb:px-2 rb:py-1 rb:text-left">{children}</td>,
|
||||
input: ({ children, ...props }: { children: string }) => {
|
||||
switch (props.type) {
|
||||
case 'color':
|
||||
return <ColorPicker {...props} />
|
||||
case 'time':
|
||||
return <TimePicker {...props} />
|
||||
case 'date':
|
||||
return <DatePicker {...props} />
|
||||
case 'datetime':
|
||||
case 'datetime-local':
|
||||
return <DatePicker showTime={true} {...props} />
|
||||
case 'week':
|
||||
return <DatePicker picker="week" {...props} />
|
||||
case 'month':
|
||||
return <DatePicker picker="month" {...props} />
|
||||
case 'number':
|
||||
return <InputNumber {...props} />
|
||||
case 'search':
|
||||
return <Input.Search {...props} />
|
||||
case 'range':
|
||||
return <Slider {...props} />
|
||||
case 'submit':
|
||||
case 'button':
|
||||
return <RbButton {...props}>{props.value}</RbButton>
|
||||
case 'checkbox':
|
||||
return <Checkbox {...props}>{children}</Checkbox>
|
||||
case 'password':
|
||||
return <Input.Password {...props} />
|
||||
case 'radio':
|
||||
return <Radio {...props}>{children}</Radio>
|
||||
default:
|
||||
return <Input value={children} {...props} />
|
||||
}
|
||||
},
|
||||
select: ({ children, ...props }: { children: string }) => <Select style={{width: '100%'}} {...props}>{children}</Select>,
|
||||
textarea: ({ children, ...props }: { children: string }) => <Input.TextArea {...props}>{children}</Input.TextArea>,
|
||||
form: ({ children }: { children: string }) => <Form>{children}</Form>,
|
||||
}
|
||||
|
||||
const RbMarkdown: FC<RbMarkdownProps> = ({
|
||||
content,
|
||||
showHtmlComments = false,
|
||||
}) => {
|
||||
// 根据参数决定是否将 HTML 注释转换为可见文本
|
||||
// 使用特殊的 markdown 语法来显示注释,避免被 rehype-raw 过滤
|
||||
const processedContent = showHtmlComments
|
||||
? content.replace(/<!--([\s\S]*?)-->/g, (_match, commentContent) => {
|
||||
// 转换为带样式的文本,使用 <span class="html-comment"> 标记
|
||||
const escaped = commentContent.trim().replace(/</g, '<').replace(/>/g, '>')
|
||||
return `<span class="html-comment"><!-- ${escaped} --></span>`
|
||||
})
|
||||
: content
|
||||
|
||||
return (
|
||||
<div>
|
||||
<style>{`
|
||||
.html-comment {
|
||||
color: #999;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
`}</style>
|
||||
<ReactMarkdown
|
||||
// allowElement={[]}
|
||||
// allowedElements={[]}
|
||||
components={components}
|
||||
disallowedElements={['script', 'iframe', 'head', 'html', 'meta', 'link', 'style', 'body']}
|
||||
rehypePlugins={[
|
||||
RehypeKatex,
|
||||
RehypeRaw,
|
||||
// The Rehype plug-in is used to remove the ref attribute of an element
|
||||
// () => {
|
||||
// return (tree) => {
|
||||
// const iterate = (node: any) => {
|
||||
// if (node.type === 'element' && !node.properties?.src && node.properties?.ref && node.properties.ref.startsWith('{') && node.properties.ref.endsWith('}'))
|
||||
// delete node.properties.ref
|
||||
|
||||
// if (node.children)
|
||||
// node.children.forEach(iterate)
|
||||
// }
|
||||
// tree.children.forEach(iterate)
|
||||
// }
|
||||
// },
|
||||
]}
|
||||
remarkPlugins={[RemarkGfm, RemarkMath, RemarkBreaks]}
|
||||
remarkRehypeOptions={{
|
||||
allowDangerousHtml: true,
|
||||
}}
|
||||
>
|
||||
{processedContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default RbMarkdown
|
||||
Reference in New Issue
Block a user