index.vue 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. <template>
  2. <div>
  3. <el-upload v-if="type === 'url'" :action="upload.url" :before-upload="handleBeforeUpload" :on-success="handleUploadSuccess" :on-error="handleUploadError" class="editor-img-uploader" name="file" :show-file-list="false" :headers="upload.headers">
  4. <i ref="uploadRef"></i>
  5. </el-upload>
  6. </div>
  7. <div class="editor">
  8. <quill-editor ref="quillEditorRef" v-model:content="content" content-type="html" :options="options" :style="styles" @text-change="(e: any) => $emit('update:modelValue', content)" :disabled="props.readOnly" />
  9. </div>
  10. </template>
  11. <script setup lang="ts">
  12. import '@vueup/vue-quill/dist/vue-quill.snow.css';
  13. import { QuillEditor, Quill } from '@vueup/vue-quill';
  14. import { propTypes } from '@/utils/propTypes';
  15. import { globalHeaders } from '@/utils/request';
  16. defineEmits(['update:modelValue']);
  17. const props = defineProps({
  18. /* 编辑器的内容 */
  19. modelValue: propTypes.string,
  20. /* 高度 */
  21. height: propTypes.number.def(400),
  22. /* 最小高度 */
  23. minHeight: propTypes.number.def(400),
  24. /* 只读 */
  25. readOnly: propTypes.bool.def(false),
  26. /* 上传文件大小限制(MB) */
  27. fileSize: propTypes.number.def(5),
  28. /* 类型(base64格式、url格式) */
  29. type: propTypes.string.def('url')
  30. });
  31. const { proxy } = getCurrentInstance() as ComponentInternalInstance;
  32. const upload = reactive<UploadOption>({
  33. headers: globalHeaders(),
  34. url: import.meta.env.VITE_APP_BASE_API + '/resource/oss/upload'
  35. });
  36. const quillEditorRef = ref();
  37. const uploadRef = ref<HTMLDivElement>();
  38. const options = ref<any>({
  39. theme: 'snow',
  40. bounds: document.body,
  41. debug: 'warn',
  42. modules: {
  43. // 工具栏配置
  44. toolbar: {
  45. container: [
  46. ['bold', 'italic', 'underline', 'strike'], // 加粗 斜体 下划线 删除线
  47. ['blockquote', 'code-block'], // 引用 代码块
  48. [{ list: 'ordered' }, { list: 'bullet' }], // 有序、无序列表
  49. [{ indent: '-1' }, { indent: '+1' }], // 缩进
  50. [{ size: ['small', false, 'large', 'huge'] }], // 字体大小
  51. [{ header: [1, 2, 3, 4, 5, 6, false] }], // 标题
  52. [{ color: [] }, { background: [] }], // 字体颜色、字体背景颜色
  53. [{ align: [] }], // 对齐方式
  54. ['clean'], // 清除文本格式
  55. ['link', 'image', 'video'] // 链接、图片、视频
  56. ],
  57. handlers: {
  58. image: (value: boolean) => {
  59. if (value) {
  60. // 调用element图片上传
  61. uploadRef.value.click();
  62. } else {
  63. Quill.format('image', true);
  64. }
  65. }
  66. }
  67. }
  68. },
  69. placeholder: '请输入内容',
  70. readOnly: props.readOnly
  71. });
  72. const styles = computed(() => {
  73. let style: any = {};
  74. if (props.minHeight) {
  75. style.minHeight = `${props.minHeight}px`;
  76. }
  77. if (props.height) {
  78. style.height = `${props.height}px`;
  79. }
  80. return style;
  81. });
  82. const content = ref('');
  83. watch(
  84. () => props.modelValue,
  85. (v: string) => {
  86. if (v !== content.value) {
  87. content.value = v || '<p></p>';
  88. }
  89. },
  90. { immediate: true }
  91. );
  92. // 图片上传成功返回图片地址
  93. const handleUploadSuccess = (res: any) => {
  94. // 如果上传成功
  95. if (res.code === 200) {
  96. // 获取富文本实例
  97. let quill = toRaw(quillEditorRef.value).getQuill();
  98. // 获取光标位置
  99. let length = quill.selection.savedRange.index;
  100. // 插入图片,res为服务器返回的图片链接地址
  101. quill.insertEmbed(length, 'image', res.data.url);
  102. // 调整光标到最后
  103. quill.setSelection(length + 1);
  104. proxy?.$modal.closeLoading();
  105. } else {
  106. proxy?.$modal.msgError('图片插入失败');
  107. proxy?.$modal.closeLoading();
  108. }
  109. };
  110. // 图片上传前拦截
  111. const handleBeforeUpload = (file: any) => {
  112. const type = ['image/jpeg', 'image/jpg', 'image/png', 'image/svg'];
  113. const isJPG = type.includes(file.type);
  114. //检验文件格式
  115. if (!isJPG) {
  116. proxy?.$modal.msgError(`图片格式错误!`);
  117. return false;
  118. }
  119. // 校检文件大小
  120. if (props.fileSize) {
  121. const isLt = file.size / 1024 / 1024 < props.fileSize;
  122. if (!isLt) {
  123. proxy?.$modal.msgError(`上传文件大小不能超过 ${props.fileSize} MB!`);
  124. return false;
  125. }
  126. }
  127. proxy?.$modal.loading('正在上传文件,请稍候...');
  128. return true;
  129. };
  130. // 图片失败拦截
  131. const handleUploadError = (err: any) => {
  132. proxy?.$modal.msgError('上传文件失败');
  133. };
  134. // 处理粘贴事件
  135. const handlePaste = (event: ClipboardEvent) => {
  136. const clipboardData = event.clipboardData;
  137. if (clipboardData) {
  138. const items = Array.from(clipboardData.items);
  139. for (let i = 0; i < items.length; i++) {
  140. const item = items[i];
  141. if (item.type.startsWith('image/')) {
  142. event.preventDefault();
  143. const file = item.getAsFile();
  144. if (file) {
  145. handleImageUpload(file);
  146. }
  147. }
  148. }
  149. }
  150. };
  151. const handleImageUpload = async (file: File) => {
  152. const formData = new FormData();
  153. formData.append('file', file);
  154. try {
  155. proxy?.$modal.loading('正在上传图片,请稍候...');
  156. const response = await fetch(upload.url, {
  157. method: 'POST',
  158. headers: upload.headers,
  159. body: formData
  160. });
  161. const res = await response.json();
  162. if (res.code === 200) {
  163. const quill = toRaw(quillEditorRef.value).getQuill();
  164. const length = quill.selection.savedRange.index;
  165. quill.insertEmbed(length, 'image', res.data.url);
  166. quill.setSelection(length + 1);
  167. } else {
  168. proxy?.$modal.msgError('图片上传失败');
  169. }
  170. } catch (error) {
  171. proxy?.$modal.msgError('图片上传失败');
  172. } finally {
  173. proxy?.$modal.closeLoading();
  174. }
  175. };
  176. // 监听粘贴事件
  177. onMounted(() => {
  178. const quill = toRaw(quillEditorRef.value).getQuill();
  179. quill.root.addEventListener('paste', handlePaste);
  180. });
  181. onBeforeUnmount(() => {
  182. const quill = toRaw(quillEditorRef.value).getQuill();
  183. quill.root.removeEventListener('paste', handlePaste);
  184. });
  185. </script>
  186. <style>
  187. .editor-img-uploader {
  188. display: none;
  189. }
  190. .editor,
  191. .ql-toolbar {
  192. white-space: pre-wrap !important;
  193. line-height: normal !important;
  194. }
  195. .quill-img {
  196. display: none;
  197. }
  198. .ql-snow .ql-tooltip[data-mode='link']::before {
  199. content: '请输入链接地址:';
  200. }
  201. .ql-snow .ql-tooltip.ql-editing a.ql-action::after {
  202. border-right: 0;
  203. content: '保存';
  204. padding-right: 0;
  205. }
  206. .ql-snow .ql-tooltip[data-mode='video']::before {
  207. content: '请输入视频地址:';
  208. }
  209. .ql-snow .ql-picker.ql-size .ql-picker-label::before,
  210. .ql-snow .ql-picker.ql-size .ql-picker-item::before {
  211. content: '14px';
  212. }
  213. .ql-snow .ql-picker.ql-size .ql-picker-label[data-value='small']::before,
  214. .ql-snow .ql-picker.ql-size .ql-picker-item[data-value='small']::before {
  215. content: '10px';
  216. }
  217. .ql-snow .ql-picker.ql-size .ql-picker-label[data-value='large']::before,
  218. .ql-snow .ql-picker.ql-size .ql-picker-item[data-value='large']::before {
  219. content: '18px';
  220. }
  221. .ql-snow .ql-picker.ql-size .ql-picker-label[data-value='huge']::before,
  222. .ql-snow .ql-picker.ql-size .ql-picker-item[data-value='huge']::before {
  223. content: '32px';
  224. }
  225. .ql-snow .ql-picker.ql-header .ql-picker-label::before,
  226. .ql-snow .ql-picker.ql-header .ql-picker-item::before {
  227. content: '文本';
  228. }
  229. .ql-snow .ql-picker.ql-header .ql-picker-label[data-value='1']::before,
  230. .ql-snow .ql-picker.ql-header .ql-picker-item[data-value='1']::before {
  231. content: '标题1';
  232. }
  233. .ql-snow .ql-picker.ql-header .ql-picker-label[data-value='2']::before,
  234. .ql-snow .ql-picker.ql-header .ql-picker-item[data-value='2']::before {
  235. content: '标题2';
  236. }
  237. .ql-snow .ql-picker.ql-header .ql-picker-label[data-value='3']::before,
  238. .ql-snow .ql-picker.ql-header .ql-picker-item[data-value='3']::before {
  239. content: '标题3';
  240. }
  241. .ql-snow .ql-picker.ql-header .ql-picker-label[data-value='4']::before,
  242. .ql-snow .ql-picker.ql-header .ql-picker-item[data-value='4']::before {
  243. content: '标题4';
  244. }
  245. .ql-snow .ql-picker.ql-header .ql-picker-label[data-value='5']::before,
  246. .ql-snow .ql-picker.ql-header .ql-picker-item[data-value='5']::before {
  247. content: '标题5';
  248. }
  249. .ql-snow .ql-picker.ql-header .ql-picker-label[data-value='6']::before,
  250. .ql-snow .ql-picker.ql-header .ql-picker-item[data-value='6']::before {
  251. content: '标题6';
  252. }
  253. .ql-snow .ql-picker.ql-font .ql-picker-label::before,
  254. .ql-snow .ql-picker.ql-font .ql-picker-item::before {
  255. content: '标准字体';
  256. }
  257. .ql-snow .ql-picker.ql-font .ql-picker-label[data-value='serif']::before,
  258. .ql-snow .ql-picker.ql-font .ql-picker-item[data-value='serif']::before {
  259. content: '衬线字体';
  260. }
  261. .ql-snow .ql-picker.ql-font .ql-picker-label[data-value='monospace']::before,
  262. .ql-snow .ql-picker.ql-font .ql-picker-item[data-value='monospace']::before {
  263. content: '等宽字体';
  264. }
  265. </style>