web-权限、文章

This commit is contained in:
2025-10-18 18:19:19 +08:00
parent b3424e554f
commit ccc1d6338b
35 changed files with 3314 additions and 463 deletions

View 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 的图片处理器(未来版本会添加)

View 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>

View 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>

View File

@@ -0,0 +1,2 @@
export { default as RichTextComponent } from './RichTextComponent.vue';