diff --git a/package.json b/package.json index 70e8a83..0a29eab 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@ant-design/icons": "^5.6.1", "@ant-design/pro-components": "^2.8.9", "@ant-design/v5-patch-for-react-19": "^1.0.3", + "@tinymce/tinymce-react": "^6.3.0", "antd": "^5.26.4", "antd-style": "^3.7.0", "classnames": "^2.5.1", @@ -45,6 +46,7 @@ "jsencrypt": "^3.5.4", "react": "^19.1.0", "react-dom": "^19.1.0", + "tinymce": "^8.1.2", "web-storage-cache": "^1.1.1" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3b842d..62cc0fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@ant-design/v5-patch-for-react-19': specifier: ^1.0.3 version: 1.0.3(antd@5.27.3(date-fns@2.30.0)(moment@2.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@tinymce/tinymce-react': + specifier: ^6.3.0 + version: 6.3.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(tinymce@8.1.2) antd: specifier: ^5.26.4 version: 5.27.3(date-fns@2.30.0)(moment@2.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -38,6 +41,9 @@ importers: react-dom: specifier: ^19.1.0 version: 19.1.1(react@19.1.1) + tinymce: + specifier: ^8.1.2 + version: 8.1.2 web-storage-cache: specifier: ^1.1.1 version: 1.1.1 @@ -2551,6 +2557,16 @@ packages: '@types/react-dom': optional: true + '@tinymce/tinymce-react@6.3.0': + resolution: {integrity: sha512-E++xnn0XzDzpKr40jno2Kj7umfAE6XfINZULEBBeNjTMvbACWzA6CjiR6V8eTDc9yVmdVhIPqVzV4PqD5TZ/4g==} + peerDependencies: + react: ^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0 + react-dom: ^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0 + tinymce: ^8.0.0 || ^7.0.0 || ^6.0.0 || ^5.5.1 + peerDependenciesMeta: + tinymce: + optional: true + '@tootallnate/once@1.1.2': resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} engines: {node: '>= 6'} @@ -10246,6 +10262,9 @@ packages: tinyexec@1.0.1: resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==} + tinymce@8.1.2: + resolution: {integrity: sha512-KITxHEEHRlxC5xOnxA123eAJ67NgsWxNphtItWt9TRu07DiTZrWIqJeIKRX9euE51/l3kJO4WQiqoBXKTJJGsA==} + titleize@3.0.0: resolution: {integrity: sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==} engines: {node: '>=12'} @@ -14715,6 +14734,14 @@ snapshots: '@types/react': 19.1.12 '@types/react-dom': 19.1.9(@types/react@19.1.12) + '@tinymce/tinymce-react@6.3.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(tinymce@8.1.2)': + dependencies: + prop-types: 15.8.1 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + tinymce: 8.1.2 + '@tootallnate/once@1.1.2': {} '@tootallnate/once@2.0.0': {} @@ -24850,6 +24877,8 @@ snapshots: tinyexec@1.0.1: {} + tinymce@8.1.2: {} + titleize@3.0.0: {} tldts-core@6.1.86: {} diff --git a/src/components/DrawerForm/index.tsx b/src/components/DrawerForm/index.tsx index f213dd7..ec7f714 100644 --- a/src/components/DrawerForm/index.tsx +++ b/src/components/DrawerForm/index.tsx @@ -1,16 +1,14 @@ -import { CloseOutlined } from '@ant-design/icons'; import type { ProFormColumnsType } from '@ant-design/pro-components'; -import { BetaSchemaForm, DrawerForm } from '@ant-design/pro-components'; +import { BetaSchemaForm } from '@ant-design/pro-components'; import { Button, type ColProps, Drawer, Space, Typography } from 'antd'; import React, { forwardRef, useImperativeHandle } from 'react'; -const { Title } = Typography; interface ConfigurableDrawerFormProps { title?: string; columns: ProFormColumnsType[]; onSubmit?: (values: any) => Promise; initialValues?: Record; - width?: number; + width?: number | string; labelCol?: ColProps; wrapperCol?: ColProps; } @@ -70,31 +68,31 @@ const ConfigurableDrawerForm = forwardRef< setLoading(false); } }; - const customHeader = ( -
- - {title} - -
- ); + // const customHeader = ( + //
+ // + // {title} + // + //
+ // ); return ( void; + height?: number; + placeholder?: string; + disabled?: boolean; + uploadConfig?: { + action: string; + headers?: Record; + maxSize?: number; + acceptTypes?: string[]; + data?: Record; + }; + showWordCount?: boolean; + maxWords?: number; +} + +const RichEditor: React.FC = ({ + value = '', + onChange, + height = 400, + placeholder = '请输入内容...', + disabled = false, + showWordCount = true, + maxWords, + uploadConfig = { + action: '/api/upload/image', + maxSize: 5, + acceptTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], + }, +}) => { + const [uploading, setUploading] = useState(false); + const [wordCount, setWordCount] = useState(0); + const [charCount, setCharCount] = useState(0); + + // 自定义字数统计函数 + const countWords = (text: string) => { + const plainText = text.replace(/<[^>]*>/g, ''); + const cleanText = plainText.replace(/\s+/g, ' ').trim(); + + if (!cleanText) return { words: 0, characters: 0 }; + + const chineseChars = cleanText.match(/[\u4e00-\u9fa5]/g) || []; + const englishWords = cleanText.match(/[a-zA-Z]+/g) || []; + const numbers = cleanText.match(/\d+/g) || []; + + const words = chineseChars.length + englishWords.length + numbers.length; + const characters = cleanText.length; + + return { words, characters }; + }; + + const handleEditorChange = (content: string) => { + onChange?.(content); + + if (showWordCount) { + const { words, characters } = countWords(content); + setWordCount(words); + setCharCount(characters); + + if (maxWords && words > maxWords) { + message.warning( + `内容超出字数限制,当前 ${words} 字,限制 ${maxWords} 字`, + ); + } + } + }; + + useEffect(() => { + if (showWordCount && value) { + const { words, characters } = countWords(value); + setWordCount(words); + setCharCount(characters); + } + }, [value, showWordCount]); + + const uploadFile = useCallback( + async (file: File): Promise => { + return new Promise((resolve, reject) => { + const formData = new FormData(); + formData.append('file', file); + + if (uploadConfig.data) { + Object.keys(uploadConfig.data).forEach((key) => { + if (uploadConfig.data) formData.append(key, uploadConfig.data[key]); + }); + } + + const xhr = new XMLHttpRequest(); + + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + const percent = Math.round((e.loaded / e.total) * 100); + console.log(`上传进度: ${percent}%`); + } + }); + + xhr.addEventListener('load', () => { + if (xhr.status === 200) { + try { + const response = JSON.parse(xhr.responseText); + if (response.code === 200 && response.data?.url) { + resolve(response.data.url); + } else { + reject(response.message || '上传失败'); + } + } catch (_error) { + reject('响应解析失败'); + } + } else { + reject(`上传失败: ${xhr.status}`); + } + }); + + xhr.addEventListener('error', () => { + reject('网络错误'); + }); + + if (uploadConfig.headers) { + Object.keys(uploadConfig.headers).forEach((key) => { + if (uploadConfig.headers) + xhr.setRequestHeader(key, uploadConfig.headers[key]); + }); + } + + xhr.open('POST', uploadConfig.action); + xhr.send(formData); + }); + }, + [uploadConfig], + ); + + const handleImageUpload = useCallback( + (blobInfo: any, _: (percent: number) => void): Promise => { + return new Promise((resolve, reject) => { + try { + setUploading(true); + const file = blobInfo.blob(); + uploadFile(file).then((url) => { + resolve(url); + message.success('图片上传成功'); + }); + } catch (error) { + reject(error); + message.error(typeof error === 'string' ? error : '上传失败'); + } finally { + setUploading(false); + } + }); + }, + [uploadFile], + ); + + const handleCustomUpload = useCallback( + (editor: TinyMCEEditor) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = uploadConfig.acceptTypes?.join(',') || 'image/*'; + input.multiple = true; + + const handleFileChange = async (event: Event) => { + const target = event.target as HTMLInputElement; + const files = Array.from(target.files || []); + + if (files.length === 0) return; + + setUploading(true); + + try { + for (const file of files) { + const isValidType = uploadConfig.acceptTypes?.includes(file.type); + if (!isValidType) { + message.error(`文件 ${file.name} 类型不支持`); + continue; + } + + const isValidSize = + file.size / 1024 / 1024 < (uploadConfig.maxSize || 5); + if (!isValidSize) { + message.error( + `文件 ${file.name} 大小超过 ${uploadConfig.maxSize || 5}MB`, + ); + continue; + } + + try { + const url = await uploadFile(file); + const imgHtml = `${file.name}`; + editor.insertContent(imgHtml); + message.success(`${file.name} 上传成功`); + } catch (error) { + message.error(`${file.name} 上传失败: ${error}`); + } + } + } finally { + setUploading(false); + } + }; + + input.addEventListener('change', handleFileChange); + input.click(); + }, + [uploadConfig, uploadFile], + ); + + // 全展开的工具栏配置 + const editorConfig = { + height, + menubar: 'file edit view insert format tools table help', + plugins: [ + 'advlist', + 'autolink', + 'lists', + 'link', + 'image', + 'charmap', + 'preview', + 'anchor', + 'searchreplace', + 'visualblocks', + 'code', + 'fullscreen', + 'insertdatetime', + 'media', + 'table', + 'help', + 'wordcount', + 'emoticons', + 'template', + 'codesample', + 'hr', + 'pagebreak', + 'nonbreaking', + 'toc', + 'imagetools', + 'textpattern', + 'noneditable', + 'quickbars', + 'accordion', + ], + + // 修改为全展开的工具栏配置 + toolbar: [ + // 第一行:撤销重做 + 格式选择 + 字体 + 'undo redo | blocks fontfamily fontsize', + + // 第二行:文本格式 + 'bold italic underline strikethrough subscript superscript | forecolor backcolor', + + // 第三行:对齐和列表 + 'alignleft aligncenter alignright alignjustify | bullist numlist outdent indent', + + // 第四行:插入功能 + 'link customupload image media table emoticons charmap insertdatetime hr pagebreak', + + // 第五行:高级功能 + 'codesample accordion blockquote | searchreplace visualblocks code', + + // 第六行:其他工具 + 'removeformat | fullscreen preview help', + ].join(' | '), + + // 设置工具栏模式为换行显示,而不是滑动 + toolbar_mode: 'wrap' as const, // 改为 wrap 模式,全部展开 + + // 工具栏分组,每组之间有分隔符 + toolbar_groups: { + history: { icon: 'undo', tooltip: '历史操作' }, + formatting: { icon: 'bold', tooltip: '文本格式' }, + alignment: { icon: 'align-left', tooltip: '对齐方式' }, + indentation: { icon: 'indent', tooltip: '缩进' }, + insert: { icon: 'plus', tooltip: '插入' }, + tools: { icon: 'preferences', tooltip: '工具' }, + }, + + font_family_formats: [ + '微软雅黑=Microsoft YaHei,Helvetica Neue,PingFang SC,sans-serif', + '苹方=PingFang SC,Microsoft YaHei,sans-serif', + '宋体=SimSun,serif', + '黑体=SimHei,sans-serif', + '楷体=KaiTi,serif', + 'Arial=arial,helvetica,sans-serif', + 'Times New Roman=times new roman,times,serif', + 'Courier New=courier new,courier,monospace', + ].join(';'), + + fontsize_formats: + '8px 9px 10px 11px 12px 14px 16px 18px 20px 22px 24px 26px 28px 36px 48px 72px', + + block_formats: [ + '段落=p', + '标题1=h1', + '标题2=h2', + '标题3=h3', + '标题4=h4', + '标题5=h5', + '标题6=h6', + '预格式化=pre', + '地址=address', + '代码=code', + ].join(';'), + + // 字数统计配置 + wordcount_countregex: /[\w\u2019\u4e00-\u9fa5]+/g, + wordcount_cleanregex: /[0-9.(),;:!?%#$?\x27\x22_+=\\/-]*/g, + + // 颜色配置 + color_map: [ + '000000', + '黑色', + '993300', + '深红色', + '333300', + '深黄色', + '003300', + '深绿色', + '003366', + '深青色', + '000080', + '深蓝色', + '333399', + '蓝色', + '333333', + '深灰色', + '800000', + '栗色', + 'FF6600', + '橙色', + '808000', + '橄榄色', + '008000', + '绿色', + '008080', + '青色', + '0000FF', + '蓝色', + '666699', + '灰蓝色', + '808080', + '灰色', + 'FF0000', + '红色', + 'FF9900', + '琥珀色', + '99CC00', + '黄绿色', + '339966', + '海绿色', + '33CCCC', + '青绿色', + '3366FF', + '蓝色', + '800080', + '紫色', + '999999', + '中灰色', + 'FF00FF', + '洋红色', + 'FFCC00', + '金色', + 'FFFF00', + '黄色', + '00FF00', + '酸橙色', + '00FFFF', + '水蓝色', + '00CCFF', + '天蓝色', + '993366', + '红紫色', + 'FFFFFF', + '白色', + ], + + // 代码高亮配置 + codesample_languages: [ + { text: 'HTML/XML', value: 'markup' }, + { text: 'JavaScript', value: 'javascript' }, + { text: 'TypeScript', value: 'typescript' }, + { text: 'CSS', value: 'css' }, + { text: 'SCSS', value: 'scss' }, + { text: 'Python', value: 'python' }, + { text: 'Java', value: 'java' }, + { text: 'C++', value: 'cpp' }, + { text: 'C#', value: 'csharp' }, + { text: 'PHP', value: 'php' }, + { text: 'Ruby', value: 'ruby' }, + { text: 'Go', value: 'go' }, + { text: 'Rust', value: 'rust' }, + { text: 'SQL', value: 'sql' }, + { text: 'JSON', value: 'json' }, + { text: 'Bash', value: 'bash' }, + ], + + // 表格配置 + table_default_attributes: { + border: '1', + }, + table_default_styles: { + 'border-collapse': 'collapse', + width: '100%', + }, + table_class_list: [ + { title: '无样式', value: '' }, + { title: '简单表格', value: 'simple-table' }, + { title: '条纹表格', value: 'striped-table' }, + { title: '边框表格', value: 'bordered-table' }, + ], + + // 链接配置 + link_default_target: '_blank', + link_assume_external_targets: true, + link_context_toolbar: true, + + // 图片配置 + image_advtab: true, + image_caption: true, + + // 媒体配置 + media_live_embeds: true, + media_filter_html: false, + + content_style: ` + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + font-size: 14px; + line-height: 1.6; + margin: 16px; + color: #333; + } + + h1, h2, h3, h4, h5, h6 { + margin-top: 24px; + margin-bottom: 16px; + font-weight: 600; + line-height: 1.25; + } + + h1 { font-size: 2em; color: #1a1a1a; } + h2 { font-size: 1.5em; color: #2a2a2a; } + h3 { font-size: 1.25em; color: #3a3a3a; } + + p { margin-bottom: 16px; } + + img { + max-width: 100%; + height: auto; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + margin: 8px 0; + } + + table { + border-collapse: collapse; + width: 100%; + margin: 16px 0; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + } + + table td, table th { + border: 1px solid #e8e8e8; + padding: 12px; + text-align: left; + } + + table th { + background-color: #f8f9fa; + font-weight: 600; + color: #495057; + } + + blockquote { + border-left: 4px solid #1890ff; + margin: 16px 0; + padding: 8px 16px; + background-color: #f6f8fa; + color: #666; + font-style: italic; + } + + code { + background-color: #f1f3f4; + padding: 2px 4px; + border-radius: 3px; + font-family: 'Courier New', monospace; + font-size: 0.9em; + color: #d73a49; + } + + pre { + background-color: #f8f8f8; + border: 1px solid #e1e4e8; + border-radius: 6px; + padding: 16px; + overflow-x: auto; + margin: 16px 0; + } + + pre code { + background: none; + padding: 0; + color: inherit; + } + `, + + placeholder, + branding: false, + elementpath: true, + resize: 'both' as const, + statusbar: showWordCount, + + // 图片上传配置 + images_upload_handler: handleImageUpload, + automatic_uploads: true, + images_reuse_filename: true, + images_upload_url: uploadConfig.action, + images_upload_base_path: '', + images_upload_credentials: true, + + // 粘贴配置 + paste_data_images: true, + paste_as_text: false, + paste_webkit_styles: 'none', + paste_merge_formats: true, + paste_remove_styles_if_webkit: true, + + // 文件选择器配置 + file_picker_types: 'image media', + file_picker_callback: ( + callback: (url: string, meta?: any) => void, + // value: string, + meta: any, + ) => { + if (meta.filetype === 'image') { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = uploadConfig.acceptTypes?.join(',') || 'image/*'; + + const handleChange = async (event: Event) => { + const target = event.target as HTMLInputElement; + const file = target.files?.[0]; + + if (!file) return; + + const isValidType = uploadConfig.acceptTypes?.includes(file.type); + const isValidSize = + file.size / 1024 / 1024 < (uploadConfig.maxSize || 5); + + if (!isValidType) { + message.error('文件类型不支持'); + return; + } + + if (!isValidSize) { + message.error(`文件大小不能超过 ${uploadConfig.maxSize || 5}MB`); + return; + } + + try { + setUploading(true); + const url = await uploadFile(file); + callback(url, { alt: file.name }); + message.success('图片上传成功'); + } catch (error) { + message.error('上传失败'); + console.error('Upload error:', error); + } finally { + setUploading(false); + } + }; + + input.addEventListener('change', handleChange); + input.click(); + } + }, + + // 快速工具栏 + quickbars_selection_toolbar: + 'bold italic | quicklink h2 h3 blockquote quickimage quicktable', + quickbars_insert_toolbar: 'quickimage quicktable', + + // 文本模式 + textpattern_patterns: [ + { start: '*', end: '*', format: 'italic' }, + { start: '**', end: '**', format: 'bold' }, + { start: '#', format: 'h1' }, + { start: '##', format: 'h2' }, + { start: '###', format: 'h3' }, + { start: '####', format: 'h4' }, + { start: '#####', format: 'h5' }, + { start: '######', format: 'h6' }, + { start: '1. ', cmd: 'InsertOrderedList' }, + { start: '* ', cmd: 'InsertUnorderedList' }, + { start: '- ', cmd: 'InsertUnorderedList' }, + ], + + // 初始化设置 + setup: (editor: TinyMCEEditor) => { + // 注册自定义上传按钮 + editor.ui.registry.addButton('customupload', { + text: uploading ? '上传中...' : '上传', + icon: 'upload', + tooltip: '上传图片(支持多选)', + enabled: !disabled && !uploading, + onAction: () => { + handleCustomUpload(editor); + }, + }); + + // 监听内容变化,实时更新字数统计 + editor.on('input change undo redo', () => { + if (showWordCount) { + const content = editor.getContent(); + const { words, characters } = countWords(content); + setWordCount(words); + setCharCount(characters); + } + }); + + editor.on('init', () => { + console.log('编辑器初始化完成'); + + // 初始化字数统计 + if (showWordCount && value) { + const { words, characters } = countWords(value); + setWordCount(words); + setCharCount(characters); + } + }); + }, + + // 其他配置 + convert_urls: false, + remove_script_host: false, + relative_urls: false, + language: 'zh_CN', + directionality: 'ltr' as const, + + // 高级配置 + extended_valid_elements: 'script[src|async|defer|type|charset]', + + // 性能配置 + browser_spellcheck: true, + contextmenu: 'link image table', + }; + + return ( +
+ + + {/* 自定义字数统计显示 */} + {showWordCount && ( +
+ + 字数: {wordCount} + {maxWords && ( + maxWords ? 'over-limit' : ''}> + /{maxWords} + + )} + + 字符: {charCount} +
+ )} + + {uploading && ( +
+
+
+ 上传中... +
+
+ )} +
+ ); +}; + +export default RichEditor; diff --git a/src/components/Tinymce/index.less b/src/components/Tinymce/index.less new file mode 100644 index 0000000..ceb8837 --- /dev/null +++ b/src/components/Tinymce/index.less @@ -0,0 +1,145 @@ +/* components/RichEditor/index.css */ +.rich-editor-wrapper { + position: relative; + border: 1px solid #d9d9d9; + border-radius: 6px; + overflow: hidden; + transition: all 0.3s; + background: #fff; +} + +.rich-editor-wrapper:hover { + border-color: #4096ff; +} + +.rich-editor-wrapper:focus-within { + border-color: #4096ff; + box-shadow: 0 0 0 2px rgba(64, 150, 255, 0.2); +} + +.rich-editor-wrapper .tox-tinymce { + border: none !important; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +.rich-editor-wrapper .tox-editor-header { + border-bottom: 1px solid #e8e8e8 !important; + background: #fafafa !important; +} + +.rich-editor-wrapper .tox-toolbar__primary { + background: #fafafa !important; +} + +.rich-editor-wrapper .tox-statusbar { + border-top: 1px solid #e8e8e8 !important; + background: #fafafa !important; +} + +/* 自定义按钮样式 */ +.rich-editor-wrapper .tox-tbtn--enabled { + background: transparent !important; +} + +.rich-editor-wrapper .tox-tbtn--enabled:hover { + background: #e6f7ff !important; + border-color: #91d5ff !important; +} + +.rich-editor-wrapper .tox-tbtn--enabled:active { + background: #bae7ff !important; +} + +/* 上传状态覆盖层 */ +.upload-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(2px); +} + +.upload-spinner { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 20px; + background: #fff; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.spinner { + width: 32px; + height: 32px; + border: 3px solid #f3f3f3; + border-top: 3px solid #1890ff; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.upload-spinner span { + color: #666; + font-size: 14px; +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .rich-editor-wrapper .tox-toolbar__primary { + flex-wrap: wrap; + } + + .rich-editor-wrapper .tox-toolbar__group { + margin: 2px; + } +} + +/* 图片样式优化 */ +.rich-editor-wrapper .tox-edit-area img { + max-width: 100% !important; + height: auto !important; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + margin: 4px 0; + cursor: pointer; + transition: transform 0.2s; +} + +.rich-editor-wrapper .tox-edit-area img:hover { + transform: scale(1.02); +} + +/* 表格样式优化 */ +.rich-editor-wrapper .tox-edit-area table { + border-collapse: collapse; + width: 100%; + margin: 8px 0; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.rich-editor-wrapper .tox-edit-area table td, +.rich-editor-wrapper .tox-edit-area table th { + border: 1px solid #e8e8e8; + padding: 8px 12px; +} + +.rich-editor-wrapper .tox-edit-area table th { + background-color: #fafafa; + font-weight: 600; +} diff --git a/src/components/Tinymce/index.tsx b/src/components/Tinymce/index.tsx new file mode 100644 index 0000000..3cb2c4f --- /dev/null +++ b/src/components/Tinymce/index.tsx @@ -0,0 +1,262 @@ +import { Editor } from '@tinymce/tinymce-react'; +import { message } from 'antd'; +import React, { useCallback, useState } from 'react'; +import type { Editor as TinyMCEEditor } from 'tinymce'; +export interface RichEditorProps { + value?: string; + onChange?: (content: string) => void; + height?: number; + placeholder?: string; + disabled?: boolean; + uploadConfig?: { + action: string; + headers?: Record; + maxSize?: number; + acceptTypes?: string[]; + data?: Record; + }; + showWordCount?: boolean; + maxWords?: number; +} +const RichEditor: React.FC = ({ + // value = "", + // onChange, + // height = 400, + // placeholder = "请输入内容...", + disabled = false, + // showWordCount = true, + // maxWords, + uploadConfig = { + action: '/api/upload/image', + maxSize: 5, + acceptTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], + }, +}) => { + const [uploading, setUploading] = useState(false); + const uploadFile = useCallback( + async (file: File): Promise => { + return new Promise((resolve, reject) => { + const formData = new FormData(); + formData.append('file', file); + + if (uploadConfig.data) { + Object.keys(uploadConfig.data).forEach((key) => { + formData.append(key, uploadConfig.data?.[key]); + }); + } + + const xhr = new XMLHttpRequest(); + + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + const percent = Math.round((e.loaded / e.total) * 100); + console.log(`上传进度: ${percent}%`); + } + }); + + xhr.addEventListener('load', () => { + if (xhr.status === 200) { + try { + const response = JSON.parse(xhr.responseText); + if (response.code === 200 && response.data?.url) { + resolve(response.data.url); + } else { + reject(response.message || '上传失败'); + } + } catch (_error) { + reject('响应解析失败'); + } + } else { + reject(`上传失败: ${xhr.status}`); + } + }); + + xhr.addEventListener('error', () => { + reject('网络错误'); + }); + + if (uploadConfig.headers) { + Object.keys(uploadConfig.headers).forEach((key) => { + xhr.setRequestHeader(key, uploadConfig.headers![key]); + }); + } + + xhr.open('POST', uploadConfig.action); + xhr.send(formData); + }); + }, + [uploadConfig], + ); + const handleImageUpload = useCallback( + (blobInfo: any, _: (percent: number) => void): Promise => { + return new Promise((resolve, reject) => { + try { + setUploading(true); + const file = blobInfo.blob(); + uploadFile(file).then((url) => { + resolve(url); + }); + + message.success('图片上传成功'); + } catch (error) { + reject(error); + message.error(typeof error === 'string' ? error : '上传失败'); + } finally { + setUploading(false); + } + }); + }, + [uploadFile], + ); + + const handleCustomUpload = useCallback( + (editor: TinyMCEEditor) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = uploadConfig.acceptTypes?.join(',') || 'image/*'; + input.multiple = true; + + const handleFileChange = async (event: Event) => { + const target = event.target as HTMLInputElement; + const files = Array.from(target.files || []); + + if (files.length === 0) return; + + setUploading(true); + + try { + for (const file of files) { + const isValidType = uploadConfig.acceptTypes?.includes(file.type); + if (!isValidType) { + message.error(`文件 ${file.name} 类型不支持`); + continue; + } + + const isValidSize = + file.size / 1024 / 1024 < (uploadConfig.maxSize || 5); + if (!isValidSize) { + message.error( + `文件 ${file.name} 大小超过 ${uploadConfig.maxSize || 5}MB`, + ); + continue; + } + + try { + const url = await uploadFile(file); + const imgHtml = `${file.name}`; + editor.insertContent(imgHtml); + message.success(`${file.name} 上传成功`); + } catch (error) { + message.error(`${file.name} 上传失败: ${error}`); + } + } + } finally { + setUploading(false); + } + }; + + input.addEventListener('change', handleFileChange); + input.click(); + }, + [uploadConfig, uploadFile], + ); + + return ( + Promise) => any }, + ) => + respondWith.string(() => + Promise.reject('See docs to implement AI Assistant'), + ), + language: 'zh-CN', + + // 其他配置 + + convert_urls: false, + remove_script_host: false, + uploadcare_public_key: '0ad3671d77f59c5756dd', + + setup: (editor: TinyMCEEditor) => { + // 注册自定义上传按钮 + editor.ui.registry.addButton('customupload', { + text: uploading ? '上传中...' : '上传', + icon: 'upload', + tooltip: '上传图片(支持多选)', + enabled: !disabled && !uploading, + onAction: () => { + handleCustomUpload(editor); + }, + }); + }, + // 图片上传配置 + images_upload_handler: handleImageUpload, + // 性能配置 + browser_spellcheck: true, + contextmenu: 'link image table', + }} + initialValue="Welcome to TinyMCE!" + /> + ); +}; + +export default React.memo(RichEditor); diff --git a/src/pages/prod/category/config.tsx b/src/pages/prod/category/config.tsx index cb243ad..6d47b25 100644 --- a/src/pages/prod/category/config.tsx +++ b/src/pages/prod/category/config.tsx @@ -4,6 +4,7 @@ import type { } from '@ant-design/pro-components'; import dayjs from 'dayjs'; import TagEditor from '@/components/TagEditor'; +import TinyMCEEditor from '@/components/Tinymce'; export const baseTenantColumns: ProColumns[] = [ { title: '类目名称', @@ -86,7 +87,7 @@ export const formColumns = (data: { dataIndex: 'description', valueType: 'textarea', renderFormItem: () => { - return 1; + return ; }, }, { diff --git a/src/pages/prod/category/index.tsx b/src/pages/prod/category/index.tsx index 27372ad..0387c94 100644 --- a/src/pages/prod/category/index.tsx +++ b/src/pages/prod/category/index.tsx @@ -1,9 +1,5 @@ import { PlusOutlined } from '@ant-design/icons'; -import { - type ActionType, - type ProColumns, - ProCoreActionType, -} from '@ant-design/pro-components'; +import type { ActionType, ProColumns } from '@ant-design/pro-components'; import type { TabsProps } from 'antd'; import { Tabs } from 'antd'; import { useCallback, useRef, useState } from 'react'; @@ -97,6 +93,7 @@ const ProdCategory = () => {