Files
yunzerwebsiteallinone/backend/src/views/components/TiptapEditor.vue
T
2026-07-14 15:49:00 +08:00

1045 lines
26 KiB
Vue

<template>
<div class="tiptap-editor-wrapper" :class="{ focused: isFocused }">
<div v-if="editor" class="tiptap-toolbar">
<div class="toolbar-group">
<button
title="加粗"
:class="{ active: editor.isActive('bold') }"
@click="editor.chain().focus().toggleBold().run()"
>
<strong>B</strong>
</button>
<button
title="斜体"
:class="{ active: editor.isActive('italic') }"
@click="editor.chain().focus().toggleItalic().run()"
>
<em>I</em>
</button>
<button
title="下划线"
:class="{ active: editor.isActive('underline') }"
@click="editor.chain().focus().toggleUnderline().run()"
>
<u>U</u>
</button>
<button
title="删除线"
:class="{ active: editor.isActive('strike') }"
@click="editor.chain().focus().toggleStrike().run()"
>
<s>S</s>
</button>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<select
title="标题"
:value="currentHeadingLevel"
@change="setHeading($event.target.value)"
>
<option value="0">正文</option>
<option value="1">标题 1</option>
<option value="2">标题 2</option>
<option value="3">标题 3</option>
<option value="4">标题 4</option>
</select>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<button
title="有序列表"
:class="{ active: editor.isActive('orderedList') }"
@click="editor.chain().focus().toggleOrderedList().run()"
>
1.
</button>
<button
title="无序列表"
:class="{ active: editor.isActive('bulletList') }"
@click="editor.chain().focus().toggleBulletList().run()"
>
&bull;
</button>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<button
title="引用"
:class="{ active: editor.isActive('blockquote') }"
@click="editor.chain().focus().toggleBlockquote().run()"
>
&ldquo;
</button>
<button
title="代码块"
:class="{ active: editor.isActive('codeBlock') }"
@click="editor.chain().focus().toggleCodeBlock().run()"
>
&lt;/&gt;
</button>
<button
title="行内代码"
:class="{ active: editor.isActive('code') }"
@click="editor.chain().focus().toggleCode().run()"
>
Code
</button>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<button
title="水平线"
@click="editor.chain().focus().setHorizontalRule().run()"
>
&mdash;
</button>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<button
title="插入链接"
:class="{ active: editor.isActive('link') }"
@click="toggleLink"
>
&#128279;
</button>
<button title="上传图片" @click="triggerImageUpload">
&#128247;
</button>
<input
ref="imageInputRef"
type="file"
accept="image/*"
style="display: none"
@change="handleImageUpload"
/>
<button title="插入视频" @click="insertVideo">
&#9654;
</button>
<button title="上传附件" @click="triggerAttachmentUpload">
&#128206;
</button>
<input
ref="attachmentInputRef"
type="file"
accept="*/*"
multiple
style="display: none"
@change="handleAttachmentUpload"
/>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<button
title="插入表格"
@click="editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()"
>
&#9638;
</button>
<template v-if="editor.isActive('table')">
<button title="删除表格" @click="editor.chain().focus().deleteTable().run()">
&#10005;
</button>
<button title="删除行" @click="editor.chain().focus().deleteRow().run()">
-
</button>
<button title="删除列" @click="editor.chain().focus().deleteColumn().run()">
-
</button>
<button title="合并单元格" @click="editor.chain().focus().mergeCells().run()">
合并
</button>
</template>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<div class="color-picker-wrapper" title="文字颜色">
<span class="color-label" :style="{ borderBottomColor: currentTextColor }">A</span>
<input
type="color"
:value="currentTextColor"
@input="editor.chain().focus().setColor($event.target.value).run()"
/>
</div>
<div class="color-picker-wrapper" title="背景色">
<span class="color-label highlight" :style="{ backgroundColor: currentHighlightColor }">A</span>
<input
type="color"
:value="currentHighlightColor"
@input="editor.chain().focus().toggleHighlight({ color: $event.target.value }).run()"
/>
</div>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<button
title="左对齐"
:class="{ active: editor.isActive({ textAlign: 'left' }) }"
@click="editor.chain().focus().setTextAlign('left').run()"
>
&#8676;
</button>
<button
title="居中"
:class="{ active: editor.isActive({ textAlign: 'center' }) }"
@click="editor.chain().focus().setTextAlign('center').run()"
>
&#8596;
</button>
<button
title="右对齐"
:class="{ active: editor.isActive({ textAlign: 'right' }) }"
@click="editor.chain().focus().setTextAlign('right').run()"
>
&#8677;
</button>
<button
title="两端对齐"
:class="{ active: editor.isActive({ textAlign: 'justify' }) }"
@click="editor.chain().focus().setTextAlign('justify').run()"
>
&#8615;
</button>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<button
title="撤销"
:disabled="!editor.can().undo()"
@click="editor.chain().focus().undo().run()"
>
&#8617;
</button>
<button
title="重做"
:disabled="!editor.can().redo()"
@click="editor.chain().focus().redo().run()"
>
&#8618;
</button>
</div>
</div>
<div class="editor-container">
<editor-content :editor="editor" class="tiptap-content" />
</div>
</div>
</template>
<script setup>
import { ref, watch, computed, onBeforeUnmount } from 'vue';
import { useEditor, EditorContent } from '@tiptap/vue-3';
import StarterKit from '@tiptap/starter-kit';
import { Underline } from '@tiptap/extension-underline';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
import { Highlight } from '@tiptap/extension-highlight';
import { Placeholder } from '@tiptap/extension-placeholder';
import { Link } from '@tiptap/extension-link';
import { Image } from '@tiptap/extension-image';
import { Table } from '@tiptap/extension-table';
import { TableRow } from '@tiptap/extension-table-row';
import { TableCell } from '@tiptap/extension-table-cell';
import { TableHeader } from '@tiptap/extension-table-header';
import { TextAlign } from '@tiptap/extension-text-align';
import { ElMessage } from 'element-plus';
import { uploadFile } from '@/api/file';
const props = defineProps({
modelValue: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:modelValue']);
const isFocused = ref(false);
const imageInputRef = ref(null);
const attachmentInputRef = ref(null);
const currentTextColor = ref('#000000');
const currentHighlightColor = ref('#ffff00');
const currentHeadingLevel = computed(() => {
if (!editor.value) return '0';
for (let i = 1; i <= 6; i++) {
if (editor.value.isActive('heading', { level: i })) return String(i);
}
return '0';
});
const getUploadUrl = () => {
return import.meta.env.VITE_API_BASE_URL;
};
const getAuthHeaders = () => {
const token = localStorage.getItem('token');
return token ? { Authorization: `Bearer ${token}` } : {};
};
const editor = useEditor({
content: props.modelValue || '',
extensions: [
StarterKit,
Underline,
TextStyle,
Color,
Highlight.configure({ multicolor: true }),
Placeholder.configure({ placeholder: '请输入内容...' }),
Link.configure({ openOnClick: false }),
Image,
Table.configure({ resizable: true }),
TableRow,
TableCell,
TableHeader,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
],
editorProps: {
attributes: {
class: 'tiptap',
},
},
onUpdate: ({ editor: e }) => {
if (!isDestroyed) {
emit('update:modelValue', e.getHTML());
}
},
onFocus: () => { isFocused.value = true; },
onBlur: () => { isFocused.value = false; },
});
let isDestroyed = false;
watch(
() => props.modelValue,
(newVal) => {
if (isDestroyed || !editor.value) return;
const currentHtml = editor.value.getHTML();
if (newVal !== currentHtml) {
editor.value.commands.setContent(newVal || '', false);
}
},
);
const setHeading = (level) => {
if (!editor.value) return;
const l = parseInt(level);
if (l === 0) {
editor.value.chain().focus().setParagraph().run();
} else {
editor.value.chain().focus().toggleHeading({ level: l }).run();
}
};
const toggleLink = () => {
if (!editor.value) return;
if (editor.value.isActive('link')) {
editor.value.chain().focus().unsetLink().run();
return;
}
const url = window.prompt('请输入链接地址:', 'https://');
if (url) {
editor.value.chain().focus().setLink({ href: url }).run();
}
};
const triggerImageUpload = () => {
imageInputRef.value?.click();
};
const handleImageUpload = async (event) => {
const file = event.target.files?.[0];
if (!file || !editor.value) return;
event.target.value = '';
try {
const formData = new FormData();
formData.append('file', file);
formData.append('cate', 'article');
const response = await uploadFile(formData);
if (response && (response.code === 200 || response.code === 201) && response.data?.url) {
const fileUrl = response.data.url;
const baseUrl = getUploadUrl() || window.location.origin;
let fullUrl = fileUrl;
if (!fileUrl.startsWith('http')) {
const base = baseUrl.replace(/\/$/, '');
const url = fileUrl.startsWith('/') ? fileUrl : '/' + fileUrl;
fullUrl = `${base}${url}`;
}
editor.value.chain().focus().setImage({ src: fullUrl, alt: file.name }).run();
ElMessage.success(response.code === 201 ? '使用已有图片' : '图片上传成功');
} else {
ElMessage.error('上传失败:' + (response?.msg || '未知错误'));
}
} catch (error) {
console.error('Upload error:', error);
ElMessage.error('上传失败:' + (error.message || '未知错误'));
}
};
const insertVideo = () => {
if (!editor.value) return;
const url = window.prompt('请输入视频地址:', 'https://');
if (url) {
editor.value.chain().focus().insertContent(`<video src="${url}" controls style="max-width:100%"></video>`).run();
}
};
const triggerAttachmentUpload = () => {
attachmentInputRef.value?.click();
};
const handleAttachmentUpload = async (event) => {
const files = Array.from(event.target.files || []);
if (!files.length || !editor.value) return;
event.target.value = '';
for (const file of files) {
try {
const formData = new FormData();
formData.append('file', file);
// 可以根据文件类型设置不同的分类
const fileType = getFileCategory(file);
formData.append('cate', fileType);
const response = await uploadFile(formData);
if (response && (response.code === 200 || response.code === 201) && response.data?.url) {
const fileUrl = response.data.url;
const baseUrl = getUploadUrl() || window.location.origin;
let fullUrl = fileUrl;
if (!fileUrl.startsWith('http')) {
const base = baseUrl.replace(/\/$/, '');
const url = fileUrl.startsWith('/') ? fileUrl : '/' + fileUrl;
fullUrl = `${base}${url}`;
}
// 插入附件链接
insertAttachmentLink(file.name, fullUrl, file.size, file.type);
ElMessage.success(response.code === 201 ? `使用已有文件: ${file.name}` : `文件上传成功: ${file.name}`);
} else {
ElMessage.error(`上传失败 ${file.name}: ` + (response?.msg || '未知错误'));
}
} catch (error) {
console.error('Attachment upload error:', error);
ElMessage.error(`上传失败 ${file.name}: ` + (error.message || '未知错误'));
}
}
};
const getFileCategory = (file) => {
const fileType = file.type;
if (fileType.startsWith('image/')) return 'image';
if (fileType.startsWith('video/')) return 'video';
if (fileType.startsWith('audio/')) return 'audio';
if (fileType.includes('pdf')) return 'pdf';
if (fileType.includes('word') || file.name.match(/\.(doc|docx)$/i)) return 'word';
if (fileType.includes('excel') || file.name.match(/\.(xls|xlsx)$/i)) return 'excel';
if (fileType.includes('powerpoint') || file.name.match(/\.(ppt|pptx)$/i)) return 'ppt';
if (fileType.includes('zip') || fileType.includes('rar') || file.name.match(/\.(zip|rar|7z|tar|gz)$/i)) return 'archive';
return 'document';
};
const insertAttachmentLink = (fileName, fileUrl, fileSize, fileType) => {
if (!editor.value) return;
const sizeText = formatFileSize(fileSize);
const fileIcon = getFileIcon(fileType, fileName);
// 创建附件链接,包含图标、文件名、大小和下载链接
const attachmentHtml = `
<div class="attachment-link" style="display: flex; align-items: center; gap: 8px; margin: 8px 0; padding: 8px 12px; background: #f5f7fa; border-radius: 4px; border: 1px solid #e4e7ed;">
<span style="font-size: 20px;">${fileIcon}</span>
<div style="flex: 1; min-width: 0;">
<a href="${fileUrl}" target="_blank" rel="noopener noreferrer" style="color: #3973ff; text-decoration: none; font-weight: 500; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
${fileName}
</a>
<div style="font-size: 12px; color: #909399; margin-top: 2px;">
${sizeText}${getFileTypeText(fileType)}
</div>
</div>
<a href="${fileUrl}" download="${fileName}" style="color: #409eff; text-decoration: none; padding: 4px 8px; border: 1px solid #409eff; border-radius: 3px; font-size: 12px;">
下载
</a>
</div>
`;
editor.value.chain().focus().insertContent(attachmentHtml).run();
};
const formatFileSize = (bytes) => {
if (bytes === 0 || !bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const getFileIcon = (fileType, fileName) => {
// 根据文件类型返回相应的图标
if (fileType.startsWith('image/')) return '🖼️';
if (fileType.startsWith('video/')) return '🎬';
if (fileType.startsWith('audio/')) return '🎵';
if (fileType.includes('pdf') || fileName.match(/\.pdf$/i)) return '📄';
if (fileType.includes('word') || fileName.match(/\.(doc|docx)$/i)) return '📝';
if (fileType.includes('excel') || fileName.match(/\.(xls|xlsx)$/i)) return '📊';
if (fileType.includes('powerpoint') || fileName.match(/\.(ppt|pptx)$/i)) return '📽️';
if (fileType.includes('zip') || fileType.includes('rar') || fileName.match(/\.(zip|rar|7z|tar|gz)$/i)) return '📦';
if (fileName.match(/\.(txt|md|json|xml|html|js|ts|css)$/i)) return '📄';
return '📎';
};
const getFileTypeText = (fileType) => {
if (fileType.startsWith('image/')) return '图片';
if (fileType.startsWith('video/')) return '视频';
if (fileType.startsWith('audio/')) return '音频';
if (fileType.includes('pdf')) return 'PDF文档';
if (fileType.includes('word')) return 'Word文档';
if (fileType.includes('excel')) return 'Excel表格';
if (fileType.includes('powerpoint')) return 'PPT演示';
if (fileType.includes('zip') || fileType.includes('rar')) return '压缩文件';
if (fileType.includes('text')) return '文本文件';
return '文档文件';
};
defineExpose({
clear: () => {
if (isDestroyed || !editor.value) return;
editor.value.commands.clearContent();
},
getContent: () => {
if (isDestroyed || !editor.value) return props.modelValue;
return editor.value.getHTML();
},
setContent: (content) => {
if (isDestroyed || !editor.value) return;
editor.value.commands.setContent(content || '', false);
},
});
onBeforeUnmount(() => {
isDestroyed = true;
editor.value?.destroy();
});
</script>
<style lang="less">
.tiptap-editor-wrapper {
display: flex;
flex-direction: column;
height: 650px;
border: 1px solid #dcdfe6;
border-radius: 4px;
overflow: hidden;
background: #ffffff;
&.focused {
border-color: #409eff;
}
.tiptap-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
padding: 6px 8px;
background: #f5f7fa;
border-bottom: 1px solid #dcdfe6;
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 10;
.toolbar-group {
display: flex;
align-items: center;
gap: 2px;
button, select {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
padding: 2px 6px;
border: 1px solid transparent;
border-radius: 3px;
background: transparent;
color: #606266;
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
line-height: 1;
&:hover {
background: #e8eaed;
color: #303133;
}
&.active {
background: #ecf5ff;
color: #409eff;
border-color: #b3d8ff;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
select {
min-width: 70px;
padding: 2px 4px;
border-color: #dcdfe6;
background: #fff;
font-size: 12px;
cursor: pointer;
}
.color-picker-wrapper {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
cursor: pointer;
.color-label {
font-weight: bold;
font-size: 14px;
border-bottom: 3px solid #000;
line-height: 1;
padding-bottom: 1px;
&.highlight {
border-bottom-color: transparent;
background-color: #ffff00;
padding: 0 2px;
}
}
input[type="color"] {
position: absolute;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
}
}
.toolbar-divider {
width: 1px;
height: 20px;
background: #dcdfe6;
margin: 0 4px;
}
}
.editor-container {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 0;
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-thumb {
background: #c0c4cc;
border-radius: 3px;
}
&::-webkit-scrollbar-track {
background: #f5f7fa;
border-radius: 3px;
}
}
.tiptap-content {
height: 100%;
}
.tiptap {
outline: none;
min-height: 100%;
padding: 16px;
> * + * {
margin-top: 0.75em;
}
p {
color: #1a1a2e;
margin: 8px 0;
line-height: 1.8;
}
h1, h2, h3, h4, h5, h6 {
color: #1a1a2e;
font-weight: 600;
margin: 16px 0 8px;
line-height: 1.4;
}
h1 { font-size: 2em; }
h2 { font-size: 1.5em; }
h3 { font-size: 1.25em; }
h4 { font-size: 1.1em; }
a {
color: #3973ff;
text-decoration: underline;
cursor: pointer;
}
code {
background: #f5f7fa;
color: #1a1a2e;
border: 1px solid #e4e7ed;
border-radius: 3px;
padding: 2px 6px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
}
pre {
background: #f5f7fa;
border: 1px solid #e4e7ed;
border-radius: 4px;
padding: 12px 16px;
margin: 12px 0;
overflow-x: auto;
code {
background: transparent;
border: none;
padding: 0;
font-size: 13px;
line-height: 1.6;
color: #1a1a2e;
}
}
blockquote {
border-left: 4px solid #3973ff;
background: #f5f7fa;
color: #606266;
padding: 8px 16px;
margin: 12px 0;
> * + * {
margin-top: 0.5em;
}
}
table {
border-collapse: collapse;
border: 1px solid #e4e7ed;
width: 100%;
margin: 12px 0;
table-layout: fixed;
th, td {
border: 1px solid #e4e7ed;
padding: 8px 12px;
min-width: 60px;
position: relative;
vertical-align: top;
}
th {
background: #f5f7fa;
font-weight: 600;
}
.selectedCell::after {
z-index: 2;
position: absolute;
content: "";
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(200, 200, 255, 0.3);
pointer-events: none;
}
.column-resize-handle {
position: absolute;
right: -2px;
top: 0;
bottom: -2px;
width: 4px;
background: #adf;
pointer-events: none;
}
p {
margin: 0;
}
}
ul, ol {
color: #1a1a2e;
padding-left: 24px;
margin: 8px 0;
}
li {
color: #1a1a2e;
line-height: 1.8;
margin: 4px 0;
}
hr {
border: none;
border-top: 1px solid #e4e7ed;
margin: 16px 0;
}
img {
max-width: 100%;
border-radius: 4px;
margin: 8px 0;
}
video {
max-width: 100%;
border-radius: 4px;
margin: 8px 0;
}
mark {
background-color: #fff3cd;
padding: 0 2px;
border-radius: 2px;
}
p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: #adb5bd;
pointer-events: none;
height: 0;
}
.attachment-link {
display: flex;
align-items: center;
gap: 8px;
margin: 8px 0;
padding: 8px 12px;
background: #f5f7fa;
border-radius: 4px;
border: 1px solid #e4e7ed;
transition: all 0.2s;
&:hover {
background: #ecf5ff;
border-color: #b3d8ff;
}
a[href] {
color: #3973ff;
text-decoration: none;
font-weight: 500;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
color: #1d5fdd;
text-decoration: underline;
}
}
.file-info {
font-size: 12px;
color: #909399;
margin-top: 2px;
}
.download-btn {
color: #409eff;
text-decoration: none;
padding: 4px 8px;
border: 1px solid #409eff;
border-radius: 3px;
font-size: 12px;
transition: all 0.2s;
&:hover {
background: #409eff;
color: white;
}
}
}
}
}
html.dark {
.tiptap-editor-wrapper {
border-color: #3d3d3d;
background: #1a1a1a;
.tiptap-toolbar {
background: #2d2d2d;
border-color: #3d3d3d;
.toolbar-group button, .toolbar-group select {
color: #b0b0b0;
&:hover {
background: #3d3d3d;
color: #e0e0e0;
}
&.active {
background: #1a3a5c;
color: #409eff;
border-color: #2a5a8c;
}
}
.toolbar-divider {
background: #3d3d3d;
}
}
.editor-container {
&::-webkit-scrollbar-thumb {
background: #4d4d4d;
}
&::-webkit-scrollbar-track {
background: #2d2d2d;
}
}
.tiptap {
p, li, h1, h2, h3, h4, h5, h6 {
color: #e0e0e0;
}
a {
color: #4f84ff;
}
code {
background: #2d2d2d;
color: #e0e0e0;
border-color: #3d3d3d;
}
pre {
background: #2d2d2d;
border-color: #3d3d3d;
color: #e0e0e0;
code {
background: transparent;
border: none;
color: #e0e0e0;
}
}
blockquote {
border-color: #4f84ff;
background: #2d2d2d;
color: #b0b0b0;
}
table {
border-color: #3d3d3d;
th, td {
border-color: #3d3d3d;
background: #1a1a1a;
color: #e0e0e0;
}
th {
background: #2d2d2d;
}
}
ul, ol {
color: #e0e0e0;
}
hr {
border-color: #3d3d3d;
}
mark {
background-color: #4a3d00;
color: #ffd666;
}
.attachment-link {
background: #2d2d2d;
border-color: #3d3d3d;
&:hover {
background: #1a3a5c;
border-color: #2a5a8c;
}
a[href] {
color: #4f84ff;
&:hover {
color: #2d68ff;
}
}
.file-info {
color: #b0b0b0;
}
.download-btn {
color: #409eff;
border-color: #409eff;
&:hover {
background: #409eff;
color: white;
}
}
}
}
}
}
</style>