本文将详细介绍如何使用Node.js、Express、MongoDB和Vue3开发一个完整的新闻发布系统,包括富文本编辑器的集成和项目部署上线。
以下是新闻发布系统的项目结构图,采用树状形式展示,包含前后端主要文件和目录:
news-system/
├── backend/ # 后端Express项目
│ ├── node_modules/ # 后端依赖
│ ├── config/ # 配置文件
│ │ └── db.js # 数据库配置
│ ├── controllers/ # 控制器
│ │ ├── newsController.js # 新闻业务逻辑
│ │ └── authController.js # 认证逻辑(可选)
│ ├── models/ # 数据模型
│ │ ├── News.js # 新闻模型
│ │ └── User.js # 用户模型(可选)
│ ├── routes/ # 路由
│ │ ├── news.js # 新闻路由
│ │ └── auth.js # 认证路由(可选)
│ ├── middleware/ # 中间件
│ │ └── auth.js # 认证中间件(可选)
│ ├── uploads/ # 上传文件目录
│ ├── .env # 环境变量
│ ├── app.js # 主应用文件
│ ├── package.json
│ └── package-lock.json
│
└── frontend/ # 前端Vue3项目
├── node_modules/ # 前端依赖
├── public/ # 静态资源
├── src/
│ ├── assets/ # 静态资源
│ ├── components/ # 公共组件
│ │ ├── RichTextEditor.vue # 富文本编辑器
│ │ ├── NewsCard.vue # 新闻卡片组件
│ │ └── …
│ ├── composables/ # 组合式函数
│ ├── stores/ # Pinia状态管理
│ │ └── news.js # 新闻状态管理
│ ├── router/ # 路由配置
│ │ └── index.js
│ ├── views/ # 页面视图
│ │ ├── HomeView.vue # 首页
│ │ ├── NewsList.vue # 新闻列表
│ │ ├── NewsCreate.vue # 新闻创建
│ │ ├── NewsEdit.vue # 新闻编辑
│ │ └── NewsDetail.vue # 新闻详情
│ ├── App.vue # 根组件
│ └── main.js # 入口文件
├── .env.development # 开发环境变量
├── .env.production # 生产环境变量
├── vite.config.js # Vite配置
├── package.json
└── package-lock.json
关键文件说明:
后端结构
- config/db.js – MongoDB数据库连接配置
- controllers/ – 处理业务逻辑
- models/News.js – 定义新闻数据模型
- routes/news.js – 新闻相关API路由
- uploads/ – 存储上传的图片文件
- app.js – Express应用入口
前端结构
- components/RichTextEditor.vue – 富文本编辑器组件
- stores/news.js – Pinia状态管理(新闻数据)
- views/News*.vue – 各新闻相关页面
- router/index.js – 前端路由配置
- vite.config.js – Vite构建配置
数据流示意图:
前端(Vue3) → API请求 → 后端(Express) → 数据库(MongoDB)
↑ |
| ↓
←─────── 响应数据 ──────────────←
开发工作流:
- 前端通过Pinia store发起API请求
- 后端路由接收请求并转发到控制器
- 控制器调用模型进行数据库操作
- 操作结果通过路由返回给前端
- 前端更新Pinia状态和UI显示
这个结构遵循了现代Web应用的最佳实践,保持了前后端分离的架构,组件化和模块化的设计便于维护和扩展。
一、项目概述
技术栈
- 后端:Node.js + Express + MongoDB
- 前端:Vue3 + Vite + Element Plus
- 富文本编辑器:TinyMCE 或 Quill
- 构建工具:Vite
- 部署:Docker + Nginx 或 PM2
二、环境准备
1. 安装必要软件
- Node.js (建议16.x或18.x LTS版本)
- MongoDB (4.4或更高版本)
- Git
- 代码编辑器(VSCode推荐)
2. 检查安装
node -v
npm -v
mongod --version
git --version
三、后端开发(Express + MongoDB)
1. 创建项目目录
mkdir news-system
cd news-system
mkdir backend
cd backend
npm init -y
2. 安装依赖
npm install express mongoose cors body-parser multer dotenv
npm install --save-dev nodemon
3. 基础Express应用结构
创建app.js: javascript
const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
const bodyParser = require('body-parser');
const dotenv = require('dotenv');
dotenv.config();
const app = express();
<em>// 中间件</em>
app.use(cors());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
<em>// 数据库连接</em>
mongoose.connect(process.env.MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true
})
.then(() => console.log('MongoDB connected'))
.catch(err => console.log(err));
<em>// 路由</em>
app.get('/', (req, res) => {
res.send('News System API');
});
<em>// 错误处理</em>
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).send('Something broke!');
});
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});JavaScript创建.env文件:
MONGODB_URI=mongodb://localhost:27017/news-system
PORT=5000
JWT_SECRET=your_jwt_secret
4. 创建新闻模型和路由
创建models/News.js:javascript
const mongoose = require('mongoose');
const newsSchema = new mongoose.Schema({
title: { type: String, required: true },
content: { type: String, required: true },
category: { type: String, required: true },
author: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
coverImage: { type: String },
isPublished: { type: Boolean, default: false },
publishedAt: { type: Date },
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }
});
module.exports = mongoose.model('News', newsSchema);
创建routes/news.js:javascript
const express = require('express');
const router = express.Router();
const News = require('../models/News');
const multer = require('multer');
const path = require('path');
<em>// 配置multer用于文件上传</em>
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, 'uploads/');
},
filename: function (req, file, cb) {
cb(null, Date.now() + path.extname(file.originalname));
}
});
const upload = multer({ storage: storage });
<em>// 创建新闻</em>
router.post('/', upload.single('coverImage'), async (req, res) => {
try {
const { title, content, category } = req.body;
const coverImage = req.file ? req.file.path : '';
const news = new News({
title,
content,
category,
coverImage,
isPublished: true,
publishedAt: new Date()
});
await news.save();
res.status(201).json(news);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
<em>// 获取所有新闻</em>
router.get('/', async (req, res) => {
try {
const news = await News.find().sort({ createdAt: -1 });
res.json(news);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
<em>// 获取单条新闻</em>
router.get('/:id', async (req, res) => {
try {
const news = await News.findById(req.params.id);
if (!news) return res.status(404).json({ error: 'News not found' });
res.json(news);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
<em>// 更新新闻</em>
router.put('/:id', upload.single('coverImage'), async (req, res) => {
try {
const { title, content, category } = req.body;
const updateData = { title, content, category, updatedAt: new Date() };
if (req.file) {
updateData.coverImage = req.file.path;
}
const news = await News.findByIdAndUpdate(req.params.id, updateData, { new: true });
if (!news) return res.status(404).json({ error: 'News not found' });
res.json(news);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
<em>// 删除新闻</em>
router.delete('/:id', async (req, res) => {
try {
const news = await News.findByIdAndDelete(req.params.id);
if (!news) return res.status(404).json({ error: 'News not found' });
res.json({ message: 'News deleted successfully' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;JavaScript在app.js中添加路由:javascript
<em>// 在数据库连接后添加</em>
const newsRoutes = require('./routes/news');
app.use('/api/news', newsRoutes);
<em>// 静态文件访问</em>
app.use('/uploads', express.static('uploads'));JavaScript5. 添加用户认证(可选)
创建models/User.js和routes/auth.js,实现JWT认证。
6. 启动后端
全局安装 nodemon(推荐)
npm install -g nodemon
检查 nodemon 是否安装成功:
nodemon -v
nodemon app.js
四、前端开发(Vue3 + Vite)
1. 创建Vue3项目
cd ..
npm create vite@latest frontend --template vue
cd frontend
2. 安装必要依赖
npm install axios vue-router@4 pinia element-plus @element-plus/icons-vue
npm install @tinymce/tinymce-vue tinymce # 富文本编辑器
# 或 npm install quill @vueup/vue-quill@beta # 另一个富文本编辑器选项
3. 配置Vite
修改vite.config.js: javascript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
<em>// https://vitejs.dev/config/</em>
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
proxy: {
'/api': {
target: 'http://localhost:5000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
})JavaScript4. 配置Element Plus
修改main.js:javascript
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
<em>// Element Plus</em>
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
const app = createApp(App)
<em>// 注册Element Plus图标</em>
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')JavaScript5. 创建路由
创建src/router/index.js:javascript
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import NewsList from '../views/NewsList.vue'
import NewsCreate from '../views/NewsCreate.vue'
import NewsEdit from '../views/NewsEdit.vue'
import NewsDetail from '../views/NewsDetail.vue'
const routes = [
{
path: '/',
name: 'home',
component: HomeView
},
{
path: '/news',
name: 'news-list',
component: NewsList
},
{
path: '/news/create',
name: 'news-create',
component: NewsCreate
},
{
path: '/news/:id/edit',
name: 'news-edit',
component: NewsEdit,
props: true
},
{
path: '/news/:id',
name: 'news-detail',
component: NewsDetail,
props: true
}
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
export default routerJavaScript6. 创建Pinia store
创建src/stores/news.js:javascript
import { defineStore } from 'pinia'
import axios from 'axios'
export const useNewsStore = defineStore('news', {
state: () => ({
newsList: [],
currentNews: null,
loading: false,
error: null
}),
actions: {
async fetchNews() {
this.loading = true
try {
const response = await axios.get('/api/news')
this.newsList = response.data
} catch (error) {
this.error = error.message
} finally {
this.loading = false
}
},
async fetchNewsById(id) {
this.loading = true
try {
const response = await axios.get(`/api/news/${id}`)
this.currentNews = response.data
} catch (error) {
this.error = error.message
} finally {
this.loading = false
}
},
async createNews(newsData) {
this.loading = true
try {
const formData = new FormData()
formData.append('title', newsData.title)
formData.append('content', newsData.content)
formData.append('category', newsData.category)
if (newsData.coverImage) {
formData.append('coverImage', newsData.coverImage)
}
const response = await axios.post('/api/news', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
return response.data
} catch (error) {
this.error = error.message
throw error
} finally {
this.loading = false
}
},
async updateNews({ id, ...newsData }) {
this.loading = true
try {
const formData = new FormData()
formData.append('title', newsData.title)
formData.append('content', newsData.content)
formData.append('category', newsData.category)
if (newsData.coverImage) {
formData.append('coverImage', newsData.coverImage)
}
const response = await axios.put(`/api/news/${id}`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
return response.data
} catch (error) {
this.error = error.message
throw error
} finally {
this.loading = false
}
},
async deleteNews(id) {
this.loading = true
try {
await axios.delete(`/api/news/${id}`)
} catch (error) {
this.error = error.message
throw error
} finally {
this.loading = false
}
}
}
})JavaScript7. 实现富文本编辑器(TinyMCE)
创建src/components/RichTextEditor.vue:
<template>
<div class="rich-text-editor">
<editor
v-model="content"
api-key="your-tinymce-api-key"
:init="editorConfig"
/><!--//从TinyMCE官网获取免费API key-->
</div>
</template>
<script>
import Editor from '@tinymce/tinymce-vue'
import { ref, watch } from 'vue'
export default {
name: 'RichTextEditor',
components: {
Editor
},
props: {
modelValue: {
type: String,
default: ''
}
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const content = ref(props.modelValue)
const editorConfig = {
height: 500,
menubar: true,
plugins: [
'advlist autolink lists link image charmap print preview anchor',
'searchreplace visualblocks code fullscreen',
'insertdatetime media table paste code help wordcount'
],
toolbar:
'undo redo | formatselect | bold italic backcolor | \
alignleft aligncenter alignright alignjustify | \
bullist numlist outdent indent | removeformat | help | image',
images_upload_handler: (blobInfo, success, failure) => {
<em>// 这里可以实现图片上传逻辑</em>
const formData = new FormData()
formData.append('file', blobInfo.blob(), blobInfo.filename())
axios.post('/api/upload', formData)
.then(res => {
success(res.data.location)
})
.catch(err => {
failure('Image upload failed')
console.error(err)
})
}
}
watch(content, (newVal) => {
emit('update:modelValue', newVal)
})
watch(() => props.modelValue, (newVal) => {
if (newVal !== content.value) {
content.value = newVal
}
})
return {
content,
editorConfig
}
}
}
</script>
<style>
.tox-tinymce {
border-radius: 4px !important;
}
</style>JavaScript8. 创建新闻相关组件
创建src/views/NewsCreate.vue:
<template>
<div class="news-create">
<el-card>
<template #header>
<div class="card-header">
<h2>创建新闻</h2>
</div>
</template>
<el-form :model="form" label-width="120px" ref="formRef">
<el-form-item label="新闻标题" prop="title" required>
<el-input v-model="form.title" placeholder="请输入新闻标题"></el-input>
</el-form-item>
<el-form-item label="新闻分类" prop="category" required>
<el-select v-model="form.category" placeholder="请选择分类">
<el-option label="政治" value="politics"></el-option>
<el-option label="经济" value="economy"></el-option>
<el-option label="科技" value="technology"></el-option>
<el-option label="体育" value="sports"></el-option>
<el-option label="娱乐" value="entertainment"></el-option>
</el-select>
</el-form-item>
<el-form-item label="封面图片" prop="coverImage">
<el-upload
class="avatar-uploader"
action="#"
:show-file-list="false"
:auto-upload="false"
:on-change="handleCoverImageChange"
>
<img v-if="form.coverImageUrl" :src="form.coverImageUrl" class="avatar" />
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
</el-upload>
</el-form-item>
<el-form-item label="新闻内容" prop="content" required>
<RichTextEditor v-model="form.content" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm">发布新闻</el-button>
<el-button @click="resetForm">重置</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { Plus } from '@element-plus/icons-vue'
import { useNewsStore } from '@/stores/news'
import RichTextEditor from '@/components/RichTextEditor.vue'
const router = useRouter()
const newsStore = useNewsStore()
const form = ref({
title: '',
category: '',
content: '',
coverImage: null,
coverImageUrl: ''
})
const formRef = ref(null)
const handleCoverImageChange = (file) => {
form.value.coverImage = file.raw
form.value.coverImageUrl = URL.createObjectURL(file.raw)
}
const submitForm = async () => {
try {
await newsStore.createNews(form.value)
ElMessage.success('新闻创建成功')
router.push({ name: 'news-list' })
} catch (error) {
ElMessage.error('新闻创建失败: ' + error.message)
}
}
const resetForm = () => {
if (formRef.value) {
formRef.value.resetFields()
}
form.value.content = ''
form.value.coverImage = null
form.value.coverImageUrl = ''
}
</script>
<style scoped>
.avatar-uploader .avatar {
width: 178px;
height: 178px;
display: block;
}
.avatar-uploader .el-upload {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
}
.avatar-uploader .el-upload:hover {
border-color: var(--el-color-primary);
}
.el-icon.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 178px;
height: 178px;
text-align: center;
}
</style>JavaScript类似地创建NewsList.vue, NewsEdit.vue和NewsDetail.vue。
五、项目部署
1. 生产环境准备
后端:
cd backend
npm install --production
前端:
cd frontend
npm run build
本地访问构建后的静态文件
构建完成后会生成 dist 目录(包含编译后的静态文件),你可以通过以下方式访问:
使用本地静态服务器
# 安装 serve 工具(若未安装)
npm install -g serve
# 进入 dist 目录并启动服务
cd dist
serve -s
访问网址 http://localhost:3000
2. 使用PM2部署
安装PM2:
npm install -g pm2
启动后端:
cd backend
pm2 start app.js --name "news-system-api"
启动前端服务(如果需要):
cd frontend
pm2 serve dist 8080 --name "news-system-frontend" --spa
3. 使用Docker部署
创建docker-compose.yml:yaml
version: '3.8'
services:
mongodb:
image: mongo:5.0
container_name: mongodb
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: example
volumes:
- mongodb_data:/data/db
ports:
- "27017:27017"
restart: always
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: backend
environment:
MONGODB_URI: mongodb://root:example@mongodb:27017/news-system?authSource=admin
PORT: 5000
ports:
- "5000:5000"
depends_on:
- mongodb
restart: always
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: frontend
ports:
- "8080:80"
depends_on:
- backend
restart: always
volumes:
mongodb_data:
后端Dockerfile:dockerfile
FROM node:16-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 5000
CMD ["node", "app.js"]
前端Dockerfile:dockerfile
FROM node:16-alpine as builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
前端nginx.conf:nginx
server {
listen 80;
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
启动服务:
docker-compose up -d --build
六、常见问题解决
- Vue3依赖版本冲突:
- 确保所有Vue相关包版本一致
- 使用
npm ls vue检查版本 - 可以尝试删除
node_modules和package-lock.json后重新安装
- 富文本编辑器问题:
- TinyMCE需要API key(免费版可用)
- 图片上传需要正确配置后端接口
- 如果使用Quill,确保安装
@vueup/vue-quill@beta版本
- 跨域问题:
- 开发时配置Vite代理
- 生产环境确保Nginx正确配置代理
- 静态资源404:
- 确保上传目录存在且有写入权限
- 生产环境需要将上传目录映射到容器中
- MongoDB连接问题:
- 检查MongoDB服务是否运行
- 验证连接字符串是否正确
- 生产环境需要设置认证
七、项目优化建议
- 性能优化:
- 实现新闻分页
- 添加缓存层(Redis)
- 图片压缩和CDN
- 安全优化:
- 实现JWT认证
- 输入验证和XSS防护
- 限制上传文件类型和大小
- 功能扩展:
- 新闻分类管理
- 用户评论功能
- 新闻审核流程
- 数据统计和分析
- 监控和日志:
- 添加日志系统
- 实现错误监控
- 性能监控
通过以上步骤,你应该能够成功搭建并运行一个基于Node.js + Express + MongoDB + Vue3的新闻发布系统。如果在具体实施过程中遇到问题,可以根据错误信息搜索解决方案或查阅相关文档。