web-上传组件、富文本组件

This commit is contained in:
2025-10-20 11:25:34 +08:00
parent f137d7d720
commit 2f1835bdbf
12 changed files with 1608 additions and 445 deletions

View File

@@ -1,5 +1,91 @@
<template>
<div v-if="!asDialog" class="upload-container">
<div
class="upload-area"
:class="{ 'is-dragover': isDragover, 'is-disabled': uploading }"
@click="handleClickUpload"
@drop.prevent="handleDrop"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
>
<div class="upload-icon">📁</div>
<div class="upload-text">
将文件拖到此处<span class="link-text">点击上传</span>
</div>
<div class="upload-tip">
{{ tip || `支持 ${accept || '所有'} 格式,单个文件不超过 ${maxSize}MB` }}
</div>
</div>
<!-- 隐藏的文件输入框 -->
<input
ref="fileInputRef"
type="file"
:accept="accept"
:multiple="multiple"
@change="handleFileSelect"
style="display: none"
/>
<!-- 文件列表 -->
<div v-if="selectedFiles.length > 0" class="file-list">
<div v-for="(file, index) in selectedFiles" :key="index" class="file-item">
<!-- 文件预览 -->
<div class="file-preview">
<img
v-if="isImageFile(file)"
:src="getFilePreviewUrl(file)"
class="preview-image"
@click="showImagePreview(file)"
/>
<div v-else class="preview-icon">
<span class="file-type-icon">{{ getFileTypeIcon(file) }}</span>
</div>
</div>
<!-- 文件信息 -->
<div class="file-info">
<span class="file-name">{{ file.name }}</span>
<span class="file-size">{{ formatFileSize(file.size) }}</span>
</div>
<!-- 操作按钮 -->
<div class="file-actions">
<el-button
type="text"
size="small"
@click="previewFile(file)"
:disabled="uploading"
>
预览
</el-button>
<el-button
type="text"
size="small"
@click="removeFile(index)"
:disabled="uploading"
>
删除
</el-button>
</div>
</div>
</div>
<!-- 上传按钮 -->
<div v-if="selectedFiles.length > 0" class="upload-actions">
<el-button
type="primary"
@click="handleUpload"
:loading="uploading"
:disabled="selectedFiles.length === 0"
>
{{ uploading ? '上传中...' : '确定上传' }}
</el-button>
</div>
</div>
<el-dialog
v-else
v-model="visible"
:title="title"
width="600px"
@@ -37,16 +123,44 @@
<!-- 文件列表 -->
<div v-if="selectedFiles.length > 0" class="file-list">
<div v-for="(file, index) in selectedFiles" :key="index" class="file-item">
<span class="file-name">{{ file.name }}</span>
<span class="file-size">{{ formatFileSize(file.size) }}</span>
<el-button
type="text"
size="small"
@click="removeFile(index)"
:disabled="uploading"
>
删除
</el-button>
<!-- 文件预览 -->
<div class="file-preview">
<img
v-if="isImageFile(file)"
:src="getFilePreviewUrl(file)"
class="preview-image"
@click="showImagePreview(file)"
/>
<div v-else class="preview-icon">
<span class="file-type-icon">{{ getFileTypeIcon(file) }}</span>
</div>
</div>
<!-- 文件信息 -->
<div class="file-info">
<span class="file-name">{{ file.name }}</span>
<span class="file-size">{{ formatFileSize(file.size) }}</span>
</div>
<!-- 操作按钮 -->
<div class="file-actions">
<el-button
type="text"
size="small"
@click="previewFile(file)"
:disabled="uploading"
>
预览
</el-button>
<el-button
type="text"
size="small"
@click="removeFile(index)"
:disabled="uploading"
>
删除
</el-button>
</div>
</div>
</div>
</div>
@@ -63,6 +177,43 @@
</el-button>
</template>
</el-dialog>
<!-- 图片预览对话框 -->
<el-dialog
v-model="imagePreviewVisible"
title="图片预览"
width="80%"
:close-on-click-modal="false"
>
<div class="image-preview-container">
<img :src="previewImageUrl" class="preview-large-image" />
</div>
</el-dialog>
<!-- 文件预览对话框 -->
<el-dialog
v-model="filePreviewVisible"
title="文件预览"
width="80%"
:close-on-click-modal="false"
>
<div class="file-preview-container">
<div v-if="previewFileType === 'image'" class="image-preview">
<img :src="previewFileUrl" class="preview-large-image" />
</div>
<div v-else-if="previewFileType === 'text'" class="text-preview">
<pre>{{ previewTextContent }}</pre>
</div>
<div v-else class="unsupported-preview">
<div class="preview-icon-large">
<span class="file-type-icon-large">{{ currentPreviewFile ? getFileTypeIcon(currentPreviewFile) : '📄' }}</span>
</div>
<p>该文件类型不支持预览</p>
<p>文件名{{ currentPreviewFile?.name }}</p>
<p>文件大小{{ currentPreviewFile ? formatFileSize(currentPreviewFile.size) : '' }}</p>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
@@ -80,6 +231,7 @@ interface Props {
module?: string;
businessId?: string;
tip?: string;
asDialog?: boolean; // 是否作为弹窗使用
}
const props = withDefaults(defineProps<Props>(), {
@@ -89,7 +241,8 @@ const props = withDefaults(defineProps<Props>(), {
maxSize: 10,
multiple: false,
module: 'common',
tip: ''
tip: '',
asDialog: true
});
const emit = defineEmits<{
@@ -104,10 +257,19 @@ const uploading = ref(false);
const isDragover = ref(false);
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
get: () => props.asDialog ? props.modelValue : false,
set: (val) => props.asDialog ? emit('update:modelValue', val) : undefined
});
// 预览相关
const imagePreviewVisible = ref(false);
const filePreviewVisible = ref(false);
const previewImageUrl = ref('');
const previewFileUrl = ref('');
const previewFileType = ref('');
const previewTextContent = ref('');
const currentPreviewFile = ref<File | null>(null);
// 点击上传区域
function handleClickUpload() {
if (uploading.value) return;
@@ -203,6 +365,65 @@ function formatFileSize(bytes: number): string {
return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i];
}
// 判断是否为图片文件
function isImageFile(file: File): boolean {
return file.type.startsWith('image/');
}
// 获取文件预览URL
function getFilePreviewUrl(file: File): string {
return URL.createObjectURL(file);
}
// 获取文件类型图标
function getFileTypeIcon(file: File): string {
const extension = file.name.split('.').pop()?.toLowerCase();
const iconMap: Record<string, string> = {
'pdf': '📄',
'doc': '📝',
'docx': '📝',
'txt': '📄',
'zip': '📦',
'rar': '📦',
'mp4': '🎬',
'avi': '🎬',
'mp3': '🎵',
'wav': '🎵',
'xls': '📊',
'xlsx': '📊',
'ppt': '📊',
'pptx': '📊'
};
return iconMap[extension || ''] || '📄';
}
// 显示图片预览
function showImagePreview(file: File) {
previewImageUrl.value = getFilePreviewUrl(file);
imagePreviewVisible.value = true;
}
// 预览文件
async function previewFile(file: File) {
currentPreviewFile.value = file;
previewFileUrl.value = getFilePreviewUrl(file);
if (isImageFile(file)) {
previewFileType.value = 'image';
} else if (file.type.startsWith('text/')) {
previewFileType.value = 'text';
try {
previewTextContent.value = await file.text();
} catch (error) {
previewTextContent.value = '无法读取文件内容';
}
} else {
previewFileType.value = 'unsupported';
}
filePreviewVisible.value = true;
}
// 上传文件
async function handleUpload() {
if (selectedFiles.value.length === 0) {
@@ -249,7 +470,9 @@ async function handleUpload() {
// 关闭对话框
function handleClose() {
visible.value = false;
if (props.asDialog) {
visible.value = false;
}
selectedFiles.value = [];
isDragover.value = false;
uploading.value = false;
@@ -257,7 +480,9 @@ function handleClose() {
// 打开对话框
function open() {
visible.value = true;
if (props.asDialog) {
visible.value = true;
}
}
defineExpose({
@@ -355,4 +580,134 @@ defineExpose({
font-size: 12px;
margin-right: 12px;
}
.file-preview {
width: 60px;
height: 60px;
margin-right: 12px;
border-radius: 4px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
flex-shrink: 0;
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
.preview-icon {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
.file-type-icon {
font-size: 24px;
}
}
}
.file-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
.file-name {
color: #606266;
font-size: 14px;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
color: #909399;
font-size: 12px;
}
}
.file-actions {
display: flex;
gap: 8px;
margin-left: 12px;
}
.image-preview-container {
text-align: center;
padding: 20px;
.preview-large-image {
max-width: 100%;
max-height: 70vh;
border-radius: 4px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
}
.file-preview-container {
min-height: 300px;
.image-preview {
text-align: center;
padding: 20px;
.preview-large-image {
max-width: 100%;
max-height: 70vh;
border-radius: 4px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
}
.text-preview {
pre {
background: #f5f7fa;
padding: 16px;
border-radius: 4px;
max-height: 60vh;
overflow: auto;
white-space: pre-wrap;
word-wrap: break-word;
font-family: 'Courier New', monospace;
font-size: 14px;
line-height: 1.5;
}
}
.unsupported-preview {
text-align: center;
padding: 40px;
.preview-icon-large {
margin-bottom: 16px;
.file-type-icon-large {
font-size: 64px;
}
}
p {
margin: 8px 0;
color: #606266;
font-size: 14px;
}
}
}
.upload-actions {
margin-top: 16px;
text-align: center;
}
</style>

View File

@@ -107,7 +107,7 @@ import Quill from 'quill';
import { FileUpload } from '@/components/file';
import type { SysFile } from '@/types';
import { FILE_DOWNLOAD_URL } from '@/config';
import { ImageResize } from '@/utils/quill-resize';
import { registerImageResize } from '@/utils/quill-resize';
// Quill 样式已在 main.ts 中全局引入
interface Props {
@@ -204,7 +204,10 @@ function initQuill() {
const node = super.create() as HTMLVideoElement;
node.setAttribute('src', value);
node.setAttribute('controls', 'true');
node.setAttribute('style', 'max-width: 100%; display: block; margin: 12px auto;');
node.setAttribute('class', 'custom-video');
node.setAttribute('data-custom-video', 'true');
// 视频默认居中显示
node.setAttribute('style', 'max-width: 100%; display: block; margin: 0 auto;');
return node;
}
@@ -213,7 +216,33 @@ function initQuill() {
}
}
// 自定义图片 Blot与文字同行显示
const InlineEmbed: any = Quill.import('blots/embed');
class CustomImageBlot extends InlineEmbed {
static blotName = 'customImage';
static tagName = 'img';
static create(value: string) {
const node = super.create() as HTMLImageElement;
node.setAttribute('src', value);
node.setAttribute('class', 'custom-image');
node.setAttribute('data-custom-image', 'true');
// 图片与文字同行显示
node.setAttribute('style', 'max-width: 100%; display: inline-block; vertical-align: bottom;');
return node;
}
static value(node: HTMLImageElement) {
return node.getAttribute('src');
}
}
Quill.register(VideoBlot);
Quill.register(CustomImageBlot);
// 注册图片/视频拉伸模块
registerImageResize();
// 配置选项
const options = {
@@ -249,7 +278,17 @@ function initQuill() {
matchVisual: false
},
// 启用图片/视频缩放模块
imageResize: ImageResize
imageResize: {
onResizeEnd: () => {
console.log('🔄 图片/视频拉伸结束,强制更新内容');
// 强制触发内容更新
if (quillInstance) {
const html = quillInstance.root.innerHTML;
emit('update:modelValue', html);
emit('change', html);
}
}
}
},
placeholder: props.placeholder,
readOnly: props.readOnly || props.disabled
@@ -369,12 +408,12 @@ function handleUploadSuccess(files: SysFile[]) {
// 根据类型插入内容
if (uploadType.value === 'image') {
// 插入图片
quillInstance!.insertEmbed(range.index, 'image', downloadUrl);
// 插入自定义图片(与文字同行显示)
quillInstance!.insertEmbed(range.index, 'customImage', downloadUrl);
// 移动光标到图片后面
quillInstance!.setSelection(range.index + 1);
} else if (uploadType.value === 'video') {
// 插入自定义视频(使用 customVideo 而不是默认的 video
// 插入自定义视频(单行居中显示
quillInstance!.insertEmbed(range.index, 'customVideo', downloadUrl);
// 移动光标到视频后面
quillInstance!.setSelection(range.index + 1);
@@ -464,50 +503,93 @@ defineExpose({
}
}
// 图片样式 - 默认内联显示,底部对齐
img {
max-width: 100%;
height: auto;
display: inline-block;
vertical-align: bottom;
}
// 视频容器样式
// 视频容器样式 - 默认内联显示,底部对齐
iframe, video {
max-width: 100%;
height: auto;
display: inline-block;
vertical-align: bottom;
}
// 自定义视频默认样式 - 单行居中显示
.custom-video {
max-width: 100%;
height: auto;
display: block;
margin: 12px auto; // 默认居中
margin: 0 auto;
}
// 自定义图片默认样式 - 与文字同行显示
.custom-image {
max-width: 100%;
height: auto;
display: inline-block;
vertical-align: bottom;
}
// Quill 视频包装器
.ql-video {
display: block;
display: inline-block;
max-width: 100%;
}
// 支持对齐方式
// 支持对齐方式 - 图片和视频分别处理
.ql-align-center {
text-align: center;
text-align: center !important;
iframe, video, .ql-video {
margin-left: auto;
margin-right: auto;
// 视频始终居中显示
video, .custom-video {
display: block !important;
margin-left: auto !important;
margin-right: auto !important;
}
// 图片跟随文字对齐
img, .custom-image {
display: inline-block !important;
vertical-align: bottom !important;
}
}
.ql-align-right {
text-align: right;
text-align: right !important;
iframe, video, .ql-video {
margin-left: auto;
margin-right: 0;
// 视频始终居中显示
video, .custom-video {
display: block !important;
margin-left: auto !important;
margin-right: auto !important;
}
// 图片跟随文字对齐
img, .custom-image {
display: inline-block !important;
vertical-align: bottom !important;
}
}
.ql-align-left {
text-align: left;
text-align: left !important;
iframe, video, .ql-video {
margin-left: 0;
margin-right: auto;
// 视频始终居中显示
video, .custom-video {
display: block !important;
margin-left: auto !important;
margin-right: auto !important;
}
// 图片跟随文字对齐
img, .custom-image {
display: inline-block !important;
vertical-align: bottom !important;
}
}

View File

@@ -1,161 +0,0 @@
<template>
<div class="rich-text-example">
<h2>富文本编辑器示例</h2>
<div class="example-section">
<h3>基础使用</h3>
<RichTextComponent
v-model="content1"
placeholder="请输入内容..."
height="300px"
/>
<div class="preview">
<h4>预览</h4>
<div v-html="content1" class="preview-content"></div>
</div>
</div>
<div class="example-section">
<h3>带字数统计</h3>
<RichTextComponent
v-model="content2"
placeholder="最多输入500字..."
height="200px"
:max-length="500"
show-word-count
/>
</div>
<div class="example-section">
<h3>只读模式</h3>
<RichTextComponent
v-model="content3"
height="150px"
read-only
/>
</div>
<div class="example-section">
<h3>禁用状态</h3>
<RichTextComponent
v-model="content4"
height="150px"
disabled
/>
</div>
<div class="example-section">
<h3>错误状态</h3>
<RichTextComponent
v-model="content5"
height="150px"
error
error-message="内容不能为空"
/>
</div>
<div class="example-section">
<h3>使用 ref 调用方法</h3>
<RichTextComponent
ref="editorRef"
v-model="content6"
height="200px"
/>
<div class="button-group">
<el-button @click="getText">获取纯文本</el-button>
<el-button @click="getHTML">获取HTML</el-button>
<el-button @click="clearContent">清空内容</el-button>
<el-button @click="setContent">设置内容</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ElButton, ElMessage } from 'element-plus';
import RichTextComponent from './RichTextComponent.vue';
const content1 = ref('<p>这是一段<strong>富文本</strong>内容</p>');
const content2 = ref('');
const content3 = ref('<p>这是只读内容,无法编辑</p>');
const content4 = ref('<p>这是禁用状态</p>');
const content5 = ref('');
const content6 = ref('<p>测试内容</p>');
const editorRef = ref();
function getText() {
const text = editorRef.value?.getText();
ElMessage.success(`纯文本内容:${text}`);
console.log('纯文本:', text);
}
function getHTML() {
const html = editorRef.value?.getHTML();
ElMessage.success('HTML已输出到控制台');
console.log('HTML', html);
}
function clearContent() {
editorRef.value?.clear();
ElMessage.success('内容已清空');
}
function setContent() {
const newContent = '<h2>新标题</h2><p>这是通过方法设置的内容</p><ul><li>列表项1</li><li>列表项2</li></ul>';
editorRef.value?.setContent(newContent);
ElMessage.success('内容已设置');
}
</script>
<style lang="scss" scoped>
.rich-text-example {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
h2 {
margin-bottom: 30px;
color: #303133;
}
.example-section {
margin-bottom: 40px;
h3 {
margin-bottom: 16px;
color: #606266;
font-size: 16px;
}
}
.preview {
margin-top: 20px;
padding: 16px;
background: #f5f7fa;
border-radius: 4px;
h4 {
margin: 0 0 12px 0;
color: #606266;
font-size: 14px;
}
.preview-content {
padding: 12px;
background: white;
border-radius: 4px;
min-height: 100px;
}
}
.button-group {
margin-top: 16px;
display: flex;
gap: 12px;
}
</style>