| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408 |
- <template>
- <div>
- <el-upload
- :http-request="customUpload"
- :before-upload="handleBeforeUpload"
- :on-error="handleUploadError"
- name="file"
- :show-file-list="false"
- class="editor-img-uploader"
- v-if="type == 'url'"
- >
- <i ref="uploadRef" class="editor-img-uploader"></i>
- </el-upload>
- </div>
- <div class="editor">
- <quill-editor
- ref="quillEditorRef"
- v-model:content="content"
- contentType="html"
- @textChange="(e) => $emit('update:modelValue', content)"
- :options="options"
- :style="styles"
- />
- </div>
- </template>
- <script setup>
- import axios from 'axios'
- import { QuillEditor } from "@vueup/vue-quill"
- import "@vueup/vue-quill/dist/vue-quill.snow.css"
- import { getToken } from "@/utils/auth"
- import request from "@/utils/request"
- const { proxy } = getCurrentInstance()
- const quillEditorRef = ref()
- const uploadUrl = ref(import.meta.env.VITE_APP_BASE_API + "/common/upload") // 保留作为备用
- const headers = ref({
- Authorization: "Bearer " + getToken()
- })
- const props = defineProps({
- /* 编辑器的内容 */
- modelValue: {
- type: String,
- },
- /* 高度 */
- height: {
- type: Number,
- default: null,
- },
- /* 最小高度 */
- minHeight: {
- type: Number,
- default: null,
- },
- /* 只读 */
- readOnly: {
- type: Boolean,
- default: false,
- },
- /* 上传文件大小限制(MB) */
- fileSize: {
- type: Number,
- default: 5,
- },
- /* 类型(base64格式、url格式) */
- type: {
- type: String,
- default: "url",
- }
- })
- const options = ref({
- theme: "snow",
- bounds: document.body,
- debug: "warn",
- modules: {
- // 工具栏配置
- toolbar: [
- ["bold", "italic", "underline", "strike"], // 加粗 斜体 下划线 删除线
- ["blockquote", "code-block"], // 引用 代码块
- [{ list: "ordered" }, { list: "bullet" }], // 有序、无序列表
- [{ indent: "-1" }, { indent: "+1" }], // 缩进
- [{ size: ["small", false, "large", "huge"] }], // 字体大小
- [{ header: [1, 2, 3, 4, 5, 6, false] }], // 标题
- [{ color: [] }, { background: [] }], // 字体颜色、字体背景颜色
- [{ align: [] }], // 对齐方式
- ["clean"], // 清除文本格式
- ["link", "image", "video"] // 链接、图片、视频
- ],
- },
- placeholder: "请输入内容",
- readOnly: props.readOnly
- })
- const styles = computed(() => {
- let style = {}
- if (props.minHeight) {
- style.minHeight = `${props.minHeight}px`
- }
- if (props.height) {
- style.height = `${props.height}px`
- }
- return style
- })
- const content = ref("")
- watch(() => props.modelValue, (v) => {
- if (v !== content.value) {
- content.value = v == undefined ? "<p></p>" : v
- // 内容更新后,为所有图片添加跨域和防盗链属性
- nextTick(() => {
- if (quillEditorRef.value && props.type == 'url') {
- const quill = quillEditorRef.value.getQuill()
- addImageAttributes(quill.root)
- }
- })
- }
- }, { immediate: true })
- // 如果设置了上传地址则自定义图片上传事件
- onMounted(() => {
- if (props.type == 'url') {
- let quill = quillEditorRef.value.getQuill()
- let toolbar = quill.getModule("toolbar")
- toolbar.addHandler("image", (value) => {
- if (value) {
- proxy.$refs.uploadRef.click()
- } else {
- quill.format("image", false)
- }
- })
- quill.root.addEventListener('paste', handlePasteCapture, true)
-
- // 监听编辑器内容变化,为所有图片添加跨域和防盗链属性
- quill.on('text-change', () => {
- addImageAttributes(quill.root)
- })
-
- // 初始化时也为已有图片添加属性
- nextTick(() => {
- addImageAttributes(quill.root)
- })
- }
- })
- // 为编辑器中的所有图片添加跨域和防盗链属性
- function addImageAttributes(rootElement) {
- const images = rootElement.querySelectorAll('img')
- images.forEach(img => {
- // 如果图片还没有这些属性,则添加
- if (!img.hasAttribute('crossorigin')) {
- img.setAttribute('crossorigin', 'anonymous')
- }
- if (!img.hasAttribute('referrerpolicy')) {
- img.setAttribute('referrerpolicy', 'no-referrer')
- }
- // 确保图片样式正常
- if (!img.style.maxWidth) {
- img.style.maxWidth = '100%'
- img.style.height = 'auto'
- img.style.display = 'block'
- img.style.margin = '10px 0'
- }
- })
- }
- // 上传前校检格式和大小
- function handleBeforeUpload(file) {
- const type = ["image/jpeg", "image/jpg", "image/png", "image/svg"]
- const isJPG = type.includes(file.type)
- //检验文件格式
- if (!isJPG) {
- proxy.$modal.msgError(`图片格式错误!`)
- return false
- }
- // 校检文件大小
- if (props.fileSize) {
- const isLt = file.size / 1024 / 1024 < props.fileSize
- if (!isLt) {
- proxy.$modal.msgError(`上传文件大小不能超过 ${props.fileSize} MB!`)
- return false
- }
- }
- return true
- }
- // 自定义上传方法 - 通过后端获取签名后上传到OSS
- async function customUpload(options) {
- const file = options.file
-
- try {
- // 1. 从后端获取OSS签名
- const signatureResponse = await request({
- url: '/common/oss/signature',
- method: 'get',
- params: {
- dir: 'questions/images/'
- }
- })
-
- if (signatureResponse.code !== 200 || !signatureResponse.data) {
- throw new Error(signatureResponse.msg || '获取OSS签名失败')
- }
-
- const signature = signatureResponse.data
-
- // 2. 生成文件路径
- const filePath = generateFilePath(file, signature.dir)
-
- // 3. 构建FormData,使用PostObject方式上传
- const formData = new FormData()
- formData.append('key', filePath)
- formData.append('policy', signature.policy)
- formData.append('OSSAccessKeyId', signature.accessKeyId)
- formData.append('signature', signature.signature)
- formData.append('file', file)
-
- // 4. 上传到OSS
- const response = await fetch(signature.host, {
- method: 'POST',
- body: formData
- })
-
- if (response.ok || response.status === 204) {
- // 上传成功,返回OSS URL
- const ossUrl = `${signature.host}/${filePath}`
- handleUploadSuccess({
- code: 200,
- fileName: filePath,
- url: ossUrl
- }, file)
- } else {
- const errorText = await response.text()
- throw new Error(`上传失败: ${response.status} ${errorText}`)
- }
- } catch (error) {
- console.error('OSS上传失败:', error)
- proxy.$modal.msgError('上传图片失败: ' + (error.message || '未知错误'))
- }
- }
- // 生成文件路径
- function generateFilePath(file, dir) {
- const date = new Date()
- const year = date.getFullYear()
- const month = String(date.getMonth() + 1).padStart(2, '0')
- const day = String(date.getDate()).padStart(2, '0')
- const timestamp = Date.now()
- const random = Math.random().toString(36).substring(2, 15)
- const ext = file.name.substring(file.name.lastIndexOf('.'))
- return `${dir}${year}/${month}/${day}/${timestamp}_${random}${ext}`
- }
- // 上传成功处理
- function handleUploadSuccess(res, file) {
- // 如果上传成功
- if (res.code == 200) {
- // 获取富文本实例
- let quill = toRaw(quillEditorRef.value).getQuill()
- // 获取光标位置
- let length = quill.selection.savedRange.index
- // 插入图片,使用OSS的完整URL
- const imageUrl = res.url || (import.meta.env.VITE_APP_BASE_API + res.fileName)
- quill.insertEmbed(length, "image", imageUrl)
-
- // 等待图片插入后,为其添加跨域和防盗链属性
- nextTick(() => {
- const images = quill.root.querySelectorAll('img')
- images.forEach(img => {
- if (img.src === imageUrl || img.src.includes(imageUrl)) {
- img.setAttribute('crossorigin', 'anonymous')
- img.setAttribute('referrerpolicy', 'no-referrer')
- img.style.maxWidth = '100%'
- img.style.height = 'auto'
- img.style.display = 'block'
- img.style.margin = '10px 0'
- }
- })
- })
-
- // 调整光标到最后
- quill.setSelection(length + 1)
- } else {
- proxy.$modal.msgError("图片插入失败")
- }
- }
- // 上传失败处理
- function handleUploadError() {
- proxy.$modal.msgError("图片插入失败")
- }
- // 复制粘贴图片处理
- function handlePasteCapture(e) {
- const clipboard = e.clipboardData || window.clipboardData
- if (clipboard && clipboard.items) {
- for (let i = 0; i < clipboard.items.length; i++) {
- const item = clipboard.items[i]
- if (item.type.indexOf('image') !== -1) {
- e.preventDefault()
- const file = item.getAsFile()
- insertImage(file)
- }
- }
- }
- }
- async function insertImage(file) {
- try {
- // 使用OSS上传
- await customUpload({ file })
- } catch (error) {
- console.error('粘贴图片上传失败:', error)
- proxy.$modal.msgError('上传图片失败')
- }
- }
- </script>
- <style>
- .editor-img-uploader {
- display: none;
- }
- .editor, .ql-toolbar {
- white-space: pre-wrap !important;
- line-height: normal !important;
- }
- /* 确保富文本编辑器中的图片正常显示 */
- .editor :deep(img) {
- max-width: 100% !important;
- height: auto !important;
- display: block !important;
- margin: 10px 0 !important;
- border-radius: 4px;
- }
- .quill-img {
- display: none;
- }
- .ql-snow .ql-tooltip[data-mode="link"]::before {
- content: "请输入链接地址:";
- }
- .ql-snow .ql-tooltip.ql-editing a.ql-action::after {
- border-right: 0px;
- content: "保存";
- padding-right: 0px;
- }
- .ql-snow .ql-tooltip[data-mode="video"]::before {
- content: "请输入视频地址:";
- }
- .ql-snow .ql-picker.ql-size .ql-picker-label::before,
- .ql-snow .ql-picker.ql-size .ql-picker-item::before {
- content: "14px";
- }
- .ql-snow .ql-picker.ql-size .ql-picker-label[data-value="small"]::before,
- .ql-snow .ql-picker.ql-size .ql-picker-item[data-value="small"]::before {
- content: "10px";
- }
- .ql-snow .ql-picker.ql-size .ql-picker-label[data-value="large"]::before,
- .ql-snow .ql-picker.ql-size .ql-picker-item[data-value="large"]::before {
- content: "18px";
- }
- .ql-snow .ql-picker.ql-size .ql-picker-label[data-value="huge"]::before,
- .ql-snow .ql-picker.ql-size .ql-picker-item[data-value="huge"]::before {
- content: "32px";
- }
- .ql-snow .ql-picker.ql-header .ql-picker-label::before,
- .ql-snow .ql-picker.ql-header .ql-picker-item::before {
- content: "文本";
- }
- .ql-snow .ql-picker.ql-header .ql-picker-label[data-value="1"]::before,
- .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]::before {
- content: "标题1";
- }
- .ql-snow .ql-picker.ql-header .ql-picker-label[data-value="2"]::before,
- .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]::before {
- content: "标题2";
- }
- .ql-snow .ql-picker.ql-header .ql-picker-label[data-value="3"]::before,
- .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]::before {
- content: "标题3";
- }
- .ql-snow .ql-picker.ql-header .ql-picker-label[data-value="4"]::before,
- .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]::before {
- content: "标题4";
- }
- .ql-snow .ql-picker.ql-header .ql-picker-label[data-value="5"]::before,
- .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]::before {
- content: "标题5";
- }
- .ql-snow .ql-picker.ql-header .ql-picker-label[data-value="6"]::before,
- .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]::before {
- content: "标题6";
- }
- .ql-snow .ql-picker.ql-font .ql-picker-label::before,
- .ql-snow .ql-picker.ql-font .ql-picker-item::before {
- content: "标准字体";
- }
- .ql-snow .ql-picker.ql-font .ql-picker-label[data-value="serif"]::before,
- .ql-snow .ql-picker.ql-font .ql-picker-item[data-value="serif"]::before {
- content: "衬线字体";
- }
- .ql-snow .ql-picker.ql-font .ql-picker-label[data-value="monospace"]::before,
- .ql-snow .ql-picker.ql-font .ql-picker-item[data-value="monospace"]::before {
- content: "等宽字体";
- }
- </style>
|