2025-08-18 22:09:42 +08:00

481 lines
10 KiB
Vue

<template>
<div class="quill-editor-container">
<div ref="editorContainer" class="editor-content"></div>
<!-- 文件列表区域 -->
<div class="file-list-section" v-if="uploadedFiles.length > 0">
<div class="file-list">
<div v-for="(file, index) in uploadedFiles" :key="index" class="file-item">
<div class="file-icon">
<img src="/images/profile/file.png" alt="文件图标" />
</div>
<div class="file-info">
<div class="file-name">{{ file.name }}</div>
<div class="file-size">{{ file.size }}</div>
</div>
<button class="file-delete" @click="removeFile(index)" title="删除文件">
<img src="/images/profile/del-black.png" alt="删除" />
</button>
</div>
</div>
</div>
<div v-if="showColorPicker" class="color-picker-popup" @click.stop>
<div class="color-grid">
<div v-for="color in colors" :key="color" class="color-item" :style="{ backgroundColor: color }" @click="selectColor(color)"></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue'
import Quill from 'quill'
import 'quill/dist/quill.snow.css'
interface Props {
modelValue?: string
placeholder?: string
height?: string
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
placeholder: '请输入内容...',
height: '500px'
})
const emit = defineEmits<{
'update:modelValue': [value: string]
'change': [value: string]
}>()
const editorContainer = ref<HTMLElement>()
const quill = ref<Quill>()
// const fontSize = ref(14)
const isBold = ref(false)
const isList = ref(false)
const showColorPicker = ref(false)
const uploadedFiles = ref<Array<{name: string, size: string}>>([])
const colors = [
'#000000', '#FF0000', '#00FF00', '#0000FF', '#FFFF00',
'#FF00FF', '#00FFFF', '#FFA500', '#800080', '#008000',
'#FFC0CB', '#A52A2A', '#808080', '#C0C0C0', '#FFFFFF'
]
const initQuill = () => {
if (!editorContainer.value) return
const toolbarOptions = [
['bold', 'underline', 'italic'],
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
[{ 'color': [] }, { 'background': [] }],
[{ 'size': ['12', '14', '16', '18'] }],
['image'],
['clean']
]
quill.value = new Quill(editorContainer.value, {
theme: 'snow',
modules: {
toolbar: {
container: toolbarOptions,
handlers: {
image: () => insertImage()
}
}
},
placeholder: props.placeholder
})
// 添加自定义文件上传按钮
addCustomFileButton()
if (props.modelValue) {
quill.value.root.innerHTML = props.modelValue
}
quill.value.on('text-change', () => {
const content = quill.value?.root.innerHTML || ''
emit('update:modelValue', content)
emit('change', content)
})
quill.value.on('selection-change', (range) => {
if (range) {
const formats = quill.value?.getFormat(range)
isBold.value = !!formats?.bold
isList.value = !!formats?.list
}
})
}
// const changeFontSize = () => {
// if (quill.value) {
// quill.value.format('size', fontSize.value)
// }
// }
// const toggleBold = () => {
// if (quill.value) {
// quill.value.format('bold', !isBold.value)
// }
// }
// const toggleFontStyle = () => {
// if (quill.value) {
// quill.value.format('italic', !quill.value.getFormat().italic)
// }
// }
// const toggleColorPicker = () => {
// showColorPicker.value = !showColorPicker.value
// }
const selectColor = (color: string) => {
if (quill.value) {
quill.value.format('color', color)
}
showColorPicker.value = false
}
// const toggleList = () => {
// if (quill.value) {
// const format = quill.value.getFormat()
// if (format.list) {
// quill.value.format('list', false)
// } else {
// quill.value.format('list', 'bullet')
// }
// }
// }
const insertImage = () => {
const input = document.createElement('input')
input.setAttribute('type', 'file')
input.setAttribute('accept', 'image/*')
input.click()
input.onchange = () => {
const file = input.files?.[0]
if (file && quill.value) {
const reader = new FileReader()
reader.onload = (e) => {
const range = quill.value?.getSelection()
if (range) {
quill.value?.insertEmbed(range.index, 'image', e.target?.result)
}
}
reader.readAsDataURL(file)
}
}
}
const insertFile = () => {
const input = document.createElement('input')
input.setAttribute('type', 'file')
input.setAttribute('accept', '.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt')
input.click()
input.onchange = () => {
const file = input.files?.[0]
if (file) {
const fileSize = (file.size / 1024).toFixed(2) + 'KB'
uploadedFiles.value.push({
name: file.name,
size: fileSize
})
}
}
}
const removeFile = (index: number) => {
uploadedFiles.value.splice(index, 1)
}
const addCustomFileButton = () => {
if (!quill.value) return
// 获取工具栏
const toolbar = quill.value.getModule('toolbar') as any
const toolbarElement = toolbar.container
// 查找最后一个格式组
const lastFormatGroup = toolbarElement.querySelector('.ql-formats:last-child')
if (lastFormatGroup) {
// 创建文件按钮
const fileButton = document.createElement('button')
fileButton.type = 'button'
fileButton.className = 'ql-file'
fileButton.setAttribute('aria-label', 'file')
fileButton.setAttribute('title', '上传文件')
fileButton.innerHTML = `
<svg viewBox="0 0 18 18">
<path class="ql-stroke" d="M6,4V2A1,1,0,0,1,7,1H11A1,1,0,0,1,12,2V4"></path>
<path class="ql-stroke" d="M6,4H4A1,1,0,0,0,3,5V15A1,1,0,0,0,4,16H14A1,1,0,0,0,15,15V5A1,1,0,0,0,14,4H12"></path>
<line class="ql-stroke" x1="6" x2="12" y1="8" y2="8"></line>
<line class="ql-stroke" x1="6" x2="12" y1="11" y2="11"></line>
<line class="ql-stroke" x1="6" x2="10" y1="14" y2="14"></line>
</svg>
`
// 添加点击事件
fileButton.addEventListener('click', insertFile)
// 将按钮添加到工具栏
lastFormatGroup.appendChild(fileButton)
}
}
watch(() => props.modelValue, (newValue: string) => {
if (quill.value && newValue !== quill.value.root.innerHTML) {
quill.value.root.innerHTML = newValue
}
})
onMounted(() => {
nextTick(() => {
initQuill()
})
})
defineExpose({
getContent: () => quill.value?.root.innerHTML || '',
setContent: (content: string) => {
if (quill.value) {
quill.value.root.innerHTML = content
}
},
focus: () => quill.value?.focus()
})
</script>
<style scoped>
.quill-editor-container {
border: 1px solid #F7F7F7;
overflow: hidden;
width: 100%;
}
.editor-toolbar {
background: #f8f9fa;
border-bottom: 1px solid #F7F7F7;
padding: 8px 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 8px;
}
.font-size-select {
border-radius: 4px;
padding: 4px 8px;
font-size: 12px;
background: white;
cursor: pointer;
}
.toolbar-btn {
background: #f8f9fa;
border-radius: 4px;
padding: 6px 10px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
min-width: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.toolbar-btn:hover {
background: #e9ecef;
border-color: #adb5bd;
}
.toolbar-btn.active {
background: #007bff;
color: white;
border-color: #007bff;
}
.toolbar-btn.bold {
font-weight: bold;
}
.toolbar-btn.font-style {
font-style: italic;
}
.toolbar-btn.color {
position: relative;
}
.toolbar-btn.color::after {
content: '';
position: absolute;
bottom: 2px;
left: 50%;
transform: translateX(-50%);
width: 12px;
height: 2px;
background: #ff0000;
border-radius: 1px;
}
.editor-content {
min-height: v-bind(height);
background: white;
width: 100%;
}
.editor-content :deep(.ql-editor) {
min-height: v-bind(height);
padding: 16px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
width: 100%;
}
.editor-content :deep(.ql-toolbar) {
display: block;
background: white;
border-bottom: 1px solid #E6E6E6;
padding: 12px 16px;
}
.editor-content :deep(.ql-container) {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
.editor-content :deep(.ql-editor) {
min-height: v-bind(height);
padding: 16px;
padding-bottom: 80px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
width: 100%;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
.color-picker-popup {
position: absolute;
top: 100%;
left: 0;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
.color-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
}
.color-item {
width: 20px;
height: 20px;
border-radius: 2px;
cursor: pointer;
border: 1px solid #ddd;
transition: transform 0.1s;
}
.color-item:hover {
transform: scale(1.1);
}
/* 文件列表样式 */
.file-list-section {
position: absolute;
bottom: 20px;
left: 20px;
right: 20px;
z-index: 10;
backdrop-filter: blur(4px);
}
.file-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.file-item {
display: flex;
align-items: center;
background: #F5F8FB;
padding: 8px 12px;
width: 187px;
min-height: 84px;
position: relative;
}
.file-icon {
margin-right: 8px;
}
.file-icon img {
width: 49px;
height: 49px;
}
.file-info {
flex: 1;
min-width: 0;
}
.file-name {
font-size: 10px;
color: #000;
font-weight: 500;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-size {
font-size: 8px;
color: #999;
}
.file-delete {
position: absolute;
top: 8px;
right: 8px;
background: none;
border: none;
cursor: pointer;
padding: 4px;
transition: opacity 0.2s;
z-index: 5;
}
.file-delete img {
width: 12px;
height: 12px;
opacity: 0.6;
transition: opacity 0.2s;
}
.file-delete:hover img {
opacity: 1;
}
</style>