前端实现 页面富文本编辑器

富文本编辑器实现效果

该富文本组件支持的内容如上图标签所示

vue3+ts+elementplus组件封装

所需依赖

复制代码
"@tiptap/extension-image": "^3.25.0",
"@tiptap/extension-link": "^3.25.0",
"@tiptap/extension-placeholder": "^3.25.0",
"@tiptap/extension-table": "^3.25.0",
"@tiptap/extension-table-cell": "^3.25.0",
"@tiptap/extension-table-header": "^3.25.0",
"@tiptap/extension-table-row": "^3.25.0",
"@tiptap/extension-underline": "^3.25.0",
"@tiptap/starter-kit": "^3.25.0",
"@tiptap/vue-3": "^3.25.0",

涉及的文本处理方法

javascript 复制代码
/** 判断富文本 HTML 是否无实际文本内容 */
export const isRichTextEmpty = (html?: string): boolean => {
  if (!html?.trim()) return true
  if (typeof document !== 'undefined') {
    const div = document.createElement('div')
    div.innerHTML = html
    const text = (div.textContent || div.innerText || '').replace(/\u00a0/g, ' ').trim()
    return !text
  }
  const text = html
    .replace(/<[^>]+>/g, '')
    .replace(/&nbsp;/gi, ' ')
    .replace(/\s+/g, ' ')
    .trim()
  return !text
}

/** 内容是否包含 HTML 标签 */
export const isHtmlContent = (content?: string): boolean => /<[^>]+>/.test(content || '')

/** 提交前规范化富文本(空内容返回空字符串) */
export const normalizeRichTextForSubmit = (html?: string): string => {
  if (isRichTextEmpty(html)) return ''
  return html?.trim() || ''
}

/** 富文本 HTML 转纯文本 */
export const richTextToPlain = (html?: string): string => {
  if (!html?.trim()) return ''
  if (typeof document !== 'undefined') {
    const div = document.createElement('div')
    div.innerHTML = html
    return (div.textContent || div.innerText || '').replace(/\u00a0/g, ' ').trim()
  }
  return html
    .replace(/<[^>]+>/g, '')
    .replace(/&nbsp;/gi, ' ')
    .replace(/\s+/g, ' ')
    .trim()
}

组件使用方法

配置项

javascript 复制代码
Prop          默认值    说明 
enableHistory true 撤销/重做 
enableHeading true 标题选择 
enableBold true 粗体 
enableItalic true 斜体 
enableUnderline true 下划线 
enableStrike true 删除线 
enableCode true 行内代码 
enableBulletList true 无序列表 
enableOrderedList true 有序列表 
enableBlockquote true 引用 
enableCodeBlock true 代码块 
enableLink true 链接 
enableHorizontalRule true 分隔线 
enableClearFormat true 清除格式 
enableImage false 图片(需显式启用) 
enableTable false 表格(需显式启用)
javascript 复制代码
<RichTextEditor
            v-model="form.reasonDescription"
            placeholder="请输入原因描述"
            :min-height="200"
            :enable-image="true"
            :enable-table="true"
            :enable-code="false"
            :enable-blockquote="false"
            :enable-code-block="false"
            :enable-link="false"
          />

33

组件源码

javascript 复制代码
<template>
  <el-form
    ref="formRef"
    :model="form"
    :rules="rules"
    label-width="180px"
    class="fcr-form"
    :validate-on-rule-change="false"
  >
    <el-row :gutter="20">
      <el-col :span="12">
        <el-form-item label="FCR编号">
          <el-input :model-value="'待生成'" disabled />
        </el-form-item>
      </el-col>
      <el-col :span="12">
        <el-form-item label="版次 Rev." prop="revision">
          <el-input v-model="form.revision" placeholder="请输入版次" maxlength="10" />
        </el-form-item>
      </el-col>
    </el-row>

    <el-row :gutter="20">
      <el-col :span="24">
        <el-form-item label="主题 Subject" prop="subject">
          <el-input v-model="form.subject" placeholder="请输入主题" maxlength="500" />
        </el-form-item>
      </el-col>
    </el-row>

    <el-row :gutter="20">
      <el-col :span="12">
        <el-form-item label="发起单位 Issue Institution" prop="issueInstitution">
          <el-input v-model="form.issueInstitution" placeholder="请输入发起单位" maxlength="200" />
        </el-form-item>
      </el-col>
      <el-col :span="12">
        <el-form-item label="项目名称 Project Name" prop="projectId">
          <el-select
            v-model="form.projectId"
            placeholder="请选择项目"
            filterable
            remote
            clearable
            :remote-method="searchProject"
            :loading="projectLoading"
            style="width: 100%"
            @change="handleProjectChange"
          >
            <el-option
              v-for="item in projectOptions"
              :key="item.value"
              :label="item.label"
              :value="item.value"
            />
          </el-select>
        </el-form-item>
      </el-col>
    </el-row>

    <el-row :gutter="20">
      <el-col :span="12">
        <el-form-item label="机组 Unit" prop="unitId">
          <el-select
            v-model="form.unitId"
            placeholder="请选择机组"
            filterable
            clearable
            :disabled="!form.projectId"
            style="width: 100%"
            @change="handleUnitChange"
          >
            <el-option
              v-for="item in unitOptions"
              :key="item.value"
              :label="item.label"
              :value="item.value"
            />
          </el-select>
        </el-form-item>
      </el-col>
      <el-col :span="12">
        <el-form-item label="系统 System" prop="system">
          <el-input v-model="form.system" placeholder="请输入系统" maxlength="100" />
        </el-form-item>
      </el-col>
    </el-row>

    <el-row :gutter="20">
      <el-col :span="12">
        <el-form-item label="设备位号 Equipment code" prop="equipmentCode">
          <el-input v-model="form.equipmentCode" placeholder="请输入设备位号" maxlength="100" />
        </el-form-item>
      </el-col>
      <el-col :span="12">
        <el-form-item label="厂房 Building" prop="building">
          <el-input v-model="form.building" placeholder="请输入厂房" maxlength="100" />
        </el-form-item>
      </el-col>
    </el-row>

    <el-row :gutter="20">
      <el-col :span="12">
        <el-form-item label="区域 Area" prop="area">
          <el-input v-model="form.area" placeholder="请输入区域" maxlength="100" />
        </el-form-item>
      </el-col>
      <el-col :span="12">
        <el-form-item label="房间 Room" prop="room">
          <el-input v-model="form.room" placeholder="请输入房间" maxlength="100" />
        </el-form-item>
      </el-col>
    </el-row>

    <el-row :gutter="20">
      <el-col :span="12">
        <el-form-item label="层位 Level" prop="level">
          <el-input v-model="form.level" placeholder="请输入层位" maxlength="100" />
        </el-form-item>
      </el-col>
      <el-col :span="12">
        <el-form-item label="专业 Discipline" prop="discipline">
          <el-input v-model="form.discipline" placeholder="请输入专业" maxlength="100" />
        </el-form-item>
      </el-col>
    </el-row>

    <el-row :gutter="20">
      <el-col :span="12">
        <el-form-item label="类别 Classification" prop="classification">
          <el-select
            v-model="form.classification"
            placeholder="请选择类别"
            clearable
            style="width: 100%"
          >
            <el-option label="设计" value="设计" />
            <el-option label="采购" value="采购" />
            <el-option label="施工" value="施工" />
            <el-option label="调试" value="调试" />
          </el-select>
        </el-form-item>
      </el-col>
      <el-col :span="12">
        <el-form-item label="页数 Pages" prop="pages">
          <el-input v-model="form.pages" placeholder="如: 3+2附件" maxlength="50" />
        </el-form-item>
      </el-col>
    </el-row>

    <el-row :gutter="20">
      <el-col :span="12">
        <el-form-item label="发布日期 Issue date" prop="issueDate">
          <el-date-picker
            v-model="form.issueDate"
            type="date"
            placeholder="请选择发布日期"
            value-format="YYYY-MM-DD"
            format="YYYY/M/D"
            clearable
            style="width: 100%"
          />
        </el-form-item>
      </el-col>
      <el-col :span="12">
        <el-form-item label="责任方代码 Responsible code" prop="responsibleCode">
          <el-input v-model="form.responsibleCode" placeholder="请输入责任方代码" maxlength="50" />
        </el-form-item>
      </el-col>
    </el-row>

    <el-row :gutter="20">
      <el-col :span="24">
        <el-form-item label="相关文件 Document Concerned" prop="documentConcerned">
          <el-input
            v-model="form.documentConcerned"
            type="textarea"
            :rows="4"
            placeholder="请输入相关文件"
            maxlength="2000"
            show-word-limit
          />
        </el-form-item>
      </el-col>
    </el-row>

    <el-row :gutter="20">
      <el-col :span="24">
        <el-form-item label="变更原因 Reason" prop="reasons">
          <el-checkbox-group v-model="form.reasons">
            <el-checkbox label="为适应现场特殊的设备" value="specialEquipment" />
            <el-checkbox label="为适应现场特殊的材料" value="specialMaterial" />
            <el-checkbox label="为适应现场特殊的环境条件" value="specialEnvironment" />
            <el-checkbox label="其它" value="other" />
          </el-checkbox-group>
        </el-form-item>
      </el-col>
    </el-row>

    <el-row :gutter="20">
      <el-col :span="24">
        <el-form-item label="责任方 Responsible" prop="responsible">
          <el-checkbox-group v-model="form.responsible">
            <el-checkbox label="提出单位" value="proposingUnit" />
            <el-checkbox label="相关建安承包商 Civil/Erection Contractor" value="civilContractor" />
            <el-checkbox label="设备供应商 Equip-supplier" value="equipmentSupplier" />
            <el-checkbox label="设计分包院 Sub-design institute" value="subDesignInstitute" />
            <el-checkbox label="CNPE" value="cnpe" />
            <el-checkbox label="业主 Purchaser" value="purchaser" />
            <el-checkbox label="其它 Other" value="otherResponsible" />
          </el-checkbox-group>
        </el-form-item>
      </el-col>
    </el-row>

    <el-row :gutter="20">
      <el-col :span="12">
        <el-form-item label="是否产生费用 Cost" prop="cost">
          <el-select v-model="form.cost" placeholder="请选择" clearable style="width: 100%">
            <el-option label="是" value="是" />
            <el-option label="否" value="否" />
          </el-select>
        </el-form-item>
      </el-col>
      <el-col :span="12">
        <el-form-item label="金额 Amount" prop="amount">
          <el-input v-model="form.amount" placeholder="请输入金额" maxlength="50">
            <template #append>万元</template>
          </el-input>
        </el-form-item>
      </el-col>
    </el-row>

    <el-row :gutter="20">
      <el-col :span="12">
        <el-form-item label="是否源于CR单" prop="fromCr">
          <el-select v-model="form.fromCr" placeholder="请选择" clearable style="width: 100%">
            <el-option label="是" value="是" />
            <el-option label="否" value="否" />
          </el-select>
        </el-form-item>
      </el-col>
      <el-col :span="12">
        <el-form-item label="对应CR单编号" prop="crNo">
          <el-input v-model="form.crNo" placeholder="CR-" maxlength="100" />
        </el-form-item>
      </el-col>
    </el-row>

    <el-row :gutter="20">
      <el-col :span="24">
        <el-form-item label="原因描述 DESC" prop="reasonDescription">
          <RichTextEditor
            v-model="form.reasonDescription"
            placeholder="请输入原因描述"
            :min-height="200"
            :enable-image="true"
            :enable-table="true"
            :enable-code="false"
            :enable-blockquote="false"
            :enable-code-block="false"
            :enable-link="false"
          />
        </el-form-item>
      </el-col>
    </el-row>

    <el-row :gutter="20">
      <el-col :span="24">
        <el-form-item label="变更方案 Scheme" prop="changeScheme">
          <RichTextEditor
            v-model="form.changeScheme"
            placeholder="请输入变更方案"
            :min-height="200"
            :enable-image="true"
            :enable-table="true"
            :enable-code="false"
            :enable-blockquote="false"
            :enable-code-block="false"
            :enable-link="false"
          />
        </el-form-item>
      </el-col>
    </el-row>

    <el-row :gutter="20">
      <el-col :span="24">
        <el-form-item label="材料替换 Material replacement" prop="materialReplacement">
          <el-input
            v-model="form.materialReplacement"
            type="textarea"
            :rows="4"
            placeholder="须提供相应的变更分析材料,并说明技术影响"
            maxlength="2000"
            show-word-limit
          />
        </el-form-item>
      </el-col>
    </el-row>

    <el-row :gutter="20">
      <el-col :span="24">
        <el-form-item label="临时措施 Temporary measure" prop="temporaryMeasure">
          <el-input
            v-model="form.temporaryMeasure"
            type="textarea"
            :rows="4"
            placeholder="N/A"
            maxlength="2000"
            show-word-limit
          />
        </el-form-item>
      </el-col>
    </el-row>

    <el-row :gutter="20">
      <el-col :span="12">
        <el-form-item label="计划开始时间" prop="planStartDate">
          <el-date-picker
            v-model="form.planStartDate"
            type="date"
            placeholder="请选择计划开始时间"
            value-format="YYYY-MM-DD"
            format="YYYY/M/D"
            clearable
            style="width: 100%"
          />
        </el-form-item>
      </el-col>
      <el-col :span="12">
        <el-form-item label="计划完工时间" prop="planFinishDate">
          <el-date-picker
            v-model="form.planFinishDate"
            type="date"
            placeholder="请选择计划完工时间"
            value-format="YYYY-MM-DD"
            format="YYYY/M/D"
            clearable
            style="width: 100%"
          />
        </el-form-item>
      </el-col>
    </el-row>
  </el-form>
</template>

<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { getProjectList, getProjectDetail } from '@/api/basic-data/project'
import { extractItemsByType, findNodeByItemCode } from '@/utils/index'
import RichTextEditor from '@/components/rich-text-editor/index.vue'

/** FCR表单数据模型 */
export interface FcrFormModel {
  revision: string
  subject: string
  issueInstitution: string
  projectId: string
  unitId: string
  system: string
  equipmentCode: string
  building: string
  area: string
  room: string
  level: string
  discipline: string
  classification: string
  pages: string
  issueDate: string
  responsibleCode: string
  documentConcerned: string
  reasons: string[]
  responsible: string[]
  cost: string
  amount: string
  fromCr: string
  crNo: string
  reasonDescription: string
  changeScheme: string
  materialReplacement: string
  temporaryMeasure: string
  planStartDate: string
  planFinishDate: string
}

/** 表单校验规则 */
const rules: FormRules = {
  subject: [{ required: true, message: '请输入主题', trigger: 'blur' }],
  issueInstitution: [{ required: true, message: '请输入发起单位', trigger: 'blur' }],
  projectId: [{ required: true, message: '请选择项目', trigger: 'change' }],
  unitId: [{ required: true, message: '请选择机组', trigger: 'change' }],
  classification: [{ required: true, message: '请选择类别', trigger: 'change' }],
  reasons: [{ required: true, message: '请选择变更原因', trigger: 'change' }]
}

/** 表单引用 */
const formRef = ref<FormInstance>()

/** 项目远程搜索 loading 状态 */
const projectLoading = ref(false)

/** 项目下拉选项 */
const projectOptions = ref<Array<{ label: string; value: string }>>([])

/** 机组下拉选项 */
const unitOptions = ref<Array<{ label: string; value: string }>>([])

/** 项目详情树形结构(用于联动) */
const projectDetailTree = ref<any[]>([])

/** 创建空表单的初始值 */
const createEmptyForm = (): FcrFormModel => ({
  revision: 'A',
  subject: '',
  issueInstitution: '',
  projectId: '',
  unitId: '',
  system: '',
  equipmentCode: '',
  building: '',
  area: '',
  room: '',
  level: '',
  discipline: '',
  classification: '',
  pages: '',
  issueDate: '',
  responsibleCode: '',
  documentConcerned: '',
  reasons: [],
  responsible: [],
  cost: '',
  amount: '',
  fromCr: '',
  crNo: '',
  reasonDescription: '',
  changeScheme: '',
  materialReplacement: '',
  temporaryMeasure: '',
  planStartDate: '',
  planFinishDate: ''
})

/** 表单数据 */
const form = reactive<FcrFormModel>(createEmptyForm())

/**
 * 远程搜索项目列表
 * @param query 搜索关键词
 */
const searchProject = async (query?: string) => {
  projectLoading.value = true
  try {
    const res = await getProjectList({
      projectName: query?.trim() ?? '',
      pageSize: 999,
      pageNumber: 1
    })
    const data = (res?.data ?? {}) as any
    const list = data.result || data.list || []
    projectOptions.value = (Array.isArray(list) ? list : []).map((item: any) => ({
      label: item.projectName || '',
      value: String(item.projectId ?? '')
    }))
  } catch (error) {
    console.error('获取项目列表失败:', error)
    projectOptions.value = []
  } finally {
    projectLoading.value = false
  }
}

/**
 * 项目选择变化:清空机组并加载机组列表
 */
const handleProjectChange = async () => {
  form.unitId = ''
  unitOptions.value = []
  projectDetailTree.value = []

  if (form.projectId) {
    try {
      const res = await getProjectDetail({ projectId: form.projectId })
      const detailData = (res?.data ?? {}) as any
      const childItemList = detailData.childItemList || []
      projectDetailTree.value = childItemList
      unitOptions.value = extractItemsByType(childItemList, 'unit')
    } catch (error) {
      console.error('获取项目详情失败:', error)
    }
  }
}

/**
 * 机组选择变化:联动逻辑预留
 */
const handleUnitChange = () => {}

/**
 * 表单校验方法(供父组件调用)
 * @returns Promise
 */
const validate = () => {
  return formRef.value?.validate()
}

/**
 * 获取表单数据(供父组件调用)
 * @returns 当前表单数据
 */
const getFormData = (): FcrFormModel => {
  return { ...form }
}

/**
 * 设置表单数据(供父组件调用)
 * @param data 表单数据
 */
const setFormData = (data: Partial<FcrFormModel>) => {
  Object.assign(form, createEmptyForm(), data)
}

/**
 * 重置表单(供父组件调用)
 */
const resetForm = () => {
  Object.assign(form, createEmptyForm())
}

/** 暴露方法给父组件 */
defineExpose({
  validate,
  getFormData,
  setFormData,
  resetForm,
  form
})

/** 组件挂载时加载项目列表 */
searchProject('')

/** 监听外部传入的数据变化(通过 v-model) */
const modelValue = defineModel<Partial<FcrFormModel>>()
watch(
  modelValue,
  (val) => {
    if (val) {
      setFormData(val)
    }
  },
  { immediate: true }
)
</script>

<style scoped lang="scss">
.fcr-form {
  padding: 16px;
  overflow-y: auto;
}
</style>