🎨 优化富文本编辑器,完善全屏功能

This commit is contained in:
2025-12-08 19:27:13 +08:00
parent 2fad6c1700
commit aeea06c1cf
6 changed files with 815 additions and 304 deletions

View File

@@ -76,6 +76,8 @@
"sass": "^1.78.0",
"terser": "^5.31.6",
"unocss": "^66.4.2",
"unplugin-auto-import": "^0.17.8",
"unplugin-vue-components": "^0.27.5",
"vite": "^6.2.3",
"vite-plugin-banner": "^0.8.0",
"vite-plugin-importer": "^0.2.5",

88
src/auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,88 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const createPinia: typeof import('pinia')['createPinia']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const effectScope: typeof import('vue')['effectScope']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useModel: typeof import('vue')['useModel']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

116
src/components.d.ts vendored Normal file
View File

@@ -0,0 +1,116 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
Application: typeof import('./components/application/index.vue')['default']
ArrayCtrl: typeof import('./components/arrayCtrl/arrayCtrl.vue')['default']
BottomInfo: typeof import('./components/bottomInfo/bottomInfo.vue')['default']
Charts: typeof import('./components/charts/index.vue')['default']
ColumnItem: typeof import('./components/columnItem/ColumnItem.vue')['default']
CommandMenu: typeof import('./components/commandMenu/index.vue')['default']
Common: typeof import('./components/upload/common.vue')['default']
Content: typeof import('./components/content/index.vue')['default']
Cropper: typeof import('./components/upload/cropper.vue')['default']
CustomPic: typeof import('./components/customPic/index.vue')['default']
Docx: typeof import('./components/office/docx.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAside: typeof import('element-plus/es')['ElAside']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBacktop: typeof import('element-plus/es')['ElBacktop']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton']
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
ElCard: typeof import('element-plus/es')['ElCard']
ElCarousel: typeof import('element-plus/es')['ElCarousel']
ElCarouselItem: typeof import('element-plus/es')['ElCarouselItem']
ElCascader: typeof import('element-plus/es')['ElCascader']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElImage: typeof import('element-plus/es')['ElImage']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElStatistic: typeof import('element-plus/es')['ElStatistic']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElText: typeof import('element-plus/es')['ElText']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTransfer: typeof import('element-plus/es')['ElTransfer']
ElTree: typeof import('element-plus/es')['ElTree']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
ElUpload: typeof import('element-plus/es')['ElUpload']
ElWatermark: typeof import('element-plus/es')['ElWatermark']
ErrorPreview: typeof import('./components/errorPreview/index.vue')['default']
Excel: typeof import('./components/office/excel.vue')['default']
ExportExcel: typeof import('./components/exportExcel/exportExcel.vue')['default']
ExportTemplate: typeof import('./components/exportExcel/exportTemplate.vue')['default']
Image: typeof import('./components/upload/image.vue')['default']
ImportExcel: typeof import('./components/exportExcel/importExcel.vue')['default']
MyTitle: typeof import('./components/MyTitle/MyTitle.vue')['default']
Office: typeof import('./components/office/index.vue')['default']
Pdf: typeof import('./components/office/pdf.vue')['default']
PmDialog: typeof import('./components/PmDialog/pm-dialog.vue')['default']
QRCode: typeof import('./components/upload/QR-code.vue')['default']
RichEdit: typeof import('./components/richtext/rich-edit.vue')['default']
RichView: typeof import('./components/richtext/rich-view.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SearchForm: typeof import('./components/searchForm/index.vue')['default']
SelectComponent: typeof import('./components/selectImage/selectComponent.vue')['default']
SelectFile: typeof import('./components/selectFile/selectFile.vue')['default']
SelectImage: typeof import('./components/selectImage/selectImage.vue')['default']
SvgIcon: typeof import('./components/svgIcon/svgIcon.vue')['default']
UserChoose: typeof import('./components/userChoose/index.vue')['default']
WarningBar: typeof import('./components/warningBar/warningBar.vue')['default']
}
export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}

View File

@@ -1,5 +1,15 @@
<template>
<div class="border border-solid border-gray-100 h-full">
<div ref="editorWrapper" class="rich-edit-wrapper" :class="{ 'is-fullscreen': isFullscreen }">
<!-- 独立的全屏按钮浮在编辑器右上角 -->
<div class="fullscreen-toggle-btn" @click="toggleFullscreen" :title="isFullscreen ? '退出全屏 (ESC)' : '全屏编辑'">
<svg v-if="!isFullscreen" viewBox="0 0 1024 1024" width="18" height="18">
<path d="M290 236.4l43.9-43.9c4.7-4.7 4.7-12.3 0-17L231.4 73c-4.7-4.7-12.3-4.7-17 0L71 216.4c-4.7 4.7-4.7 12.3 0 17l43.9 43.9c4.7 4.7 12.3 4.7 17 0l100.7-100.7 57.4 57.4c4.7 4.7 12.3 4.7 17 0zM642.1 845.5L734 753.6c4.7-4.7 4.7-12.3 0-17l-43.9-43.9c-4.7-4.7-12.3-4.7-17 0l-100.7 100.7-57.4-57.4c-4.7-4.7-12.3-4.7-17 0l-43.9 43.9c-4.7 4.7-4.7 12.3 0 17L597 939.8c4.7 4.7 12.3 4.7 17 0l43.9-43.9c4.7-4.7 4.7-12.3 0-17l-57.4-57.4 41.6-42zM845.5 381.9L753.6 290c-4.7-4.7-12.3-4.7-17 0l-43.9 43.9c-4.7 4.7-4.7 12.3 0 17l100.7 100.7-57.4 57.4c-4.7 4.7-4.7 12.3 0 17l43.9 43.9c4.7 4.7 12.3 4.7 17 0L939.8 427c4.7-4.7 4.7-12.3 0-17l-43.9-43.9c-4.7-4.7-12.3-4.7-17 0l-57.4 57.4-42-41.6zM381.9 178.5L290 270.4c-4.7 4.7-4.7 12.3 0 17l43.9 43.9c4.7 4.7 12.3 4.7 17 0l100.7-100.7 57.4 57.4c4.7 4.7 12.3 4.7 17 0l43.9-43.9c4.7-4.7 4.7-12.3 0-17L427 84.2c-4.7-4.7-12.3-4.7-17 0l-43.9 43.9c-4.7 4.7-4.7 12.3 0 17l57.4 57.4-41.6 42z" fill="currentColor"/>
</svg>
<svg v-else viewBox="0 0 1024 1024" width="18" height="18">
<path d="M204.8 819.2h204.8v204.8h-204.8v-204.8zM614.4 0h204.8v204.8h-204.8v-204.8zM0 614.4h204.8v204.8h-204.8v-204.8zM819.2 0h204.8v204.8h-204.8v-204.8z" fill="currentColor"/>
</svg>
</div>
<Toolbar
:editor="editorRef"
:default-config="toolbarConfig"
@@ -7,8 +17,8 @@
/>
<Editor
v-model="valueHtml"
class="overflow-y-hidden mt-0.5"
style="min-height: 25rem; height: 400px;"
class="editor-content"
:style="editorStyle"
:default-config="editorConfig"
mode="default"
@onCreated="handleCreated"
@@ -18,352 +28,564 @@
</template>
<script setup>
import '@wangeditor/editor/dist/css/style.css' // 引入 css
import '@wangeditor/editor/dist/css/style.css'
const basePath = import.meta.env.VITE_BASE_API
const basePath = import.meta.env.VITE_BASE_API
import { onBeforeUnmount, ref, shallowRef, watch, computed } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { onBeforeUnmount, ref, shallowRef, watch, computed, onMounted } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { ElMessage } from 'element-plus'
import { getUrl } from '@/utils/image'
import botLogo from '@/assets/bot_logo.png'
import { useUserStore } from '@/pinia/modules/user'
import { ElMessage } from 'element-plus'
import { getUrl } from '@/utils/image'
import botLogo from '@/assets/bot_logo.png'
import { useUserStore } from '@/pinia/modules/user'
const emits = defineEmits(['change', 'update:modelValue'])
const emits = defineEmits(['change', 'update:modelValue'])
const change = (editor) => {
emits('change', editor)
emits('update:modelValue', valueHtml.value)
const change = (editor) => {
emits('change', editor)
emits('update:modelValue', valueHtml.value)
}
const props = defineProps({
modelValue: {
type: String,
default: ''
},
useWatermark: {
type: Boolean,
default: false
}
})
const props = defineProps({
modelValue: {
type: String,
default: ''
const editorRef = shallowRef()
const valueHtml = ref('')
const userStore = useUserStore()
const editorWrapper = ref(null)
const isFullscreen = ref(false)
const originalParent = ref(null)
const originalNextSibling = ref(null)
const editorStyle = computed(() => ({
minHeight: '25rem',
height: isFullscreen.value ? 'calc(100vh - 42px)' : '400px'
}))
const toolbarConfig = {}
// 创建自定义上传函数
const createCustomUpload = () => {
return async (file, insertFn) => {
const shouldUseWatermark = props.useWatermark
// 未开启水印则直接上传原图
if (!shouldUseWatermark) {
const formData = new FormData()
formData.append('file', file)
const resp = await fetch(basePath + '/fileUploadAndDownload/upload?noSave=1', {
method: 'POST',
body: formData,
headers: {
'x-token': userStore.token,
'x-user-id': userStore.userInfo.ID
}
})
const res = await resp.json()
if (res.code === 0) {
const urlPath = getUrl(res.data.file.url)
insertFn(urlPath, res.data.file.name)
} else {
ElMessage.error(res.msg || '上传失败')
}
return
}
try {
const watermarkedBlob = await addBottomWatermark(file, {
stripRatio: 0.18,
background: 'rgba(255,255,255,0.96)',
text: '好运助手',
textColor: '#333',
fontFamily: 'PingFang SC, Microsoft YaHei, Arial',
logo: botLogo
})
const newFile = new File([watermarkedBlob], file.name, { type: watermarkedBlob.type || file.type })
const formData = new FormData()
formData.append('file', newFile)
const resp = await fetch(basePath + '/fileUploadAndDownload/upload?noSave=1', {
method: 'POST',
body: formData,
headers: {
'x-token': userStore.token,
'x-user-id': userStore.userInfo.ID
}
})
const res = await resp.json()
if (res.code === 0) {
const urlPath = getUrl(res.data.file.url)
insertFn(urlPath, res.data.file.name)
} else {
ElMessage.error(res.msg || '上传失败')
}
} catch {
ElMessage.error('处理水印失败')
// 降级:直接走原图上传
const formData = new FormData()
formData.append('file', file)
const resp = await fetch(basePath + '/fileUploadAndDownload/upload?noSave=1', {
method: 'POST',
body: formData,
headers: {
'x-token': userStore.token,
'x-user-id': userStore.userInfo.ID
}
})
const res = await resp.json()
if (res.code === 0) {
const urlPath = getUrl(res.data.file.url)
insertFn(urlPath, res.data.file.name)
} else {
ElMessage.error(res.msg || '上传失败')
}
}
}
}
const editorConfig = computed(() => ({
placeholder: '请输入内容...',
MENU_CONF: {
uploadImage: {
server: basePath + '/fileUploadAndDownload/upload?noSave=1',
fieldName: 'file',
maxFileSize: 1024 * 1024 * 10,
maxNumberOfFiles: 1,
customUpload: createCustomUpload()
},
useWatermark: {
type: Boolean,
default: false
}
})
// 调试:监听 props 变化
watch(() => props.useWatermark, (newVal) => {
console.log('useWatermark prop changed to:', newVal)
}, { immediate: true })
const editorRef = shallowRef()
const valueHtml = ref('')
const userStore = useUserStore()
const toolbarConfig = {}
// 创建自定义上传函数,直接检查当前 props 值
const createCustomUpload = () => {
return async (file, insertFn) => {
// 直接获取当前的 props.useWatermark 值
const shouldUseWatermark = props.useWatermark
console.log('customUpload called, useWatermark:', shouldUseWatermark)
console.log('props.useWatermark:', props.useWatermark)
// 未开启水印则直接上传原图
if (!shouldUseWatermark) {
const formData = new FormData()
formData.append('file', file)
const resp = await fetch(basePath + '/fileUploadAndDownload/upload?noSave=1', {
method: 'POST',
body: formData,
headers: {
'x-token': userStore.token,
'x-user-id': userStore.userInfo.ID
}
})
const res = await resp.json()
uploadVideo: {
server: basePath + '/fileUploadAndDownload/upload?noSave=1',
fieldName: 'file',
maxFileSize: 1024 * 1024 * 100,
maxNumberOfFiles: 1,
customInsert(res, insertFn) {
if (res.code === 0) {
const urlPath = getUrl(res.data.file.url)
insertFn(urlPath, res.data.file.name)
} else {
ElMessage.error(res.msg || '上传失败')
}
return
}
try {
console.log('开始添加水印,文件:', file.name)
const watermarkedBlob = await addBottomWatermark(file, {
stripRatio: 0.18, // 水印条高度占原图高度比例
background: 'rgba(255,255,255,0.96)',
text: '好运助手',
textColor: '#333',
fontFamily: 'PingFang SC, Microsoft YaHei, Arial',
logo: botLogo
})
console.log('水印处理完成,新文件大小:', watermarkedBlob.size)
const newFile = new File([watermarkedBlob], file.name, { type: watermarkedBlob.type || file.type })
const formData = new FormData()
formData.append('file', newFile)
const resp = await fetch(basePath + '/fileUploadAndDownload/upload?noSave=1', {
method: 'POST',
body: formData,
headers: {
'x-token': userStore.token,
'x-user-id': userStore.userInfo.ID
}
})
const res = await resp.json()
if (res.code === 0) {
const urlPath = getUrl(res.data.file.url)
insertFn(urlPath, res.data.file.name)
} else {
ElMessage.error(res.msg || '上传失败')
}
} catch {
ElMessage.error('处理水印失败')
// 降级:直接走原图上传
const formData = new FormData()
formData.append('file', file)
const resp = await fetch(basePath + '/fileUploadAndDownload/upload?noSave=1', { method: 'POST', body: formData })
const res = await resp.json()
if (res.code === 0) {
const urlPath = getUrl(res.data.file.url)
insertFn(urlPath, res.data.file.name)
} else {
ElMessage.error(res.msg || '上传失败')
return
}
ElMessage.error(res.msg)
}
}
}
}))
// 将 editorConfig 改为计算属性,使其响应 props 变化
const editorConfig = computed(() => ({
placeholder: '请输入内容...',
MENU_CONF: {
uploadImage: {
server: basePath + '/fileUploadAndDownload/upload?noSave=1',
fieldName: 'file',
maxFileSize: 1024 * 1024 * 10, // 限制图片大小为10MB
maxNumberOfFiles: 1,
customUpload: createCustomUpload(),
customInsert(res, insertFn) {
if (res.code === 0) {
const urlPath = getUrl(res.data.file.url)
insertFn(urlPath, res.data.file.name)
return
}
ElMessage.error(res.msg)
}
},
uploadVideo: {
server: basePath + '/fileUploadAndDownload/upload?noSave=1',
fieldName: 'file',
maxFileSize: 1024 * 1024 * 100, // 限制视频大小为100MB
maxNumberOfFiles: 1,
customInsert(res, insertFn) {
if (res.code === 0) {
const urlPath = getUrl(res.data.file.url)
insertFn(urlPath, res.data.file.name)
return
}
ElMessage.error(res.msg)
}
}
// 组件销毁时,也及时销毁编辑器
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor == null) return
// 如果是全屏状态,先退出全屏
if (isFullscreen.value) {
exitFullscreen()
}
// 移除键盘监听
document.removeEventListener('keydown', handleEscKey)
editor.destroy()
})
const handleCreated = (editor) => {
editorRef.value = editor
valueHtml.value = props.modelValue
// 动态更新上传配置
if (editor && editor.getConfig) {
const config = editor.getConfig()
if (config.MENU_CONF && config.MENU_CONF.uploadImage) {
config.MENU_CONF.uploadImage.customUpload = createCustomUpload()
}
}))
}
// 组件销毁时,也及时销毁编辑器
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor == null) return
editor.destroy()
})
// 修复点击区域问题
setTimeout(() => {
const editorContainer = editor.getEditableContainer()
if (editorContainer) {
editorContainer.style.cursor = 'text'
editorContainer.style.minHeight = '300px'
const handleCreated = (editor) => {
editorRef.value = editor
editorContainer.addEventListener('click', (e) => {
if (e.target === editorContainer) {
editor.focus()
}
})
}
}, 100)
}
// 监听 ESC 键退出全屏
function handleEscKey(e) {
if (e.key === 'Escape' && isFullscreen.value) {
exitFullscreen()
}
}
onMounted(() => {
// 监听 ESC 键
document.addEventListener('keydown', handleEscKey)
})
// 切换全屏
function toggleFullscreen() {
if (isFullscreen.value) {
exitFullscreen()
} else {
enterFullscreen()
}
}
// 进入全屏
function enterFullscreen() {
if (!editorWrapper.value) return
// 保存原始位置
originalParent.value = editorWrapper.value.parentElement
originalNextSibling.value = editorWrapper.value.nextElementSibling
isFullscreen.value = true
// 添加全屏样式类
document.body.classList.add('rich-editor-fullscreen')
// 移动到 body 下(避免受父元素样式影响)
document.body.appendChild(editorWrapper.value)
}
// 退出全屏
function exitFullscreen() {
if (!editorWrapper.value || !originalParent.value) return
isFullscreen.value = false
// 移除全屏样式类
document.body.classList.remove('rich-editor-fullscreen')
// 恢复到原始位置
if (originalNextSibling.value) {
originalParent.value.insertBefore(editorWrapper.value, originalNextSibling.value)
} else {
originalParent.value.appendChild(editorWrapper.value)
}
// 清空保存的引用
originalParent.value = null
originalNextSibling.value = null
}
watch(
() => props.modelValue,
() => {
valueHtml.value = props.modelValue
}
)
// 动态更新上传配置
if (editor && editor.getConfig) {
const config = editor.getConfig()
// 监听 useWatermark 变化,动态更新编辑器配置
watch(
() => props.useWatermark,
() => {
if (editorRef.value && editorRef.value.getConfig) {
const config = editorRef.value.getConfig()
if (config.MENU_CONF && config.MENU_CONF.uploadImage) {
config.MENU_CONF.uploadImage.customUpload = createCustomUpload()
}
}
}
)
// 修复点击区域问题
setTimeout(() => {
const editorContainer = editor.getEditableContainer()
if (editorContainer) {
// 确保整个编辑器区域都可以点击
editorContainer.style.cursor = 'text'
editorContainer.style.minHeight = '300px'
// 水印相关函数
async function addBottomWatermark(file, options) {
const { stripRatio = 0.18, background = 'rgba(255,255,255,0.96)', text = '', textColor = '#333', fontFamily = 'Arial', logo } = options || {}
// 添加点击事件监听器
editorContainer.addEventListener('click', (e) => {
if (e.target === editorContainer) {
// 如果点击的是容器本身,聚焦到编辑器
editor.focus()
}
})
}
}, 100)
const img = await fileToImage(file)
const width = img.naturalWidth || img.width
const height = img.naturalHeight || img.height
const stripHeight = Math.max(60, Math.floor(height * stripRatio))
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height + stripHeight
const ctx = canvas.getContext('2d')
// 原图
ctx.drawImage(img, 0, 0, width, height)
// 底部水印条背景
ctx.fillStyle = background
ctx.fillRect(0, height, width, stripHeight)
// 左侧 Logo
let logoSize = Math.floor(stripHeight * 0.6)
let logoPadding = Math.floor(stripHeight * 0.2)
if (logo) {
try {
const logoImg = await srcToImage(logo)
const ratio = logoImg.width / logoImg.height
const drawW = logoSize
const drawH = Math.floor(drawW / ratio)
const y = height + Math.floor((stripHeight - drawH) / 2)
ctx.drawImage(logoImg, logoPadding, y, drawW, drawH)
} catch { void 0 }
}
watch(
() => props.modelValue,
() => {
valueHtml.value = props.modelValue
}
)
// 右侧文字
ctx.fillStyle = textColor
const fontSize = Math.floor(stripHeight * 0.38)
ctx.font = `${fontSize}px ${fontFamily}`
ctx.textBaseline = 'middle'
ctx.textAlign = 'right'
const textPadding = Math.floor(stripHeight * 0.25)
ctx.fillText(text, width - textPadding, height + Math.floor(stripHeight / 2))
// 监听 useWatermark 变化,动态更新编辑器配置
watch(
() => props.useWatermark,
() => {
if (editorRef.value && editorRef.value.getConfig) {
const config = editorRef.value.getConfig()
if (config.MENU_CONF && config.MENU_CONF.uploadImage) {
config.MENU_CONF.uploadImage.customUpload = createCustomUpload()
}
}
}
)
const blob = await canvasToBlob(canvas, file.type || 'image/png')
return blob
}
async function addBottomWatermark(file, options) {
console.log('addBottomWatermark 开始处理,文件:', file.name, '大小:', file.size)
const { stripRatio = 0.18, background = 'rgba(255,255,255,0.96)', text = '', textColor = '#333', fontFamily = 'Arial', logo } = options || {}
const img = await fileToImage(file)
const width = img.naturalWidth || img.width
const height = img.naturalHeight || img.height
const stripHeight = Math.max(60, Math.floor(height * stripRatio))
console.log('图片尺寸:', width, 'x', height, '水印条高度:', stripHeight)
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height + stripHeight
const ctx = canvas.getContext('2d')
// 原图
ctx.drawImage(img, 0, 0, width, height)
// 底部水印条背景
ctx.fillStyle = background
ctx.fillRect(0, height, width, stripHeight)
// 左侧 Logo可选
let logoSize = Math.floor(stripHeight * 0.6)
let logoPadding = Math.floor(stripHeight * 0.2)
if (logo) {
try {
const logoImg = await srcToImage(logo)
const ratio = logoImg.width / logoImg.height
const drawW = logoSize
const drawH = Math.floor(drawW / ratio)
const y = height + Math.floor((stripHeight - drawH) / 2)
ctx.drawImage(logoImg, logoPadding, y, drawW, drawH)
} catch { void 0 }
}
// 右侧文字
ctx.fillStyle = textColor
const fontSize = Math.floor(stripHeight * 0.38)
ctx.font = `${fontSize}px ${fontFamily}`
ctx.textBaseline = 'middle'
ctx.textAlign = 'right'
const textPadding = Math.floor(stripHeight * 0.25)
ctx.fillText(text, width - textPadding, height + Math.floor(stripHeight / 2))
const blob = await canvasToBlob(canvas, file.type || 'image/png')
return blob
}
function fileToImage(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const img = new Image()
img.onload = () => {
console.log('图片加载成功,尺寸:', img.naturalWidth, 'x', img.naturalHeight)
resolve(img)
}
img.onerror = (error) => {
console.error('图片加载失败:', error)
reject(error)
}
img.src = reader.result
}
reader.onerror = (error) => {
console.error('文件读取失败:', error)
reject(error)
}
reader.readAsDataURL(file)
})
}
function srcToImage(src) {
return new Promise((resolve, reject) => {
function fileToImage(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
console.log('Logo 加载成功,尺寸:', img.naturalWidth, 'x', img.naturalHeight)
resolve(img)
}
img.onerror = (error) => {
console.error('Logo 加载失败:', error, 'src:', src)
reject(error)
}
img.src = src
})
}
img.onload = () => resolve(img)
img.onerror = reject
img.src = reader.result
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}
function canvasToBlob(canvas, mime) {
return new Promise((resolve) => {
if (canvas.toBlob) {
canvas.toBlob((blob) => {
console.log('Canvas 转 Blob 成功,大小:', blob.size, '类型:', blob.type)
resolve(blob)
}, mime, 0.92)
} else {
// 兼容处理
const dataURL = canvas.toDataURL(mime)
const arr = dataURL.split(',')
const bstr = atob(arr[1])
let n = bstr.length
const u8arr = new Uint8Array(n)
while (n--) u8arr[n] = bstr.charCodeAt(n)
const blob = new Blob([u8arr], { type: mime })
console.log('Canvas 转 Blob (兼容模式) 成功,大小:', blob.size, '类型:', blob.type)
resolve(blob)
}
})
}
function srcToImage(src) {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => resolve(img)
img.onerror = reject
img.src = src
})
}
function canvasToBlob(canvas, mime) {
return new Promise((resolve) => {
if (canvas.toBlob) {
canvas.toBlob((blob) => resolve(blob), mime, 0.92)
} else {
const dataURL = canvas.toDataURL(mime)
const arr = dataURL.split(',')
const bstr = atob(arr[1])
let n = bstr.length
const u8arr = new Uint8Array(n)
while (n--) u8arr[n] = bstr.charCodeAt(n)
const blob = new Blob([u8arr], { type: mime })
resolve(blob)
}
})
}
</script>
<style scoped lang="scss">
// 确保富文本编辑器的点击区域
.rich-edit-wrapper {
border: 1px solid #e8e8e8;
border-radius: 4px;
background: #fff;
position: relative;
&.is-fullscreen {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
z-index: 99999 !important;
margin: 0 !important;
padding: 0 !important;
border: none !important;
border-radius: 0 !important;
display: flex !important;
flex-direction: column !important;
.editor-content {
flex: 1 !important;
overflow-y: auto !important;
}
.fullscreen-toggle-btn {
top: 10px;
right: 20px;
background: rgba(0, 0, 0, 0.7);
color: white;
&:hover {
background: rgba(0, 0, 0, 0.85);
}
}
}
}
.editor-content {
overflow-y: auto;
overflow-x: hidden;
}
.fullscreen-toggle-btn {
position: absolute;
top: 5px;
right: 10px;
z-index: 10;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: #f1f1f1;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
color: #666;
&:hover {
background: #e0e0e0;
color: #333;
}
svg {
display: block;
}
}
// 编辑器内容区域滚动条样式
:deep(.w-e-text-container) {
min-height: 300px !important;
cursor: text !important;
overflow-y: auto !important;
overflow-x: hidden !important;
// 自定义滚动条样式
&::-webkit-scrollbar {
display: block !important;
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
transition: background 0.3s;
&:hover {
background: #a8a8a8;
}
}
// Firefox 滚动条样式
scrollbar-width: thin;
scrollbar-color: #c1c1c1 #f1f1f1;
}
:deep(.w-e-scroll) {
min-height: 300px !important;
cursor: text !important;
overflow-y: auto !important;
overflow-x: hidden !important;
// 自定义滚动条样式
&::-webkit-scrollbar {
display: block !important;
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
transition: background 0.3s;
&:hover {
background: #a8a8a8;
}
}
// Firefox 滚动条样式
scrollbar-width: thin;
scrollbar-color: #c1c1c1 #f1f1f1;
}
:deep(.w-e-text-placeholder) {
pointer-events: none;
}
// 确保整个编辑器区域都可以点击
:deep(.w-e-text-container .w-e-scroll) {
cursor: text !important;
}
// 修复编辑器内容区域的点击问题
:deep(.w-e-text-container .w-e-scroll .w-e-text) {
min-height: 300px !important;
cursor: text !important;
}
// 编辑器外层容器滚动条样式
.editor-content {
&::-webkit-scrollbar {
display: block !important;
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
transition: background 0.3s;
&:hover {
background: #a8a8a8;
}
}
// Firefox 滚动条样式
scrollbar-width: thin;
scrollbar-color: #c1c1c1 #f1f1f1;
}
</style>
<style lang="scss">
// 全局样式:全屏模式下隐藏其他元素
body.rich-editor-fullscreen {
overflow: hidden !important;
// 隐藏主要布局容器
> #app {
display: none !important;
}
// 显示全屏编辑器
> .rich-edit-wrapper.is-fullscreen {
display: flex !important;
}
}
</style>

View File

@@ -62,6 +62,7 @@
"/src/view/notice/notice/noticeForm.vue": "NoticeForm",
"/src/view/order/index.vue": "OrderManage",
"/src/view/order/order/index.vue": "Index",
"/src/view/payConfig/index.vue": "PayConfig",
"/src/view/person/person.vue": "Person",
"/src/view/routerHolder.vue": "RouterHolder",
"/src/view/superAdmin/api/api.vue": "Api",
@@ -78,6 +79,7 @@
"/src/view/superAdmin/operation/sysOperationRecord.vue": "SysOperationRecord",
"/src/view/superAdmin/params/sysParams.vue": "SysParams",
"/src/view/superAdmin/user/user.vue": "User",
"/src/view/system/ipConfig.vue": "IpConfig",
"/src/view/system/state.vue": "State",
"/src/view/systemTools/autoCode/component/fieldDialog.vue": "FieldDialog",
"/src/view/systemTools/autoCode/component/previewCodeDialog.vue": "PreviewCodeDialog",

View File

@@ -5,7 +5,7 @@
:model="formData"
:rules="rules"
label-width="auto"
style="margin-top: 0.5rem; "
style="margin-top: 0.5rem;"
>
<ColumnItem title="文章编辑">
<el-row :gutter="20">
@@ -77,6 +77,33 @@
</el-col>
</el-row>
<!-- 定时发布选项 -->
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="定时发布">
<el-switch
v-model="formData.isScheduled"
active-text="启用"
inactive-text="禁用"
@change="handleScheduledChange"
/>
</el-form-item>
</el-col>
<el-col :span="12" v-if="formData.isScheduled">
<el-form-item label="发布时间" prop="publishTime">
<el-date-picker
v-model="formData.publishTime"
type="datetime"
placeholder="选择发布时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
:disabled-date="disabledDate"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
</ColumnItem>
</el-form>
<div class="gva-table-box footer-box">
@@ -101,7 +128,7 @@
import ColumnItem from '@/components/columnItem/ColumnItem.vue'
import { ElMessage } from 'element-plus'
import RichEdit from '@/components/richtext/rich-edit.vue'
const router = useRouter()
const route = useRoute()
const userDialogRef = ref()
@@ -110,7 +137,9 @@
const ruleFormRef = ref(null), formData = ref({
isFree: 0, // 默认付费
price: 0
price: 0,
isScheduled: false, // 定时发布开关
publishTime: '' // 发布时间
}), rules = ref({
title: [
{ required: true, message: '请输入文章标题', trigger: 'blur' }
@@ -125,9 +154,9 @@
{ required: true, message: '请选择是否免费', trigger: 'change' }
],
price: [
{
required: true,
message: '请输入价格',
{
required: true,
message: '请输入价格',
trigger: 'blur',
validator: (rule, value, callback) => {
if (formData.value.isFree === 0 && (!value || value <= 0)) {
@@ -147,6 +176,20 @@
],
content: [
{ required: true, message: '请输入文章内容', trigger: 'blur' }
],
publishTime: [
{
required: true,
message: '请选择发布时间',
trigger: 'change',
validator: (rule, value, callback) => {
if (formData.value.isScheduled && (!value || value === '')) {
callback(new Error('启用定时发布时,请选择发布时间'))
} else {
callback()
}
}
}
]
})
@@ -188,22 +231,52 @@
if (formData.value.isFree === undefined || formData.value.isFree === null) {
formData.value.isFree = 0
}
// 处理定时发布字段
if (formData.value.publishTime) {
formData.value.isScheduled = true
} else {
formData.value.isScheduled = false
formData.value.publishTime = ''
}
}
}
function getStaffInfo(data) {
console.log(formData)
formData.value.teacherId = data.id
formData.value.teacherName = data.nick_name
}
// 处理定时发布开关变化
function handleScheduledChange(value) {
if (!value) {
// 关闭定时发布时,清空发布时间
formData.value.publishTime = ''
} else {
// 开启定时发布时,设置默认发布时间为当前时间
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
const hours = String(now.getHours()).padStart(2, '0')
const minutes = String(now.getMinutes()).padStart(2, '0')
const seconds = String(now.getSeconds()).padStart(2, '0')
formData.value.publishTime = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
}
// 禁用过去的日期
function disabledDate(time) {
return time.getTime() < Date.now() - 8.64e7 // 禁用今天之前的日期
}
function submit(formRef) {
if(!formRef) return
// 校验
formRef.validate(async (valid) => {
if(!valid) return
let fn = isEdit.value ? edit : add
// 处理价格字段
if (formData.value.isFree === 1) {
// 免费文章价格设为0
@@ -214,8 +287,16 @@
formData.value.price = Math.round(Number(formData.value.price) * 100)
}
}
const res = await fn(formData.value)
// 处理定时发布参数
const submitData = { ...formData.value }
if (!submitData.isScheduled) {
// 如果未启用定时发布则不传递publishTime参数
delete submitData.publishTime
}
delete submitData.isScheduled // 移除前端使用的开关字段
const res = await fn(submitData)
console.log(res)
if(res.code === 0) {
ElMessage({