web-权限、文章
This commit is contained in:
@@ -1,174 +1,76 @@
|
||||
<template>
|
||||
<aside class="floating-sidebar" :class="{ collapsed: collapsed, [type]: true }">
|
||||
<!-- 折叠按钮 -->
|
||||
<div class="sidebar-toggle-btn" @click="$emit('toggle')">
|
||||
<i class="toggle-icon">{{ collapsed ? '▶' : '◀' }}</i>
|
||||
</div>
|
||||
|
||||
<aside class="floating-sidebar">
|
||||
<!-- 侧边栏内容 -->
|
||||
<div class="sidebar-content" v-if="!collapsed">
|
||||
<!-- 标题 -->
|
||||
<div class="sidebar-header" v-if="title">
|
||||
<h3 class="sidebar-title">{{ title }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content">
|
||||
<!-- 菜单列表 -->
|
||||
<nav class="sidebar-nav">
|
||||
<div
|
||||
v-for="menu in menus"
|
||||
:key="menu.menuID"
|
||||
:key="menu.menuID || menu.url"
|
||||
class="sidebar-item"
|
||||
:class="{ active: isActive(menu), 'has-children': hasChildren(menu) }"
|
||||
:class="{ active: isActive(menu) }"
|
||||
@click="handleClick(menu)"
|
||||
>
|
||||
<div class="sidebar-link" @click="handleClick(menu)">
|
||||
<div class="sidebar-link">
|
||||
<span class="link-text">{{ menu.name }}</span>
|
||||
<i v-if="hasChildren(menu)" class="arrow-icon">▼</i>
|
||||
</div>
|
||||
|
||||
<!-- 子菜单 -->
|
||||
<div v-if="hasChildren(menu) && isExpanded(menu)" class="sidebar-submenu">
|
||||
<div
|
||||
v-for="child in menu.children"
|
||||
:key="child.menuID"
|
||||
class="submenu-item"
|
||||
:class="{ active: isActive(child) }"
|
||||
@click="handleClick(child)"
|
||||
>
|
||||
<span class="submenu-text">{{ child.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- 折叠状态的图标 -->
|
||||
<div class="sidebar-icons" v-else>
|
||||
<div
|
||||
v-for="menu in menus"
|
||||
:key="menu.menuID"
|
||||
class="icon-item"
|
||||
:class="{ active: isActive(menu) }"
|
||||
:title="menu.name"
|
||||
@click="handleClick(menu)"
|
||||
>
|
||||
<span class="icon-text">{{ menu.name?.charAt(0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import type { SysMenu } from '@/types';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
menus: SysMenu[];
|
||||
collapsed?: boolean;
|
||||
title?: string;
|
||||
type?: 'nav' | 'sidebar';
|
||||
activePath?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
collapsed: false,
|
||||
type: 'sidebar'
|
||||
});
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'toggle': [];
|
||||
'menu-click': [menu: SysMenu];
|
||||
}>();
|
||||
|
||||
// 展开的菜单ID列表
|
||||
const expandedMenus = ref<Set<string>>(new Set());
|
||||
|
||||
// 检查菜单是否有子菜单
|
||||
function hasChildren(menu: SysMenu): boolean {
|
||||
return !!(menu.children && menu.children.length > 0);
|
||||
}
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
// 检查菜单是否激活
|
||||
function isActive(menu: SysMenu): boolean {
|
||||
if (props.activePath) {
|
||||
return props.activePath === menu.url;
|
||||
}
|
||||
if (!menu.url) return false;
|
||||
return props.activePath === menu.url;
|
||||
}
|
||||
|
||||
// 检查菜单是否展开
|
||||
function isExpanded(menu: SysMenu): boolean {
|
||||
return menu.menuID ? expandedMenus.value.has(menu.menuID) : false;
|
||||
return route.path === menu.url;
|
||||
}
|
||||
|
||||
// 处理点击
|
||||
function handleClick(menu: SysMenu) {
|
||||
if (hasChildren(menu)) {
|
||||
// 切换展开状态
|
||||
if (menu.menuID) {
|
||||
if (expandedMenus.value.has(menu.menuID)) {
|
||||
expandedMenus.value.delete(menu.menuID);
|
||||
} else {
|
||||
expandedMenus.value.add(menu.menuID);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 触发点击事件
|
||||
emit('menu-click', menu);
|
||||
|
||||
// 如果有URL,进行路由跳转
|
||||
if (menu.url) {
|
||||
router.push(menu.url);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.floating-sidebar {
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
width: 180px;
|
||||
background: #FFFFFF;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
|
||||
&.sidebar {
|
||||
width: 260px;
|
||||
|
||||
&.collapsed {
|
||||
width: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
&.nav {
|
||||
width: 200px;
|
||||
|
||||
&.collapsed {
|
||||
width: 56px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-toggle-btn {
|
||||
width: 24px;
|
||||
height: 48px;
|
||||
background: white;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 0 12px 12px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
z-index: 10;
|
||||
|
||||
&:hover {
|
||||
background: #f0f2f5;
|
||||
border-color: #C62828;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
font-style: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
@@ -178,35 +80,26 @@ function handleClick(menu: SysMenu) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 20px 16px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
.sidebar-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #141F38;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
padding: 20px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
|
||||
/* 滚动条样式 */
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: #f5f5f5;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #ddd;
|
||||
border-radius: 3px;
|
||||
border-radius: 2px;
|
||||
|
||||
&:hover {
|
||||
background: #bbb;
|
||||
@@ -215,143 +108,66 @@ function handleClick(menu: SysMenu) {
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
margin: 4px 8px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
height: 54px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 4px;
|
||||
height: 20px;
|
||||
background: transparent;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #fff1f0;
|
||||
&::before {
|
||||
background: #C62828;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
color: #C62828;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
.sidebar-link {
|
||||
color: #C62828;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
user-select: none;
|
||||
|
||||
.link-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
transition: transform 0.3s;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #C62828;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-submenu {
|
||||
background: #fafafa;
|
||||
margin: 0 8px 4px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.submenu-item {
|
||||
padding: 10px 16px 10px 32px;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background: #f0f0f0;
|
||||
color: #C62828;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #fff1f0;
|
||||
color: #C62828;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.submenu-text {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-icons {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16px 8px;
|
||||
gap: 8px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.icon-item {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
background: #f5f5f5;
|
||||
height: 100%;
|
||||
padding: 0 16px;
|
||||
color: #334155;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
font-family: 'PingFang SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
transition: color 0.2s;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #C62828;
|
||||
|
||||
.icon-text {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-text {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
user-select: none;
|
||||
.link-text {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.floating-sidebar {
|
||||
&.sidebar {
|
||||
width: 200px;
|
||||
|
||||
&.collapsed {
|
||||
width: 56px;
|
||||
}
|
||||
}
|
||||
|
||||
&.nav {
|
||||
width: 180px;
|
||||
|
||||
&.collapsed {
|
||||
width: 50px;
|
||||
}
|
||||
}
|
||||
width: 160px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -97,15 +97,15 @@ const navigationMenus = computed(() => {
|
||||
}
|
||||
return menu.type === MenuType.NAVIGATION;
|
||||
});
|
||||
console.log('导航菜单数据:', menus);
|
||||
menus.forEach((menu: SysMenu) => {
|
||||
console.log(`菜单 ${menu.name}:`, {
|
||||
menuID: menu.menuID,
|
||||
parentID: menu.parentID,
|
||||
children: menu.children,
|
||||
childrenCount: menu.children?.length || 0
|
||||
});
|
||||
});
|
||||
// console.log('导航菜单数据:', menus);
|
||||
// menus.forEach((menu: SysMenu) => {
|
||||
// console.log(`菜单 ${menu.name}:`, {
|
||||
// menuID: menu.menuID,
|
||||
// parentID: menu.parentID,
|
||||
// children: menu.children,
|
||||
// childrenCount: menu.children?.length || 0
|
||||
// });
|
||||
// });
|
||||
return menus;
|
||||
});
|
||||
|
||||
@@ -117,11 +117,11 @@ function hasNavigationChildren(menu: SysMenu): boolean {
|
||||
// 获取导航类型的子菜单
|
||||
function getNavigationChildren(menu: SysMenu): SysMenu[] {
|
||||
if (!menu.children) {
|
||||
console.log(`菜单 ${menu.name} 没有子菜单`);
|
||||
// console.log(`菜单 ${menu.name} 没有子菜单`);
|
||||
return [];
|
||||
}
|
||||
const children = menu.children.filter(child => child.type === MenuType.NAVIGATION);
|
||||
console.log(`菜单 ${menu.name} 的子菜单:`, children);
|
||||
// console.log(`菜单 ${menu.name} 的子菜单:`, children);
|
||||
return children;
|
||||
}
|
||||
|
||||
|
||||
358
schoolNewsWeb/src/components/file/FileUpload.vue
Normal file
358
schoolNewsWeb/src/components/file/FileUpload.vue
Normal file
@@ -0,0 +1,358 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div 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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose" :disabled="uploading">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleUpload"
|
||||
:loading="uploading"
|
||||
:disabled="selectedFiles.length === 0"
|
||||
>
|
||||
{{ uploading ? '上传中...' : '确定上传' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { ElDialog, ElButton, ElMessage } from 'element-plus';
|
||||
import { fileApi } from '@/apis/system/file';
|
||||
import type { SysFile } from '@/types';
|
||||
|
||||
interface Props {
|
||||
modelValue?: boolean;
|
||||
title?: string;
|
||||
accept?: string;
|
||||
maxSize?: number; // MB
|
||||
multiple?: boolean;
|
||||
module?: string;
|
||||
businessId?: string;
|
||||
tip?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
title: '上传文件',
|
||||
accept: '',
|
||||
maxSize: 10,
|
||||
multiple: false,
|
||||
module: 'common',
|
||||
tip: ''
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
'success': [files: SysFile[]];
|
||||
'error': [error: any];
|
||||
}>();
|
||||
|
||||
const fileInputRef = ref<HTMLInputElement>();
|
||||
const selectedFiles = ref<File[]>([]);
|
||||
const uploading = ref(false);
|
||||
const isDragover = ref(false);
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
});
|
||||
|
||||
// 点击上传区域
|
||||
function handleClickUpload() {
|
||||
if (uploading.value) return;
|
||||
fileInputRef.value?.click();
|
||||
}
|
||||
|
||||
// 文件选择
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
addFiles(Array.from(input.files));
|
||||
// 清空 input,允许重复选择同一文件
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 拖拽相关
|
||||
function handleDragOver() {
|
||||
isDragover.value = true;
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
isDragover.value = false;
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
isDragover.value = false;
|
||||
if (uploading.value) return;
|
||||
|
||||
const files = event.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
addFiles(Array.from(files));
|
||||
}
|
||||
}
|
||||
|
||||
// 添加文件
|
||||
function addFiles(files: File[]) {
|
||||
files.forEach(file => {
|
||||
// 验证文件大小
|
||||
if (file.size / 1024 / 1024 > props.maxSize) {
|
||||
ElMessage.error(`文件 ${file.name} 大小超过 ${props.maxSize}MB`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证文件类型
|
||||
if (props.accept && !isValidFileType(file, props.accept)) {
|
||||
ElMessage.error(`文件 ${file.name} 类型不符合要求`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否重复
|
||||
if (selectedFiles.value.some(f => f.name === file.name && f.size === file.size)) {
|
||||
ElMessage.warning(`文件 ${file.name} 已添加`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果不允许多选,清空之前的文件
|
||||
if (!props.multiple) {
|
||||
selectedFiles.value = [file];
|
||||
} else {
|
||||
selectedFiles.value.push(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 验证文件类型
|
||||
function isValidFileType(file: File, accept: string): boolean {
|
||||
if (!accept) return true;
|
||||
|
||||
const acceptTypes = accept.split(',').map(t => t.trim());
|
||||
return acceptTypes.some(type => {
|
||||
if (type.startsWith('.')) {
|
||||
return file.name.toLowerCase().endsWith(type.toLowerCase());
|
||||
} else if (type.endsWith('/*')) {
|
||||
return file.type.startsWith(type.replace('/*', ''));
|
||||
} else {
|
||||
return file.type === type;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 移除文件
|
||||
function removeFile(index: number) {
|
||||
selectedFiles.value.splice(index, 1);
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
async function handleUpload() {
|
||||
if (selectedFiles.value.length === 0) {
|
||||
ElMessage.warning('请选择文件');
|
||||
return;
|
||||
}
|
||||
|
||||
uploading.value = true;
|
||||
const uploadedFilesList: SysFile[] = [];
|
||||
|
||||
try {
|
||||
// 逐个上传文件
|
||||
for (const file of selectedFiles.value) {
|
||||
const result = await fileApi.uploadFile({
|
||||
file: file,
|
||||
module: props.module,
|
||||
businessId: props.businessId
|
||||
});
|
||||
|
||||
if (result.code === 200 && result.data) {
|
||||
uploadedFilesList.push(result.data);
|
||||
ElMessage.success(`${file.name} 上传成功`);
|
||||
} else {
|
||||
ElMessage.error(`${file.name} 上传失败: ${result.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 所有文件上传完成
|
||||
if (uploadedFilesList.length > 0) {
|
||||
emit('success', uploadedFilesList);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('上传失败:', error);
|
||||
ElMessage.error('上传失败: ' + (error.message || '未知错误'));
|
||||
emit('error', error);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
// 上传完成后关闭对话框
|
||||
if (uploadedFilesList.length > 0) {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
function handleClose() {
|
||||
visible.value = false;
|
||||
selectedFiles.value = [];
|
||||
isDragover.value = false;
|
||||
uploading.value = false;
|
||||
}
|
||||
|
||||
// 打开对话框
|
||||
function open() {
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.upload-container {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
border: 2px dashed #dcdfe6;
|
||||
border-radius: 8px;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
background: #fafafa;
|
||||
|
||||
&:hover {
|
||||
border-color: #409eff;
|
||||
background: #f0f9ff;
|
||||
}
|
||||
|
||||
&.is-dragover {
|
||||
border-color: #409eff;
|
||||
background: #e6f7ff;
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 67px;
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.link-text {
|
||||
color: #409eff;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-tip {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
margin-top: 20px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
</style>
|
||||
2
schoolNewsWeb/src/components/file/index.ts
Normal file
2
schoolNewsWeb/src/components/file/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as FileUpload } from './FileUpload.vue';
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
// 导出 base 基础组件
|
||||
export * from './base';
|
||||
|
||||
// 导出 text 富文本组件
|
||||
export * from './text';
|
||||
|
||||
// 导出 file 文件组件
|
||||
export * from './file';
|
||||
|
||||
|
||||
187
schoolNewsWeb/src/components/text/README.md
Normal file
187
schoolNewsWeb/src/components/text/README.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# RichTextComponent - 富文本编辑器组件
|
||||
|
||||
基于 Quill 的 Vue 3 富文本编辑器组件。
|
||||
|
||||
## 安装依赖
|
||||
|
||||
首先需要安装 Quill 依赖:
|
||||
|
||||
```bash
|
||||
npm install quill
|
||||
# 或
|
||||
yarn add quill
|
||||
```
|
||||
|
||||
## 基础使用
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<RichTextComponent
|
||||
v-model="content"
|
||||
placeholder="请输入内容..."
|
||||
height="300px"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { RichTextComponent } from '@/components/text';
|
||||
|
||||
const content = ref('<p>初始内容</p>');
|
||||
</script>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| modelValue | string | '' | 绑定值(HTML格式) |
|
||||
| placeholder | string | '请输入内容...' | 占位文本 |
|
||||
| height | string | '300px' | 编辑器高度 |
|
||||
| disabled | boolean | false | 是否禁用 |
|
||||
| readOnly | boolean | false | 是否只读 |
|
||||
| maxLength | number | 0 | 最大字数限制(0表示无限制) |
|
||||
| showWordCount | boolean | false | 是否显示字数统计 |
|
||||
| error | boolean | false | 是否显示错误状态 |
|
||||
| errorMessage | string | '' | 错误提示文本 |
|
||||
|
||||
## Events
|
||||
|
||||
| 事件名 | 参数 | 说明 |
|
||||
|--------|------|------|
|
||||
| update:modelValue | (value: string) | 内容变化时触发 |
|
||||
| change | (value: string) | 内容变化时触发 |
|
||||
| blur | () | 失去焦点时触发 |
|
||||
| focus | () | 获得焦点时触发 |
|
||||
|
||||
## 方法
|
||||
|
||||
通过 ref 可以调用以下方法:
|
||||
|
||||
| 方法名 | 参数 | 返回值 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| getText | - | string | 获取纯文本内容 |
|
||||
| getHTML | - | string | 获取HTML内容 |
|
||||
| clear | - | void | 清空内容 |
|
||||
| setContent | (content: string) | void | 设置内容 |
|
||||
| focus | - | void | 聚焦编辑器 |
|
||||
| blur | - | void | 失焦编辑器 |
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 带字数统计
|
||||
|
||||
```vue
|
||||
<RichTextComponent
|
||||
v-model="content"
|
||||
:max-length="500"
|
||||
show-word-count
|
||||
placeholder="最多输入500字..."
|
||||
/>
|
||||
```
|
||||
|
||||
### 只读模式
|
||||
|
||||
```vue
|
||||
<RichTextComponent
|
||||
v-model="content"
|
||||
read-only
|
||||
/>
|
||||
```
|
||||
|
||||
### 禁用状态
|
||||
|
||||
```vue
|
||||
<RichTextComponent
|
||||
v-model="content"
|
||||
disabled
|
||||
/>
|
||||
```
|
||||
|
||||
### 错误状态
|
||||
|
||||
```vue
|
||||
<RichTextComponent
|
||||
v-model="content"
|
||||
error
|
||||
error-message="内容不能为空"
|
||||
/>
|
||||
```
|
||||
|
||||
### 使用 ref 调用方法
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<RichTextComponent ref="editorRef" v-model="content" />
|
||||
<button @click="handleGetText">获取文本</button>
|
||||
<button @click="handleClear">清空</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { RichTextComponent } from '@/components/text';
|
||||
|
||||
const editorRef = ref();
|
||||
const content = ref('');
|
||||
|
||||
function handleGetText() {
|
||||
const text = editorRef.value?.getText();
|
||||
console.log('纯文本:', text);
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
editorRef.value?.clear();
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 富文本功能
|
||||
|
||||
- ✅ 标题(H1-H3)
|
||||
- ✅ 字体大小
|
||||
- ✅ 加粗、斜体、下划线、删除线
|
||||
- ✅ 文字颜色、背景颜色
|
||||
- ✅ 有序列表、无序列表
|
||||
- ✅ 对齐方式(左、中、右、两端对齐)
|
||||
- ✅ 插入链接、图片、视频
|
||||
- ✅ 代码块、引用
|
||||
- ✅ 清除格式
|
||||
|
||||
### 其他特性
|
||||
|
||||
- ✅ 字数统计
|
||||
- ✅ 字数限制
|
||||
- ✅ 只读模式
|
||||
- ✅ 禁用状态
|
||||
- ✅ 错误状态显示
|
||||
- ✅ 自定义高度
|
||||
- ✅ 响应式设计
|
||||
|
||||
## 样式定制
|
||||
|
||||
组件使用了 Quill 的 Snow 主题,并进行了一些定制。如需进一步定制样式,可以通过以下方式:
|
||||
|
||||
```scss
|
||||
// 在你的样式文件中
|
||||
:deep(.ql-editor) {
|
||||
// 定制编辑器内容区域样式
|
||||
font-family: '你的字体';
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
:deep(.ql-toolbar) {
|
||||
// 定制工具栏样式
|
||||
background: #your-color;
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 确保已安装 `quill` 依赖
|
||||
2. 组件导入了 Quill 的样式文件,无需额外导入
|
||||
3. v-model 绑定的是 HTML 格式的内容
|
||||
4. 如需纯文本,使用 `getText()` 方法
|
||||
5. 图片上传需要配置 Quill 的图片处理器(未来版本会添加)
|
||||
|
||||
605
schoolNewsWeb/src/components/text/RichTextComponent.vue
Normal file
605
schoolNewsWeb/src/components/text/RichTextComponent.vue
Normal file
@@ -0,0 +1,605 @@
|
||||
<template>
|
||||
<div class="rich-text-component">
|
||||
<div class="rich-text-editor" :class="{ 'is-disabled': disabled, 'is-error': error }">
|
||||
<!-- 工具栏 -->
|
||||
<div ref="toolbarRef" class="editor-toolbar">
|
||||
<!-- 字体样式 -->
|
||||
<span class="ql-formats">
|
||||
<select class="ql-header" title="标题">
|
||||
<option value="1">标题1</option>
|
||||
<option value="2">标题2</option>
|
||||
<option value="3">标题3</option>
|
||||
<option value="">正文</option>
|
||||
</select>
|
||||
|
||||
<!-- 字体大小 -->
|
||||
<select class="ql-size" title="字体大小">
|
||||
<option value="small">小</option>
|
||||
<option selected>正常</option>
|
||||
<option value="large">大</option>
|
||||
<option value="huge">特大</option>
|
||||
</select>
|
||||
</span>
|
||||
|
||||
<!-- 加粗、斜体、下划线 -->
|
||||
<span class="ql-formats">
|
||||
<button type="button" class="ql-bold" title="加粗"></button>
|
||||
<button type="button" class="ql-italic" title="斜体"></button>
|
||||
<button type="button" class="ql-underline" title="下划线"></button>
|
||||
<button type="button" class="ql-strike" title="删除线"></button>
|
||||
<button type="button" class="ql-code" title="行内代码"></button>
|
||||
</span>
|
||||
|
||||
<!-- 文字颜色和背景色 -->
|
||||
<span class="ql-formats">
|
||||
<select class="ql-color" title="文字颜色"></select>
|
||||
<select class="ql-background" title="背景颜色"></select>
|
||||
</span>
|
||||
|
||||
<!-- 列表 -->
|
||||
<span class="ql-formats">
|
||||
<button type="button" class="ql-list" value="ordered" title="有序列表"></button>
|
||||
<button type="button" class="ql-list" value="bullet" title="无序列表"></button>
|
||||
</span>
|
||||
|
||||
<!-- 对齐方式 -->
|
||||
<span class="ql-formats">
|
||||
<select class="ql-align" title="对齐方式">
|
||||
<option selected></option>
|
||||
<option value="center">居中</option>
|
||||
<option value="right">右对齐</option>
|
||||
<option value="justify">两端对齐</option>
|
||||
</select>
|
||||
</span>
|
||||
|
||||
<!-- 链接、图片、视频 -->
|
||||
<span class="ql-formats">
|
||||
<button type="button" class="ql-link" title="插入链接"></button>
|
||||
<button type="button" class="ql-image" title="插入图片"></button>
|
||||
<button type="button" class="ql-video" title="插入视频"></button>
|
||||
</span>
|
||||
|
||||
<!-- 代码块、引用 -->
|
||||
<span class="ql-formats">
|
||||
<button type="button" class="ql-code-block" title="代码块"></button>
|
||||
<button type="button" class="ql-blockquote" title="引用"></button>
|
||||
</span>
|
||||
|
||||
<!-- 清除格式 -->
|
||||
<span class="ql-formats">
|
||||
<button type="button" class="ql-clean" title="清除格式"></button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 编辑器内容区域 -->
|
||||
<div
|
||||
ref="editorRef"
|
||||
class="editor-content"
|
||||
:style="{ height: height }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- 字符统计 -->
|
||||
<div v-if="showWordCount" class="word-count">
|
||||
字数:{{ wordCount }} / {{ maxLength || '无限制' }}
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div v-if="error" class="error-message">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- 文件上传对话框 -->
|
||||
<FileUpload
|
||||
v-model="uploadDialogVisible"
|
||||
:title="uploadType === 'image' ? '上传图片' : '上传视频'"
|
||||
:accept="uploadType === 'image' ? 'image/*' : 'video/*'"
|
||||
:module="uploadModule"
|
||||
:max-size="uploadType === 'image' ? 5 : 100"
|
||||
@success="handleUploadSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue';
|
||||
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';
|
||||
// Quill 样式已在 main.ts 中全局引入
|
||||
|
||||
interface Props {
|
||||
modelValue?: string;
|
||||
placeholder?: string;
|
||||
height?: string;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
maxLength?: number;
|
||||
showWordCount?: boolean;
|
||||
error?: boolean;
|
||||
errorMessage?: string;
|
||||
uploadModule?: string; // 上传文件的模块名
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
placeholder: '请输入内容...',
|
||||
height: '300px',
|
||||
disabled: false,
|
||||
readOnly: false,
|
||||
maxLength: 0,
|
||||
showWordCount: false,
|
||||
error: false,
|
||||
errorMessage: '',
|
||||
uploadModule: 'article'
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
'change': [value: string];
|
||||
'blur': [];
|
||||
'focus': [];
|
||||
}>();
|
||||
|
||||
const toolbarRef = ref<HTMLElement>();
|
||||
const editorRef = ref<HTMLElement>();
|
||||
let quillInstance: Quill | null = null;
|
||||
|
||||
// 文件上传相关
|
||||
const uploadDialogVisible = ref(false);
|
||||
const uploadType = ref<'image' | 'video'>('image');
|
||||
const uploadModule = computed(() => props.uploadModule);
|
||||
let currentUploadRange: any = null; // 保存当前光标位置
|
||||
|
||||
// 字符统计
|
||||
const wordCount = computed(() => {
|
||||
if (!quillInstance) return 0;
|
||||
return quillInstance.getText().trim().length;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
initQuill();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (quillInstance) {
|
||||
quillInstance = null;
|
||||
}
|
||||
});
|
||||
|
||||
// 监听外部值变化
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
if (quillInstance && newValue !== quillInstance.root.innerHTML) {
|
||||
quillInstance.root.innerHTML = newValue;
|
||||
}
|
||||
});
|
||||
|
||||
// 监听禁用状态
|
||||
watch(() => props.disabled, (newValue) => {
|
||||
if (quillInstance) {
|
||||
quillInstance.enable(!newValue);
|
||||
}
|
||||
});
|
||||
|
||||
// 监听只读状态
|
||||
watch(() => props.readOnly, (newValue) => {
|
||||
if (quillInstance) {
|
||||
quillInstance.enable(!newValue);
|
||||
}
|
||||
});
|
||||
|
||||
function initQuill() {
|
||||
if (!editorRef.value || !toolbarRef.value) return;
|
||||
|
||||
// 自定义视频 Blot(支持本地视频文件)
|
||||
const BlockEmbed: any = Quill.import('blots/block/embed');
|
||||
|
||||
class VideoBlot extends BlockEmbed {
|
||||
static blotName = 'customVideo';
|
||||
static tagName = 'video';
|
||||
|
||||
static create(value: string) {
|
||||
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;');
|
||||
return node;
|
||||
}
|
||||
|
||||
static value(node: HTMLVideoElement) {
|
||||
return node.getAttribute('src');
|
||||
}
|
||||
}
|
||||
|
||||
Quill.register(VideoBlot);
|
||||
|
||||
// 配置选项
|
||||
const options = {
|
||||
theme: 'snow',
|
||||
modules: {
|
||||
toolbar: {
|
||||
// 1. 指定工具栏 DOM 容器
|
||||
container: toolbarRef.value,
|
||||
// 2. 自定义处理器
|
||||
handlers: {
|
||||
// 自定义图片上传处理器
|
||||
image: function() {
|
||||
handleImageUpload();
|
||||
},
|
||||
// 自定义视频上传处理器
|
||||
video: function() {
|
||||
handleVideoUpload();
|
||||
},
|
||||
// 清除格式处理器
|
||||
clean: function() {
|
||||
if (quillInstance) {
|
||||
const range = quillInstance.getSelection();
|
||||
if (range) {
|
||||
quillInstance.removeFormat(range.index, range.length);
|
||||
} else {
|
||||
quillInstance.removeFormat(0, quillInstance.getLength());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
clipboard: {
|
||||
matchVisual: false
|
||||
},
|
||||
// 启用图片/视频缩放模块
|
||||
imageResize: ImageResize
|
||||
},
|
||||
placeholder: props.placeholder,
|
||||
readOnly: props.readOnly || props.disabled
|
||||
};
|
||||
|
||||
// 创建编辑器实例
|
||||
quillInstance = new Quill(editorRef.value, options);
|
||||
|
||||
// 设置初始内容
|
||||
if (props.modelValue) {
|
||||
quillInstance.root.innerHTML = props.modelValue;
|
||||
}
|
||||
|
||||
// 监听内容变化
|
||||
quillInstance.on('text-change', () => {
|
||||
if (!quillInstance) return;
|
||||
|
||||
const html = quillInstance.root.innerHTML;
|
||||
const text = quillInstance.getText().trim();
|
||||
|
||||
// 检查字数限制
|
||||
if (props.maxLength && text.length > props.maxLength) {
|
||||
quillInstance.deleteText(props.maxLength, text.length);
|
||||
return;
|
||||
}
|
||||
|
||||
emit('update:modelValue', html);
|
||||
emit('change', html);
|
||||
});
|
||||
|
||||
// 监听图片/视频尺寸变化
|
||||
quillInstance.root.addEventListener('input', () => {
|
||||
console.log('📝 input 事件触发,更新内容');
|
||||
if (!quillInstance) return;
|
||||
|
||||
const html = quillInstance.root.innerHTML;
|
||||
emit('update:modelValue', html);
|
||||
emit('change', html);
|
||||
});
|
||||
|
||||
// 监听焦点事件
|
||||
quillInstance.on('selection-change', (range: any) => {
|
||||
if (range) {
|
||||
emit('focus');
|
||||
} else {
|
||||
emit('blur');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 获取纯文本内容
|
||||
function getText(): string {
|
||||
return quillInstance?.getText() || '';
|
||||
}
|
||||
|
||||
// 获取HTML内容
|
||||
function getHTML(): string {
|
||||
return quillInstance?.root.innerHTML || '';
|
||||
}
|
||||
|
||||
// 清空内容
|
||||
function clear(): void {
|
||||
quillInstance?.setText('');
|
||||
}
|
||||
|
||||
// 设置内容
|
||||
function setContent(content: string): void {
|
||||
if (quillInstance) {
|
||||
quillInstance.root.innerHTML = content;
|
||||
}
|
||||
}
|
||||
|
||||
// 聚焦
|
||||
function focus(): void {
|
||||
quillInstance?.focus();
|
||||
}
|
||||
|
||||
// 失焦
|
||||
function blur(): void {
|
||||
quillInstance?.blur();
|
||||
}
|
||||
|
||||
// 处理图片上传
|
||||
function handleImageUpload() {
|
||||
if (!quillInstance) return;
|
||||
|
||||
// 保存当前光标位置
|
||||
currentUploadRange = quillInstance.getSelection();
|
||||
|
||||
// 设置上传类型并打开上传对话框
|
||||
uploadType.value = 'image';
|
||||
uploadDialogVisible.value = true;
|
||||
}
|
||||
|
||||
// 处理视频上传
|
||||
function handleVideoUpload() {
|
||||
if (!quillInstance) return;
|
||||
|
||||
// 保存当前光标位置
|
||||
currentUploadRange = quillInstance.getSelection();
|
||||
|
||||
// 设置上传类型并打开上传对话框
|
||||
uploadType.value = 'video';
|
||||
uploadDialogVisible.value = true;
|
||||
}
|
||||
|
||||
// 处理上传成功
|
||||
function handleUploadSuccess(files: SysFile[]) {
|
||||
if (!quillInstance || !files || files.length === 0) return;
|
||||
|
||||
files.forEach(file => {
|
||||
// 拼接下载URL
|
||||
const downloadUrl = FILE_DOWNLOAD_URL + file.fileID;
|
||||
|
||||
// 获取插入位置(使用保存的光标位置,如果没有则使用当前光标)
|
||||
const range = currentUploadRange || quillInstance!.getSelection() || { index: quillInstance!.getLength() };
|
||||
|
||||
// 根据类型插入内容
|
||||
if (uploadType.value === 'image') {
|
||||
// 插入图片
|
||||
quillInstance!.insertEmbed(range.index, 'image', downloadUrl);
|
||||
// 移动光标到图片后面
|
||||
quillInstance!.setSelection(range.index + 1);
|
||||
} else if (uploadType.value === 'video') {
|
||||
// 插入自定义视频(使用 customVideo 而不是默认的 video)
|
||||
quillInstance!.insertEmbed(range.index, 'customVideo', downloadUrl);
|
||||
// 移动光标到视频后面
|
||||
quillInstance!.setSelection(range.index + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// 清空光标位置
|
||||
currentUploadRange = null;
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
getText,
|
||||
getHTML,
|
||||
clear,
|
||||
setContent,
|
||||
focus,
|
||||
blur
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
// 富文本内容样式(全局,可复用)
|
||||
.rich-text-content,
|
||||
.ql-editor {
|
||||
line-height: 1.8;
|
||||
color: #303133;
|
||||
|
||||
p {
|
||||
margin: 0 0 12px 0;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin: 16px 0 12px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
padding-left: 1.5em;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 4px solid #C62828;
|
||||
padding-left: 16px;
|
||||
margin: 12px 0;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
// 代码块样式
|
||||
pre {
|
||||
background: #282c34;
|
||||
color: #abb2bf;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 12px 0;
|
||||
|
||||
code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
// 行内代码样式(不在 pre 中的 code)
|
||||
code:not(pre code) {
|
||||
background: #f5f7fa;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
color: #e83e8c;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #C62828;
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
color: #A82020;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
// 视频容器样式
|
||||
iframe, video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 12px auto; // 默认居中
|
||||
}
|
||||
|
||||
// Quill 视频包装器
|
||||
.ql-video {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
// 支持对齐方式
|
||||
.ql-align-center {
|
||||
text-align: center;
|
||||
|
||||
iframe, video, .ql-video {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.ql-align-right {
|
||||
text-align: right;
|
||||
|
||||
iframe, video, .ql-video {
|
||||
margin-left: auto;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ql-align-left {
|
||||
text-align: left;
|
||||
|
||||
iframe, video, .ql-video {
|
||||
margin-left: 0;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.ql-align-justify {
|
||||
text-align: justify;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.rich-text-component {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rich-text-editor {
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #c0c4cc;
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
background-color: #f5f7fa;
|
||||
cursor: not-allowed;
|
||||
|
||||
:deep(.ql-toolbar),
|
||||
:deep(.ql-container) {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-error {
|
||||
border-color: #f56c6c;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
border-bottom: 1px solid #dcdfe6;
|
||||
background: #fafafa;
|
||||
border-radius: 4px 4px 0 0;
|
||||
padding: 8px;
|
||||
|
||||
:deep(.ql-formats) {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
:deep(button) {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin: 2px;
|
||||
|
||||
&:hover {
|
||||
color: #C62828;
|
||||
}
|
||||
|
||||
&.ql-active {
|
||||
color: #C62828;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(select) {
|
||||
margin: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
border-radius: 0 0 4px 4px;
|
||||
|
||||
:deep(.ql-editor) {
|
||||
min-height: 100%;
|
||||
font-size: 14px;
|
||||
|
||||
&.ql-blank::before {
|
||||
color: #c0c4cc;
|
||||
font-style: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.word-count {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #f56c6c;
|
||||
}
|
||||
</style>
|
||||
|
||||
161
schoolNewsWeb/src/components/text/RichTextExample.vue
Normal file
161
schoolNewsWeb/src/components/text/RichTextExample.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<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>
|
||||
|
||||
2
schoolNewsWeb/src/components/text/index.ts
Normal file
2
schoolNewsWeb/src/components/text/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as RichTextComponent } from './RichTextComponent.vue';
|
||||
|
||||
Reference in New Issue
Block a user