index.vue 10 KB

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