大家好,我是一名全栈开发工程师,随着近期公司的机器人项目上线,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框架开发富文本编辑页面,支持超链接,图片,视频
先看图,编辑页面,效果如下:
列表查看,页面效果如下:
是不是一目了然,方便快捷,写好后让运营们自己去发布吧,然们专心搞技术就行!
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接口,最后画页面,一同操作猛如虎,最后部署完,交给运营去用吧,剩下的事,就是能按时下班了!给自己留点私人时间去旅行不香吗?代码拿走,不谢!