给运营开发了一个支持富文本编辑的动态网页发布系统,然后我每天都按时下班了!

3,921 阅读7分钟

大家好,我是一名全栈开发工程师,随着近期公司的机器人项目上线,pr部门一次性发过来中英法三国语言的百十篇word格式的动态新闻素材包(包括:文字,图片,视频,超链接),让我把每篇文章都务必更新到公司的两大官网和APP端(ios,andriod)上,这工作量一听是不是带把自己干到ICU了,要命的是,刚画完静态页面,然后负责法文网站翻译的同事说:不好意思,有个法语文章的一个法语单词用的不太优雅,能不能帮我改下?负责英文网站翻译的同事说:这段翻译,老板不喜欢,能不能帮我改一下?我说:当然可以,然后我的Mac键盘敲的啪啪响,CV键都快失灵了!

总结一下这种情况:从技术的角度上easy,but,会累死人!!!!不出意外的意外来了,刚发完上一波又来了一堆文章素材要发布,APP端:再给加几个页面关于用户协议说明和隐私政策。。。刚加完不久,合规说:有个地方要补充下。。。

作为全栈研发的我当然忍不了,于是,我自己花了三天时间,写了一个官网动态页面管理系统。

1.后端:先在mysql数据库建了一张表和python写了两个接口

1.1 建表


SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for c_official_web_info
-- ----------------------------
DROP TABLE IF EXISTS `c_official_web_info`;
CREATE TABLE `c_official_web_info` (
  `id` int NOT NULL AUTO_INCREMENT,
  `created_at` datetime DEFAULT NULL COMMENT '创建时间',
  `from_official` varchar(255) NOT NULL COMMENT '官网来源:'A网站/B网站',
  `title` text NOT NULL COMMENT '标题',
  `date` varchar(255) NOT NULL COMMENT '日期',
  `picture` varchar(255) NOT NULL COMMENT '封面图片',
  `content` text NOT NULL COMMENT '正文',
  `language` varchar(255) NOT NULL COMMENT '语言',
  `status` int NOT NULL DEFAULT '0' COMMENT '发布状态0:发布,1:删除',
  `updated_at` datetime DEFAULT NULL,
  `deleted_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=40 DEFAULT CHARSET=utf8mb3 COMMENT='官网动态页面管理数据表';

SET FOREIGN_KEY_CHECKS = 1;

1.2 插入数据,修改数据

接口是用python写的,甭管啥语言,最终都是CRUD那些事,问题不大!

import logging
import datetime
import re

from backend.schemas.official_news_schema import OfficialNewsDataSchema, OfficialNewsListSchema
from base_response.base_response import BaseResponse
from base_response import response_codes
from backend.providers.async_database import async_db
from const.official_const import ReleaseStatus
from models.offical_news_model import OfficialNewsInfo


class BackendOfficialNewsService(object):

    @classmethod
    async def official_news_data(cls, official_news_request: OfficialNewsDataSchema):
        if  int(official_news_request.type) == 2:
             # 更新新闻
            news_Info: OfficialNewsInfo = await async_db.get_or_none(
                OfficialNewsInfo.select().where(
                    OfficialNewsInfo.id == official_news_request.id,
                    OfficialNewsInfo.status == ReleaseStatus.release
                )
            )
            if news_Info:
                news_Info.from_official = official_news_request.from_official
                news_Info.title = official_news_request.title
                news_Info.language = official_news_request.language
                news_Info.date = official_news_request.date
                news_Info.picture = official_news_request.picture
                news_Info.content = official_news_request.content
                news_Info.status =  ReleaseStatus.release
                await async_db.update(news_Info)
                return BaseResponse.ok()
            else:
                return BaseResponse.error(data=response_codes.NEWS_CHECK_UPDATE_ERROR)
            
        elif int(official_news_request.type) == 1:
             # 添加新闻
            await async_db.create(
                OfficialNewsInfo, **dict(
                    from_official=official_news_request.from_official, title=official_news_request.title,
                    date=official_news_request.date, picture=official_news_request.picture, 
                    content = official_news_request.content, language= official_news_request.language,
                )
            )
            return BaseResponse.ok()
        elif int(official_news_request.type) == 0:
            # 删除新闻
            if not official_news_request.id:
                return BaseResponse.error(data=response_codes.NEWS_CHECK_ID_ERROR)
            else:
                news_Info: OfficialNewsInfo = await async_db.get_or_none(
                    OfficialNewsInfo.select().where(
                        OfficialNewsInfo.id == official_news_request.id,
                    )
                )
                if news_Info:
                    news_Info.status = ReleaseStatus.delete
                    await async_db.update(news_Info)
                    return BaseResponse.ok()
        else:
            return BaseResponse.error(data=response_codes.NEWS_CHECK_TYPE_ERROR)
        return BaseResponse.ok()

1.3 查表:

 @classmethod
    async def official_news_list(cls, official_news_request: OfficialNewsListSchema):
        condition = [OfficialNewsInfo.status == ReleaseStatus.release]
        if official_news_request.from_official:
            condition.append(OfficialNewsInfo.from_official.like(f"%{official_news_request.from_official}%"))
        if official_news_request.title:
            condition.append(OfficialNewsInfo.title.like(f"%{official_news_request.title}%"))
        if official_news_request.language:
            condition.append(OfficialNewsInfo.language == official_news_request.language)
        if official_news_request.id:
            condition.append(OfficialNewsInfo.id == official_news_request.id)
        base_query = OfficialNewsInfo.select().where(*condition).order_by(OfficialNewsInfo.id.desc())
        total = await async_db.count(base_query)
        query = await async_db.execute(base_query.paginate(official_news_request.current, official_news_request.size))
        data = [i.json_parse() for i in query]

        return BaseResponse.ok(data={"list": data, "total": total})
       

2. 前端vue框架开发富文本编辑页面,支持超链接,图片,视频

先看图,编辑页面,效果如下:

截屏2024-05-14 15.38.54.png

列表查看,页面效果如下:

截屏2024-05-14 15.44.45.png

是不是一目了然,方便快捷,写好后让运营们自己去发布吧,然们专心搞技术就行!

2.1 VUE3.2版本

贴一下前端富文本组件的封装组件@vueup/vue-quill,VUE3.2版本的setup写法如下: 子组件:MyQuillEditor

<template>
    <div>
        
        <QuillEditor ref="myQuillEditor" theme="snow" :content="content" :options="editorOption" contentType="html"
            @update:content="setValue" />
        <!-- 使用自定义图片上传 -->
        <el-upload class="avatar-uploader" action="" :limit="1" :http-request="fileChange"></el-upload>
        <div class="linkDialog">
            <el-input v-model="linkUrl.text" placeholder="请输入链接名称" style="width:200px"></el-input>
            <el-input v-model="linkUrl.href" placeholder="请输入链接url" style="width:200px"></el-input>
            <span slot="footer" class="dialog-footer">
                <el-button @click="handleClose">取 消</el-button>
                <el-button type="primary" @click="linkAdd">确 定</el-button>
            </span>
        </div>
    </div>
</template>

<script setup>
import { ref, toRaw, onMounted, nextTick, reactive } from 'vue';
import { QuillEditor, Quill } from '@vueup/vue-quill';
import '@vueup/vue-quill/dist/vue-quill.snow.css';
import { systemApi } from "../../utils/request";
import { ElMessage } from 'element-plus';
 // 源码中是import直接倒入,这里要用Quill.import引入
const Link = Quill.import("formats/link");
  // 自定义a链接
class FileBlot extends Link {
    // 继承Link Blot
    static create (value) {
      let node = undefined;
      if (value && !value.href) {
        // 适应原本的Link Blot
        node = super.create(value)
      } else {
        // 自定义Link Blot
        node = super.create(value.href)
        node.href = value.href
        node.innerText = value.innerText
        // node.setAttribute('download', value.innerText);  // 左键点击即下载
      }
      return node;
    }
}
FileBlot.blotName = "link" // 这里不用改,如果需要也可以保留原来的,这里用个新的blot
FileBlot.tagName = "A"
Quill.register(FileBlot) // 注册link
const props = defineProps(['value']);
const emit = defineEmits(['func']);
const myQuillEditor = ref();
const content = ref('');
const linkUrl = reactive({
  href: "",
  text: ""
});
const toolBarOption = [['bold', 'italic', 'underline', 'strike'], // 加粗、斜体、下划线、删除线
['blockquote', 'code-block'], // 引用、代码块
[{ 'header': 1 }, { 'header': 2 }], // 标题
[{ 'list': 'ordered' }, { 'list': 'bullet' }], // 有序列表、无序列表
[{ 'script': 'sub' }, { 'script': 'super' }], // 下标、上标
[{ 'indent': '-1' }, { 'indent': '+1' }], // 缩进
[{ 'direction': 'rtl' }], // 文本方向
[{ 'size': ['small', false, 'large', 'huge'] }], // 字体大小
[{ 'header': [1, 2, 3, 4, 5, 6, false] }], // 标题大小
[{ 'color': [] }, { 'background': ['black'] }], // 文本颜色、背景颜色
[{ 'font': [] }], // 字体
[{ 'align': [] }], // 对齐方式
['clean'], // 清除格式
['link', 'image', 'video']// 链接、图片、视频
];
const editorOption = reactive({
        // 富文本编辑器选项
        placeholder: '请输入文章正文内容,支持上传图片,视频',
        theme: 'snow', //主题 snow/bubble
        syntax: true, //语法检测
        lang: 'zh-CN',
        modules: {
            toolbar: {
                container: toolBarOption,
            }
        },

    });
const handleClose = () => {
  document.querySelector('.linkDialog').style.display = 'none'
  linkUrl.href = ""  
  linkUrl.text = ""
};
const linkAdd = () => {
    try{
        const quill = toRaw(myQuillEditor.value).getQuill();
        const length = quill.getSelection(true).index;
        console.log(quill,length)
        quill.insertEmbed(length, 'link', { href: linkUrl.href, innerText: linkUrl.text }, "api");
        quill.setSelection(length + linkUrl.text.length);
        handleClose()
    }catch(e){
        console.log(e)
        ElMessage({
            message: '链接添加异常!',
            type: 'error',
        })
    }
  
};
const fileChange = (file) => {
    const reader = new FileReader();
    reader.readAsDataURL(file.file)
    reader.addEventListener('load', () => {
        fileUpload(file.file.name, reader.result)
    });
};
const fileUpload = async (name, file, type) => {
    const params = {
        upload_type: 0,//0:证件,
        file: file,
        filename: name
    }
    try {
        let res = await systemApi.fileUpload(params)
        if (res.code === 200) {
            ElMessage({
                message: '操作成功!',
                type: 'success',
            })
            const quill = toRaw(myQuillEditor.value).getQuill();
            if(!quill){
                return
            }
            const length = quill.getSelection().index
            quill.insertEmbed(length, 'image', res.data.head_img)
            quill.setSelection(length + 1)
        } else {
            ElMessage({
                message: '异常!' + res?.msg,
                type: 'error',
            })
        }
    } catch (e) {
        console.log(e)
        ElMessage({
            message: '异常!' + e,
            type: 'error',
        })
    }
};
const imgHandler = (state) => {
    if (state) {
        document.querySelector('.avatar-uploader input').click()
    }
};
const linkClick = (state)=>{
    if (state) {
        document.querySelector('.linkDialog').style.display = 'block'
    }
}
// 抛出更改内容,此处避免出错直接使用文档提供的getHTML方法
const setValue = () => {
    const text = toRaw(myQuillEditor.value).getHTML()
    console.log('text',text)
    emit('func', text)
};
// 初始化编辑器
onMounted(async () => {
    const quill = toRaw(myQuillEditor.value).getQuill()
    console.log(props.value,'props.value')
    if (myQuillEditor.value) {
      quill.getModule('toolbar').addHandler('image', imgHandler)
      quill.getModule('toolbar').addHandler('link', linkClick)
      await nextTick()
      setTimeout(()=>{
        myQuillEditor.value.setContents(props.value);
      },300)
    }
});
</script>
<style scoped lang="css">
.linkDialog {
  width: fit-content;
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background-color: #fff;
  display: none;
}
</style>

解释一下:图片是自定义上传的,读取图片的base64格式的,上传后调取接口获取url,link超链接也是自定义输入的,视频是上传视频url会自动引入。

父组件中引入:

<template>
    <MyQuillEditor :value="dynamicValidateForm.content" @func="updateContent"/>
<template/>
<script setup>
import { ref ,reactive} from 'vue';
import MyQuillEditor from './myQuillEditor.vue';
const dynamicValidateForm = ref({
  // 此处省略其它字段
  content: '',
});
const updateContent = (data)=>{
   dynamicValidateForm.value.content = data 
}
</script>

2.2 有写vue2的不用怕,用'vue-quill-editor';

注意:vue-quill-editor不兼容vue3版本,我踩过坑,你就别踩了,如果你是vue3,就用上面的,如果vue2,用下面的,看代码:

<template>
    <div>
    <el-form :model="dynamicValidateForm" ref="dynamicValidateForm" label-width="100px" class="demo-dynamic">
        <el-form-item
              prop="from_official"
              label="官网"
              :rules="[
              { required: true, message: '请选择发布的官网', trigger: 'blur' },
              ]"
          >
            <el-select v-model="dynamicValidateForm.from_official" placeholder="请选择" width="50%">
              <el-option
                v-for="item in FROM_MAP"
                :key="item"
                :label="item"
                :value="item">
              </el-option>
            </el-select>
        </el-form-item>
        <el-form-item
              prop="language"
              label="语言"
              :rules="[
              { required: true, message: '请选择发布的语言', trigger: 'blur' },
              ]"
          >
            <el-select v-model="dynamicValidateForm.language" placeholder="请选择" width="50%">
              <el-option
                v-for="item in languageList"
                :key="item"
                :label="item"
                :value="item">
              </el-option>
            </el-select>
        </el-form-item>
        <el-form-item
            prop="title"
            label="标题"
            :rules="[
            { required: true, message: '请输入文章标题', trigger: 'blur' },
            ]"
        >
            <el-input v-model="dynamicValidateForm.title" placeholder="请输入文章标题"></el-input>
        </el-form-item>
        <el-form-item
            prop="date"
            label="日期"
            :rules="[
            { required: true, message: '请输入文章发布日期', trigger: 'blur' },
            ]"
        >
            <el-input v-model="dynamicValidateForm.date" placeholder="请输入文章发布日期"></el-input>
        </el-form-item>
        <el-form-item
            prop="picture"
            label="封面"
            :rules="[
            { required: true, message: '请输入封面图片url链接或者base64编码', trigger: 'blur' },
            ]"
        >
            <el-input v-model="dynamicValidateForm.picture" placeholder="请输入封面图片链接"></el-input>
            <el-upload action="" :limit="1" :http-request="imgfileChange">上传</el-upload>
        </el-form-item>
        <el-form-item
            prop="content"
            label="正文"
            :rules="[
            { required: true, message: '请编辑正文', trigger: 'blur' },
            ]"
        >
         <quill-editor 
          v-model="dynamicValidateForm.content" 
          :options="editorOption" 
          ref="myQuillEditor" 
          @change="onEditorChange($event)"
          class="news-edit-util"></quill-editor> 
         <el-upload class="avatar-uploader" ref="imgUpload" action="" :limit="1" :http-request="fileChange"></el-upload>
        </el-form-item>
        <el-form-item>
            <el-button @click="reviewEmail"  icon="el-icon-message"  style="margin-top: 20px;">预览</el-button>
            <el-button type="primary" @click="submitForm('dynamicValidateForm')" :disabled="disabled">发布</el-button>
            <el-button @click="resetForm('dynamicValidateForm')">重置</el-button>
            <el-button type="primary" @click="backList">返回发布列表</el-button>
        </el-form-item>
    </el-form>
    <el-drawer
            title="正文预览"
            size="50%"
            :visible.sync="drawer"
            :direction="direction"
    >
        <div v-html="dynamicValidateForm.content" class="previewContainer"></div>
    </el-drawer> 
    <div class="linkDialog">
      <el-input v-model="linkUrl.text" placeholder="请输入链接名称" style="width:200px"></el-input>
      <el-input v-model="linkUrl.href" placeholder="请输入链接url" style="width:200px"></el-input>
      <span slot="footer" class="dialog-footer">
        <el-button @click="handleClose">取 消</el-button>
        <el-button type="primary" @click="linkAdd">确 定</el-button>
      </span>
    </div>
</div>
  </template>
  
  <script>
  import 'quill/dist/quill.core.css';
  import 'quill/dist/quill.snow.css';
  import 'quill/dist/quill.bubble.css';
  import { quillEditor } from 'vue-quill-editor';
  import Quill from 'quill';  //引入编辑器;
  import { dashboardApi } from "@/api/index";
  // 源码中是import直接倒入,这里要用Quill.import引入
  const Link = Quill.import("formats/link");
  // 自定义a链接
  class FileBlot extends Link {
    // 继承Link Blot
    static create (value) {
      let node = undefined;
      if (value && !value.href) {
        // 适应原本的Link Blot
        node = super.create(value)
      } else {
        // 自定义Link Blot
        node = super.create(value.href)
        node.href = value.href
        node.innerText = value.innerText
        // node.setAttribute('download', value.innerText);  // 左键点击即下载
      }
      return node;
    }
  }
  FileBlot.blotName = "link" // 这里不用改,如果需要也可以保留原来的,这里用个新的blot
  FileBlot.tagName = "A"
  Quill.register(FileBlot) // 注册link
  export default {
    components: {
      quillEditor
    },
    data() {
      return {
        content_id: this.$route.query.id,
        type: this.$route.query.type,
        FROM_MAP:['官网A','官网B','APP'],
        languageList:['ZH','EN','FR'],
        linkUrl:{
          href:"",
          text:""
        },
        dynamicValidateForm:{
            from_official: '',
            title:'',
            date:'',
            picture:'',
            content:'',
            language:'',
            type:1,
        },
        dialogVisible:false,
        drawer: false,
        disabled:false,
        direction: 'rtl',
        editorOption: {
          // 富文本编辑器选项
          placeholder: '请输入文章正文内容,支持上传图片,视频',
          theme: 'snow', //主题 snow/bubble
          syntax: true, //语法检测
          modules: {
            toolbar: {
              container:[ ['bold', 'italic', 'underline', 'strike'], // 加粗、斜体、下划线、删除线
                          ['blockquote', 'code-block'], // 引用、代码块
                          [{ 'header': 1 }, { 'header': 2 }], // 标题
                          [{ 'list': 'ordered'}, { 'list': 'bullet' }], // 有序列表、无序列表
                          [{ 'script': 'sub'}, { 'script': 'super' }], // 下标、上标
                          [{ 'indent': '-1'}, { 'indent': '+1' }], // 缩进
                          [{ 'direction': 'rtl' }], // 文本方向
                          [{ 'size': ['small', false, 'large', 'huge'] }], // 字体大小
                          [{ 'header': [1, 2, 3, 4, 5, 6, false] }], // 标题大小
                          [{ 'color': [] }, { 'background': ['black'] }], // 文本颜色、背景颜色
                          [{ 'font': [] }], // 字体
                          [{ 'align': [] }], // 对齐方式
                          ['clean'], // 清除格式
                          ['link', 'image', 'video']// 链接、图片、视频
                        ], 
              handlers: {
                'image': function (value) {
                  if (value) {
                    // 触发input框选择图片文件
                    document.querySelector('.avatar-uploader input').click()
                  }
                },
                'link':function (value) {
                  if (value) {
                    document.querySelector('.linkDialog').style.display = 'block'
                  }
                }
              }
            }
          }
        }
      }
    },
    mounted() {
        this.demand();
    },
    methods: {
      // 值发生变化
      onEditorChange(editor) {
            console.log(editor.html);
      },
      fileChange(file) {
            const reader = new FileReader();
            reader.readAsDataURL(file.file)
            reader.addEventListener('load', () => {
                this.fileUpload(file.file.name, reader.result)
            });
      },
      imgfileChange(file){
        const reader = new FileReader();
            reader.readAsDataURL(file.file)
            reader.addEventListener('load', () => {
                this.fileUpload(file.file.name, reader.result,'picture')
            });
      },
      handleClose(){
        document.querySelector('.linkDialog').style.display = 'none'
        this.linkUrl = {}
      },
      linkAdd(){
        const quill = this.$refs.myQuillEditor.quill;
        // 获取光标所在位置
        let length = quill.getSelection(true).index;
        // 插入链接  res.info为服务器返回的图片地址
        quill.insertEmbed(length, 'link',{ href: this.linkUrl.href, innerText:  this.linkUrl.text }, "api" );
        // 调整光标到最后
        quill.setSelection(length + this.linkUrl.text.length);
        this.handleClose()
      },
      async fileUpload(name, file,type) {
            const params = {
                upload_type: 0,
                file: file,
                filename: name
            }
            try {
                let res = await dashboardApi.fileUpload(params)
                if (res.code === 200) {
                   this.$message.success('上传成功!');
                   if(type&&type==='picture'){
                    this.dynamicValidateForm.picture = res.data.head_img
                    return 
                   }
                    console.log(res.data)
                    const quill = this.$refs.myQuillEditor.quill;
                    // 获取光标所在位置
                    let length = quill.getSelection(true).index;
                    // 插入图片  res.info为服务器返回的图片地址
                    quill.insertEmbed(length, 'image', res.data.head_img);
                    // 调整光标到最后
                    quill.setSelection(length + 1)
                } else {
                    this.$message.error(res.msg);
                }
            } catch (e) {
                console.log(e)
                this.$message.error('图片添加异常');
            }
        },
      async demand() {
        if(this.content_id && this.type === 2){
            
            let res = await dashboardApi.getOfficialNewsList(
                {id:this.content_id, current:1, size:1}
            );
            if (res.code === 200) {
                const details = res.data.list[0]
                this.dynamicValidateForm = {
                  from_official: details.from_official,
                  title:details.title,
                  date:details.date,
                  picture:details.picture,
                  content:details.content,
                  language:details.language,
                }
            } else {
                this.$message.error(res.msg);
            }
        }            
      },
      reviewEmail(){
        this.drawer = true; 
      },
      backList(){
        this.$router.push({name: "websiteNewsList",});
      },
      submitForm(formName) {
        this.$refs[formName].validate(async(valid) => {
          if (valid) {
            let params = this.dynamicValidateForm
            if(this.content_id && this.type === 2){
              params = {
                ...params,
                id: this.content_id,
                type: this.type
              }
            }
            this.disabled = true
            let res = await dashboardApi.getOfficialNewsData(params);
            if (res.code === 200) {
              this.$message.success('发布成功!');
            }else{
              this.$message.error(res.msg);
            }
            this.disabled = false
          } else {
            console.log('error submit!!');
            return false;
          }
        });
      },
      resetForm(formName) {
        this.$refs[formName].resetFields();
      }
    } 
  }
</script>
<style scoped>

.linkDialog{
  width: fit-content;
  position: fixed;
  top:50%;
  left:50%;
  transform: translate(-50%,-50%);
  background-color: #fff;
  display: none;
}

</style>

最后,总结一下,从建表到写CRUD接口,最后画页面,一同操作猛如虎,最后部署完,交给运营去用吧,剩下的事,就是能按时下班了!给自己留点私人时间去旅行不香吗?代码拿走,不谢!