工程化项目开发笔记
0. 准备
前端演示地址:http://adv.manage.fuming.site/
后端代码仓库地址:https://gitee.com/sh230320/backend_project
1 项目初始化配置
初始化
shellnpm init
安装相关依赖
shellnpm install webpack webpack-cli webpack-dev-server html-webpack-plugin webpack-merge -D
创建 src/app.js
创建 public/index.html
创建webpack基础配置文件 config/webpack.base.config.js
jsconst path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); module.exports = { // 入口 entry: path.resolve(__dirname, '../src/app.js'), // 出口 output: { path: path.resolve(__dirname, '../dist'), filename: 'js/[name].[hash:12].js', clean: true, // 设置打包后的js文件的路径前加 / 在webpack官网 - 配置 - 出口配置项中可以查阅到 publicPath: '/' }, // 插件 plugins: [ new HtmlWebpackPlugin({ // 起始html文件 template: path.resolve(__dirname, '../public/index.html'), // 将 script 放在 body 结尾 inject: 'body', // 压缩优化 minify: { removeAttributeQuotes: true, // 去除引号 removeComments: true, // 去除注释 collapseWhitespace: true, // 代码折叠 } }) ] }
创建开发模式配置文件 configs/webpack.dev.config.js
jsconst {merge} = require('webpack-merge'); const baseConfig = require('./webpack.base.config'); module.exports = merge(baseConfig, { // 模式 mode: 'development', // 自动开启服务 devServer: { host: 'shirly.com', port: '80', open: true } })
创建生成模式配置文件 configs/webpack.prod.config.js
jsconst {merge} = require('webpack-merge'); const baseConfig = require('./webpack.base.config'); module.exports = merge(baseConfig, { // 模式 mode: 'production' })
配置 pageage.json 中的 scripts
json{ "name": "frontend_project", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "start": "npx webpack server --config ./config/webpack.dev.config.js", "build": "npx webpack --config ./config/webpack.prod.config.js" }, "author": "", "license": "ISC", "devDependencies": { "html-webpack-plugin": "^5.5.3", "webpack": "^5.87.0", "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.1", "webpack-merge": "^5.9.0" } }
运行命令
shellnpm start # 启动 npm run build # 打包
2 设置标题和站标
public/index.html
html<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title><%= htmlWebpackPlugin.options.title %></title> <link rel="icon" href="./favicon.ico"> </head> <body> <h1>Hello Shirly</h1> </body> </html>
安装插件 copy-webpack-plugin, 将 public 中的文件复制到 dist 中
html-loader 插件所有文件缺点 名字hash编码,路径默认放在dist目录,需要单独设置路径
shellnpm install copy-webpack-plugin -D
配置 config/webpack.base.config.js
jsconst path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const CopyWebpackPlugin = require('copy-webpack-plugin'); module.exports = { // 入口 entry: path.resolve(__dirname, '../src/app.js'), // 出口 output: { path: path.resolve(__dirname, '../dist'), filename: 'js/[name].[hash:12].js', clean: true, publicPath: '/' }, // 插件 plugins: [ // 打包 html 资源 new HtmlWebpackPlugin({ // 起始html文件 template: path.resolve(__dirname, '../public/index.html'), // 将 script 放在 body 结尾 inject: 'body', // 压缩优化 minify: { removeAttributeQuotes: true, // 去除引号 removeComments: true, // 去除注释 //collapseWhitespace: true, // 代码折叠 }, // 自定义的选项 title: '尚硅谷广告管理系统 shirly' }), // 将源目录(public)中的文件复制到目标目录(项目输出目录) new CopyWebpackPlugin({ patterns: [ { // 源目录 from: path.resolve(__dirname, '../public'), // 目标目录 to: path.resolve(__dirname, '../dist'), globOptions: { ignore: ['**/**.html'] // 忽略所有的html文件 } }, ], }), ] }
3 使用 sme-router 构建单页面应用
安装 sme-router 这个是在npmjs.com
vue-router 也是nodejs中的
shellnpm install sme-router 作用是路由 前端的路由
在 src/app.js 中使用
js// 导入 import SMERouter from 'sme-router' // 创建实例 // 默认是 hash 模式,特点 http://adv.manage.fuming.site/#/index/advlist // 可以改为 history 模式,特点 http://adv.manage.fuming.site/index/advlist const router = new SMERouter('app') // 这里的app是index.html 舞台文件的标签中的id标签 router.route('/index', (req, res) => { res.render('首页'); }); router.route('/admin', (req, res) => { res.render('管理员列表'); }); router.route('/adv', (req, res) => { res.render('广告列表'); }); router.route('/login', (req, res) => { res.render('登录'); }); router.route('*', (req, res) => { res.redirect('/index'); })
允许使用 history 模式,配置文件 config/webpack.dev.config.js
jsconst {merge} = require('webpack-merge'); const baseConfig = require('./webpack.base.config'); module.exports = merge(baseConfig, { // 模式 mode: 'development', // 自动开启服务 devServer: { host: 'shirly.com', port: '80', open: true, // 开发中,允许前端路由使用 history 模式 historyApiFallback: true } })
javascript// history模式 在入口文件 需要设置 第二个参数 const router = new SMERouter('app',"html5")
4 使用 ejs 模板
安装 loader,需要把ejs模版文件导入到js文件中,作为一个模块使用
shellnpm install ejs-loader -D
配置 config/webpack.base.config.js
jsconst path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const CopyWebpackPlugin = require('copy-webpack-plugin'); module.exports = { // 入口 entry: path.resolve(__dirname, '../src/app.js'), // 出口 output: { path: path.resolve(__dirname, '../dist'), filename: 'js/[name].[hash:12].js', clean: true, publicPath: '/' }, // loaders module: { rules: [ { test: /\.ejs$/, loader: 'ejs-loader', options: { variable: 'data', // 自定义定界符 替代 <% %> 执行其中语句 //interpolate : '\\{\\{(.+?)\\}\\}', // 自定义定界符 替代 <%= %> 原样显示 //evaluate : '\\[\\[(.+?)\\]\\]' } } ] }, // 插件 plugins: [ // 打包 html 资源 new HtmlWebpackPlugin({ // 起始html文件 template: path.resolve(__dirname, '../public/index.html'), // 将 script 放在 body 结尾 inject: 'body', // 压缩优化 minify: { removeAttributeQuotes: true, // 去除引号 removeComments: true, // 去除注释 //collapseWhitespace: true, // 代码折叠 }, // 自定义的选项 title: '尚硅谷广告管理系统 shirly' }), // 将源目录(public)中的文件复制到目标目录(项目输出目录) new CopyWebpackPlugin({ patterns: [ { from: path.resolve(__dirname, '../public'), to: path.resolve(__dirname, '../dist'), globOptions: { ignore: ['**/**.html'] } }, ], }), ] }
创建模板文件 views/index.js、 views/admin.js、views/login.js、views/adv.js
在 src/app.js 中引入模板并使用
js// 导入 import SMERouter from 'sme-router' // 导入一个模板 import indexV from './views/index.ejs'; import adminV from './views/admin.ejs'; import loginV from './views/login.ejs'; import advV from './views/adv.ejs'; // indexV 是一个函数,调用indexV的返回值 是模版中的内容 // 创建实例 // 默认是 hash 模式,特点 http://adv.manage.fuming.site/#/index/advlist // 可以改为 history 模式,需要第二个参数设置为 html5 特点 http://adv.manage.fuming.site/index/advlist const router = new SMERouter('app', 'html5') router.route('/index', (req, res) => { //console.log(indexV); res.render(indexV({title:'Shirly', message:'锄禾日当午'})); }); router.route('/admin', (req, res) => { res.render(adminV()); }); router.route('/adv', (req, res) => { res.render(advV()); }); router.route('/login', (req, res) => { res.render(loginV()); }); router.route('*', (req, res) => { res.redirect('/index'); })
模版配置
1. 需要把 原来index.html 中的 wrapper标签中的 内容放在 index.ejs
2. 同时还需要把 原来index.html 中的css js 标签放在 舞台文件 ./public/index.html 中
3. 把原 *.html 文件中的静态文件 css js 字体 图片 放在 ./public/文件夹中
5 省略 ejs 扩展名和路径别名
修改配置文件
config/webpack.base.config.js
jsconst path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const CopyWebpackPlugin = require('copy-webpack-plugin'); module.exports = { // 插件 plugins: [ ], // 解析 resolve: { // 设置可以省略的扩展名 extensions: ['.js', '.json', '.ejs'], // 设置路径别名 alias: { '@': path.resolve(__dirname, '../src') } } }
导入模板文件可以省略扩展名
src/app.js
js// 导入 import SMERouter from 'sme-router' // 导入一个模板 import indexV from '@/views/index'; import adminV from '@/views/admin'; import loginV from '@/views/login'; import advV from '@/views/adv'; // 创建实例 // 默认是 hash 模式,特点 http://adv.manage.fuming.site/#/index/advlist // 可以改为 history 模式,需要第二个参数设置为 html5 特点 http://adv.manage.fuming.site/index/advlist const router = new SMERouter('app', 'html5') router.route('/index', (req, res) => { //console.log(indexV); res.render(indexV({title:'Shirly', message:'锄禾日当午'})); }); router.route('/admin', (req, res) => { res.render(adminV()); }); router.route('/adv', (req, res) => { res.render(advV()); }); router.route('/login', (req, res) => { res.render(loginV()); }); router.route('*', (req, res) => { res.redirect('/index'); })
6 二级路由 呈现页面
二级路由设置
src/app.js
js// 导入 import SMERouter from 'sme-router' // 导入一个模板 import indexV from '@/views/index'; import adminV from '@/views/admin'; import loginV from '@/views/login'; import advV from '@/views/adv'; // 创建实例 // 默认是 hash 模式,特点 http://adv.manage.fuming.site/#/index/advlist // 可以改为 history 模式,需要第二个参数设置为 html5 特点 http://adv.manage.fuming.site/index/advlist const router = new SMERouter('app', 'html5') router.route('/index', (req, res, next) => { //res.render(indexV({title:'Shirly', message:'锄禾日当午'})); // res.render(` // <h1>首页</h1> // ${res.subRoute()} // `) // next函数可以 让二级路由的页面内容 呈现该该路由页面内容的内部 next(` <h1>首页</h1> ${res.subRoute()} `) console.log('/index'); }); router.route('/index/admin', (req, res) => { //res.render(adminV()); res.render(`<h2>管理员列表</h2>`); console.log('/index/admin'); }); router.route('/index/adv', (req, res) => { res.render(advV()); console.log('/index/adv'); }); router.route('/login', (req, res) => { res.render(loginV()); }); router.route('*', (req, res) => { res.redirect('/index'); })
加载模板
src/app.js
jsrouter.route('/index', (req, res, next) => { // next函数可以 让二级路由的页面内容 呈现该该路由页面内容的内部 // 渲染一个首页模版,把子路由的内容,添加到 subRouteContent 处, next(indexV({ subRouteContent: res.subRoute() })) }); router.route('/index/admin', (req, res) => { res.render(adminV()); }); router.route('/index/adv', (req, res) => { res.render(advV()); });
/index
对应的模板文本/views/index.ejs
ejs<div class="wrapper"> <!-- 顶部导航 --> 省略内容 <!-- 侧边栏 --> 省略内容 <!-- 主要内容 --> <div class="content-wrapper"> <!-- Content Header (Page header) --> <div class="content-header"> <div class="container-fluid"> <div class="row mb-2"> <div class="col-sm-6"> <h1 class="m-0">页面标题</h1> </div> </div><!-- /.row --> </div><!-- /.container-fluid --> </div> <!-- /.content-header --> <!-- Main content --> <div class="content"> <div class="container-fluid"> // 这里是 子路由传来的内容 data.subRouteContent // data 来自indexV(data) 函数形式参数 在ejs的loader中定义 // subRouteContent 是父路由定义的对象中的属性 index({subRouteContent:}) <%= data.subRouteContent %> </div><!-- /.container-fluid --> </div> <!-- /.content --> </div> <!-- Main Footer --> 省略内容
/ : 代表网站根目录,不加 / 默认在后边拼接
<link rel="stylesheet" href="/css/fontawesome.min.css"> / 这里代表 网站根目录
/ : 代表网站根目录,不加 / 默认在后边拼接
二级路由过程:
1 http://127.0.0.1:8080/index/admin
2 会同时匹配index 路由和 admin 路由(区别于后端express精准匹配)
3 通过index路由时,渲染一个页面,使用next方法,让子路由内容添加到这个页面中某处。
4 通过admin路由时,渲染一个页面,子路由页面得到渲染,通过res.subRoute()方法,把内容传递给父路由某处
res.subRoute()方法是 sme-route npm包自带的 方法,用于获取子路由的内容。
7 管理员列表模板页面和广告列表模板页
views/admin.ejs
ejs<div id="__sub-route-view"> <div class="card adminlist"> ... </div> </div> <!-- 模态框 --> <div class="modal fade" id="addAdminModal" style="display: none;" aria-hidden="true"> ... </div>
views/adv.ejs
javascript<div id="__sub-route-view"> <div class="card"> ... </div> </div>
views/login.ejs
ejs<div class="wrapper"> ... </div>
8 页面组件化
components/Header.ejs
ejs<nav class="main-header navbar navbar-expand navbar-white navbar-light"> ... </nav>
components/Sidebar.ejs
ejs<aside class="main-sidebar sidebar-dark-primary elevation-4"> ... </aside>
components/Footer.ejs
ejs<footer class="main-footer"> <!-- To the right --> <div class="float-right d-none d-sm-inline"> Anything you want </div> <!-- Default to the left --> <strong>Copyright © 2023-2024 <a href="http://learn.fuming.site">尚硅谷</a>.</strong> All rights reserved. </footer>
components/Content.ejs
js<div class="content-wrapper"> <!-- Content Header (Page header) --> <div class="content-header"> <div class="container-fluid"> <div class="row mb-2"> <div class="col-sm-6"> <h1 class="m-0">页面标题</h1> </div> </div><!-- /.row --> </div><!-- /.container-fluid --> </div> <!-- /.content-header --> <!-- Main content --> <div class="content"> <div class="container-fluid"> <%= data.subRouteContent %> </div><!-- /.container-fluid --> </div> <!-- /.content --> </div>
src/app.js
js// 导入 import SMERouter from 'sme-router' // 导入组件 import HeaderComponent from '@/components/Header'; import SidebarComponent from '@/components/Sidebar'; import FooterComponent from '@/components/Footer'; import ContentComponent from '@/components/Content'; // 导入一个模板 import indexV from '@/views/index'; import adminV from '@/views/admin'; import loginV from '@/views/login'; import advV from '@/views/adv'; // 创建实例 // 默认是 hash 模式,特点 http://adv.manage.fuming.site/#/index/advlist // 可以改为 history 模式,需要第二个参数设置为 html5 特点 http://adv.manage.fuming.site/index/advlist const router = new SMERouter('app', 'html5') router.route('/index', (req, res, next) => { // next函数可以 让二级路由的页面内容 呈现该该路由页面内容的内部 next(indexV({ // 页头组件 Header: HeaderComponent(), // 侧边栏组件 Sidebar: SidebarComponent(), // 页脚组件 Footer: FooterComponent(), // 主要内容组件 Content: ContentComponent({ // 把二级路由放在 组件中 subRouteContent: res.subRoute() }), })) }); router.route('/index/admin', (req, res) => { res.render(adminV()); }); router.route('/index/adv', (req, res) => { res.render(advV()); console.log('/index/adv'); }); router.route('/login', (req, res) => { res.render(loginV()); }); router.route('*', (req, res) => { res.redirect('/index'); })
index.ejs模版
javascript<div class="wrapper"> <!-- 在app.js入口文件中把 内容传过来 --> <!-- 顶部导航 --> <%= data.Header %> <!-- 侧边栏 --> <%= data.Sidebar %> <!-- 主要内容 --> <%= data.Content %> <!-- Footer --> <%= data.Footer %> </div>
9 抽离控制器
控制器:路由后边执行的函数 抽离出来 单独放在一个文件夹中
src/controllers/index.js
js// 导入组件 import HeaderComponent from '@/components/Header'; import SidebarComponent from '@/components/Sidebar'; import FooterComponent from '@/components/Footer'; import ContentComponent from '@/components/Content'; // 导入模板 import indexV from '@/views/index'; export default (req, res, next) => { // next函数可以 让二级路由的页面内容 呈现该该路由页面内容的内部 next(indexV({ // 页头组件 Header: HeaderComponent(), // 侧边栏组件 Sidebar: SidebarComponent(), // 页脚组件 Footer: FooterComponent(), // 主要内容组件 Content: ContentComponent({ subRouteContent: res.subRoute() }), })) }
src/controllers/admin.js
js// 导入模板页 import adminV from '@/views/admin'; export default (req, res) => { res.render(adminV()); }
src/controllers/adv.js
js// 导入模板页面 import advV from '@/views/adv'; export default (req, res) => { res.render(advV()); }
src/controllers/login.js
JS// 导入模板 import loginV from '@/views/login'; export default (req, res) => { res.render(loginV()); }
src/app.js
js// 导入 import SMERouter from 'sme-router' // 导入控制器 import indexC from '@/controllers/index'; import adminC from '@/controllers/admin'; import advC from '@/controllers/adv'; import loginC from '@/controllers/login'; // 创建实例 // 默认是 hash 模式,特点 http://adv.manage.fuming.site/#/index/advlist // 可以改为 history 模式,需要第二个参数设置为 html5 特点 http://adv.manage.fuming.site/index/advlist const router = new SMERouter('app', 'html5') router.route('/index', indexC); router.route('/index/admin', adminC); router.route('/index/adv', advC); router.route('/login', loginC); router.route('*', (req, res) => { res.redirect('/index/admin'); })
10 抽离路由配置
src/routes/index.js
js// 导入控制器 import indexC from '@/controllers/index'; import adminC from '@/controllers/admin'; import advC from '@/controllers/adv'; import loginC from '@/controllers/login'; export default [ { path: '/index', element: indexC }, { path: '/index/admin', element: adminC, }, { path: '/index/adv', element: advC }, { path: '/login', element: loginC }, { path: '*', element: (req, res) => { res.redirect('/index/admin') } } ]
src/app.js
js// 导入 import SMERouter from 'sme-router' // 导入路由配置 import routes from './routes'; // 创建实例 // 默认是 hash 模式,特点 http://adv.manage.fuming.site/#/index/advlist // 可以改为 history 模式,需要第二个参数设置为 html5 特点 http://adv.manage.fuming.site/index/advlist const router = new SMERouter('app', 'html5') // 遍历路由配置 设置路由 routes.forEach(routeItem => { router.route(routeItem.path, routeItem.element); })
11 路由导航
第一种情况:点击刷新
src/components/Sidebar.ejs
html... <nav class="mt-2"> <ul class="nav nav-pills nav-sidebar flex-column" data-widget="treeview" role="menu" data-accordion="false"> <!-- 菜单列表 --> <li class="nav-item menu-open"> <!-- 更改这里的href --> <a href="/index/admin" class="nav-link"> <i class="nav-icon fas fa-user"></i> <p>管理员列表</p> </a> </li> <li class="nav-item menu-open"> <!-- 更改这里的href --> <a href="/index/adv" class="nav-link"> <i class="nav-icon fas fa-audio-description"></i> <p>广告管理列表</p> </a> </li> </ul> </nav> ...
第一种情况:点击不刷新
src/components/Sidebar.ejs
javascript... <nav class="mt-2"> <ul class="nav nav-pills nav-sidebar flex-column" data-widget="treeview" role="menu" data-accordion="false"> <!-- 菜单列表 --> <li class="nav-item menu-open"> <!-- 更改这里的href 阻止浏览器默认行为--> <!-- 添加onclick监听事件 使用暴漏的全局对象 router 的go()方法 跳转页面--> <a href="javascript:;" class="nav-link" onclick="router.go('/index/admin')" > <i class="nav-icon fas fa-user"></i> <p>管理员列表</p> </a> </li> <li class="nav-item menu-open"> <!-- 更改这里的href 阻止浏览器默认行为--> <a href="javascript:;" class="nav-link"> <i class="nav-icon fas fa-audio-description"></i> <p>广告管理列表</p> </a> </li> </ul> </nav> ...
src/app.js
const router = new SMERouter('app', 'html5');
// 将 router 暴露到全局,webpack默认是模块化,每一个js是一个单独的作用域,默认没有暴漏到全局
window.router = router;
// 使用 router 的go()方法 无刷新跳转页面
设置导航高亮
src/components/Sidebar.ejs
ejs... <!-- 菜单列表 --> <% data.routes.filter(item=>item.isNav).forEach(item => { %> <li class="nav-item menu-open"> <!-- 判断url和path是否一致,用于高亮显示active --> <a href="javascript:;" class="nav-link <%= data.url === item.path ? 'active' : '' %>" onclick="router.go('<%= item.path %>')"> <!-- 显示导航的logo --> <i class="nav-icon fas <%= item.icon %>"></i> <!-- 显示导航的标题 --> <p><%= item.title %></p> </a> </li> <% }) %> ...
src/controllers/index.js
js// 导入模板 import indexV from '@/views/index'; // 导入组件 import HeaderComponent from '@/components/Header'; import SidebarComponent from '@/components/Sidebar'; import FooterComponent from '@/components/Footer'; import ContentComponent from '@/components/Content'; // indexV 是一个ejs-loader 包做处理的函数 ,调用indexV的返回值 是模版中的内容,indexV(data) // HeaderComponent 同样也是一个函数 调用的返回值 是模版中的内容 // 导入路由配置模块,谁用谁导入 import routes from '@/routes'; // 这里使用 req.url 方法得到路由,使用结构赋值简写为{url} export default ({url}, res, next) => { // res.render(indexV({title:'Shirly', message:'锄禾日当午'})); next(indexV({ // 页头组件 Header:HeaderComponent(), // 侧边栏组件 Sidebar: SidebarComponent({ // url:req.url url, routes }), // 页脚组件 Footer: FooterComponent(), // 主要内容组件 Content: ContentComponent({ // 把二级路由放在 组件中 subRouteContent: res.subRoute() }), })); }
12 渲染导航列表
注意: 本项目已经引入了字体图标库 fontawesome, 地址:https://fontawesome.com.cn/
src/routes/index.js
js// 导入控制器 import indexC from '@/controllers/index'; import adminC from '@/controllers/admin'; import advC from '@/controllers/adv'; import loginC from '@/controllers/login'; export default [ { path: '/index', element: indexC }, { path: '/index/admin', element: adminC, isNav: true, title: '管理员列表', icon: 'fa-user', }, { path: '/index/adv', element: advC, isNav: true, title: '广告列表', icon: 'fa-audio-description' }, { path: '/index', element: advC, isNav: true, title: '首页', icon: 'fa-home' }, { path: '/login', element: loginC }, { path: '*', element: (req, res) => { res.redirect('/index/admin') } } ]
src/controllers/index.js
js// 导入组件 import HeaderComponent from '@/components/Header'; import SidebarComponent from '@/components/Sidebar'; import FooterComponent from '@/components/Footer'; import ContentComponent from '@/components/Content'; // 导入模板 import indexV from '@/views/index'; // 导入路由配置对象 import routes from '../routes'; export default ({url}, res, next) => { // next函数可以 让二级路由的页面内容 呈现该该路由页面内容的内部 next(indexV({ // 页头组件 Header: HeaderComponent(), // 侧边栏组件 Sidebar: SidebarComponent({ url, routes }), // 页脚组件 Footer: FooterComponent(), // 主要内容组件 Content: ContentComponent({ subRouteContent: res.subRoute() }), })) }
src/components/Sidebar.ejs
javascript<aside class="main-sidebar sidebar-dark-primary elevation-4"> <% data.routes.filter(item=>item.isNav).forEach(item => { %> <li class="nav-item menu-open"> <a href="javascript:;" class="nav-link <%= data.url === item.path ? 'active' : '' %>" onclick="router.go('<%= item.path %>')"> <i class="nav-icon fas <%= item.icon %>"></i> <p><%= item.title %></p> </a> </li> <% }) %> </aside>
13 不同页面显示不同 title
src/controllers/index.js
js// 导入组件 import HeaderComponent from '@/components/Header'; import SidebarComponent from '@/components/Sidebar'; import FooterComponent from '@/components/Footer'; import ContentComponent from '@/components/Content'; // 导入模板 import indexV from '@/views/index'; // 导入路由配置对象 import routes from '../routes'; export default ({url}, res, next) => { // next函数可以 让二级路由的页面内容 呈现该该路由页面内容的内部 next(indexV({ // 页头组件 Header: HeaderComponent(), // 侧边栏组件 Sidebar: SidebarComponent({ url, routes }), // 页脚组件 Footer: FooterComponent(), // 主要内容组件 Content: ContentComponent({ title: routes.find(item=>item.path===url).title, subRouteContent: res.subRoute() }), })) }
src/components/Content.ejs
ejs<div class="content-wrapper"> <!-- Content Header (Page header) --> <div class="content-header"> <div class="container-fluid"> <div class="row mb-2"> <div class="col-sm-6"> <h1 class="m-0"><%= data.title %></h1> </div> </div><!-- /.row --> </div><!-- /.container-fluid --> </div> <!-- /.content-header --> <!-- Main content --> <div class="content"> <div class="container-fluid"> <%= data.subRouteContent %> </div><!-- /.container-fluid --> </div> <!-- /.content --> </div>
14 支持 LESS
安装依赖
shellnpm install style-loader css-loader less-loader less -D
config/webpack.base.config.js
jsmodule: { rules: [ { test: /\.less$/, use: ['style-loader', 'css-loader', 'less-loader'] } ] },
src/assets/less/app.less
less#advTable { table { min-width: 1200px; } }
src/views/adv.ejs
javascript// 加入自动滚动条 table-responsive bootstrap样式属性 <div id="advTable" class="card-body table-responsive"> <table id="" class="table table-bordered"> ... </table> </div>
src/app.js
// 入口文件导入 因为只有一个页面 只需要导入一个就可以
// 导入
import SMERouter from 'sme-router';
// 导入路由配置
import routes from './routes';
// 导入less
import './assets/less/app.less'
// 创建实例
// 默认是 hash 模式,特点 http://adv.manage.fuming.site/#/index/advlist
// 可以改为 history 模式,特点 http://adv.manage.fuming.site/index/advlist
const router = new SMERouter('app',"html5");
// 遍历路由配置 设置路由
routes.forEach(routeItem => {
router.route(routeItem.path,routeItem.element)
})
// 暴漏router对象
window.router = router;
15 添加管理员验证数据
src/controllers/admin.js
js// 导入模板页 import adminV from '@/views/admin'; // 执行添加管理员账号 const addAdminExec = () => { // 获取表单数据 const adminName = document.adminForm.adminName.value.trim(); const passWord = document.adminForm.passWord.value.trim(); const rePassWord = document.adminForm.rePassWord.value.trim(); // 管理员账号只能由英文字母组成 ^[a-zA-Z]+$ if (adminName.search(/^[a-zA-Z]+$/) === -1) { alert('管理员账号只能由英文字母组成!'); return; } // 密码是6-18位的数字、字母、下划线 ^\w{6,18}$ if (passWord.search(/^\w{6,18}$/) === -1) { alert('密码必须由6到18位数字、字母、下划线组成'); return; } // 确认密码 if (passWord !== rePassWord) { alert('两次密码不一致'); return; } } export default (req, res) => { // 加载模板内容 res.render(adminV()); // 给添加按钮监听click事件 document.querySelector('#addAdminBtn').addEventListener('click', addAdminExec) }
src/views/admin.ejs
ejs... <div class="modal-body"> <form name="adminForm" id="addAdminForm"> <div class="form-group"> <label for="exampleInputEmail1">管理员账号</label> <input type="text" name="adminName" class="form-control" id="exampleInputEmail1" placeholder="请输入管理员账号"> </div> <div class="form-group"> <label for="exampleInputPassword1">管理员密码</label> <input type="password" name="passWord" class="form-control" id="exampleInputPassword1" placeholder="请输入管理员密码"> </div> <div class="form-group"> <label for="exampleInputPassword2">重复密码</label> <input name="rePassWord" type="password" class="form-control" id="exampleInputPassword2" placeholder="请输入管理员密码"> </div> </form> </div> ...
16 使用 toastr 提示信息
安装依赖 产品依赖
shellnpm install toastr
修改配置
config/webpack.base.config.js
jsmodule: { rules: [ { test: /\.css$/, use: ['style-loader', 'css-loader'] } ] },
使用
src/controllers/admin.js
js// 导入 toastr import toastr from 'toastr'; import '../../node_modules/toastr/build/toastr.css'; // 导入模板页 import adminV from '@/views/admin'; // 执行添加管理员账号 const addAdminExec = () => { // 获取表单数据 const adminName = document.adminForm.adminName.value.trim(); const passWord = document.adminForm.passWord.value.trim(); const rePassWord = document.adminForm.rePassWord.value.trim(); // 管理员账号只能由英文字母组成 ^[a-zA-Z]+$ if (adminName.search(/^[a-zA-Z]+$/) === -1) { toastr.error('管理员账号只能由英文字母组成!'); return; } // 密码是6-18位的数字、字母、下划线 ^\w{6,18}$ if (passWord.search(/^\w{6,18}$/) === -1) { toastr.error('密码必须由6到18位数字、字母、下划线组成'); return; } // 确认密码 if (passWord !== rePassWord) { toastr.error('两次密码不一致'); return; } } export default (req, res) => { // 加载模板内容 res.render(adminV()); // 给添加按钮监听click事件 document.querySelector('#addAdminBtn').addEventListener('click', addAdminExec) }
17 设置代理并调用添加管理员接口
安装 axios 产品依赖
shellnpm install axios
设置代理
config/webpack.dev.config.js
jsconst {merge} = require('webpack-merge'); const baseConfig = require('./webpack.base.config'); module.exports = merge(baseConfig, { // 模式 mode: 'development', // 自动开启服务 devServer: { host: 'shirly.com', port: '80', open: true, // 开发中,允许前端路由使用 history 模式 historyApiFallback: true, proxy: [ { context: ['/api'], target: 'http://192.168.1.198:8088', secure: false, pathRewrite: { '^/api': '' }, }, ] } })
使用 axios 发送请求
src/controller/admin.js
js// 导入 toastr import toastr from 'toastr'; import '../../node_modules/toastr/build/toastr.css'; // 导入 axios import axios from 'axios'; // 导入模板页 import adminV from '@/views/admin'; // 执行添加管理员账号 const addAdminExec = () => { // 获取表单数据 const adminName = document.adminForm.adminName.value.trim(); const passWord = document.adminForm.passWord.value.trim(); const rePassWord = document.adminForm.rePassWord.value.trim(); // 管理员账号只能由英文字母组成 ^[a-zA-Z]+$ if (adminName.search(/^[a-zA-Z]+$/) === -1) { toastr.error('管理员账号只能由英文字母组成!'); return; } // 密码是6-18位的数字、字母、下划线 ^\w{6,18}$ if (passWord.search(/^\w{6,18}$/) === -1) { toastr.error('密码必须由6到18位数字、字母、下划线组成'); return; } // 确认密码 if (passWord !== rePassWord) { toastr.error('两次密码不一致'); return; } // 使用 axios 发送请求 axios .post('/api/admin', { adminName, passWord }) .then( res => { console.log(res); }) .catch (err => { console.log('请示失败', err); }) } export default (req, res) => { // 加载模板内容 res.render(adminV()); // 给添加按钮监听click事件 document.querySelector('#addAdminBtn').addEventListener('click', addAdminExec) }
18 axios 响应拦截器统一处理可能出现的错误
src/request/advserver.js
创建请求实例,设置响应拦截器,统一处理可能出现的错误js// 导入 axios import axios from 'axios'; import toastr from 'toastr'; // 创建 axios 实例 const advServer = axios.create({ baseURL: '/api', timeout: 5000 }); // 给 实例设置响应拦截器 advServer.interceptors.response.use(res => { if (res.data.ok !== 1) { toastr.error(res.data.msg); // 返回不改变状态的 Promise,执行链结束了 return new Promise(()=>{}); } return res.data; }, error => { toastr.error('请求错误!'); // 返回不改变状态的 Promise,执行链结束了 return new Promise(()=>{}); }); // 暴露实例 export default advServer;
src/controllers/admin.js
js// 导入 toastr import toastr from 'toastr'; import '../../node_modules/toastr/build/toastr.css'; // 导入请求实例 import advServer from '../request/advserver'; // 导入模板页 import adminV from '@/views/admin'; // 执行添加管理员账号 const addAdminExec = () => { // 获取表单数据 const adminName = document.adminForm.adminName.value.trim(); const passWord = document.adminForm.passWord.value.trim(); const rePassWord = document.adminForm.rePassWord.value.trim(); // 管理员账号只能由英文字母组成 ^[a-zA-Z]+$ if (adminName.search(/^[a-zA-Z]+$/) === -1) { toastr.error('管理员账号只能由英文字母组成!'); return; } // 密码是6-18位的数字、字母、下划线 ^\w{6,18}$ if (passWord.search(/^\w{6,18}$/) === -1) { toastr.error('密码必须由6到18位数字、字母、下划线组成'); return; } // 确认密码 if (passWord !== rePassWord) { toastr.error('两次密码不一致'); return; } // 使用 axios 发送请求 advServer .post('/admin', { adminName, passWord }) .then( res => { toastr.success('管理员添加成功'); console.log('成功:',res); }) } export default (req, res) => { // 加载模板内容 res.render(adminV()); // 给添加按钮监听click事件 document.querySelector('#addAdminBtn').addEventListener('click', addAdminExec) }
结构分析图
代理服务器原理图
19 axios 二次封装
src/api/admin.js
api文件跟后端接口对应,一个或多个后端接口对应一个函数js// 导入实例 import advServer from '../request/advserver'; // 添加管理员 export const postAdmin = body => { return advServer.post('/admin', body); } // 获取管理员 export const getAdmin = () => { return advServer.get('/admin'); } // 删除指定id的管理员 export const deleteAdmin = id => { return advServer.delete('/admin/'+id); } // 修改密码 export const changePassword = body => { return advServer.patch('/changpwd', body) }
控制器中导入 api 暴露的函数
js// 导入 toastr import toastr from 'toastr'; import '../../node_modules/toastr/build/toastr.css'; // 导入发送请求的函数 import {postAdmin} from '../api/admin'; // 导入模板页 import adminV from '@/views/admin'; // 执行添加管理员账号 const addAdminExec = () => { // 获取表单数据 const adminName = document.adminForm.adminName.value.trim(); const passWord = document.adminForm.passWord.value.trim(); const rePassWord = document.adminForm.rePassWord.value.trim(); // 管理员账号只能由英文字母组成 ^[a-zA-Z]+$ if (adminName.search(/^[a-zA-Z]+$/) === -1) { toastr.error('管理员账号只能由英文字母组成!'); return; } // 密码是6-18位的数字、字母、下划线 ^\w{6,18}$ if (passWord.search(/^\w{6,18}$/) === -1) { toastr.error('密码必须由6到18位数字、字母、下划线组成'); return; } // 确认密码 if (passWord !== rePassWord) { toastr.error('两次密码不一致'); return; } // 调用后端API 添加管理员 postAdmin({ adminName, passWord }) .then( res => { toastr.success('管理员添加成功'); console.log('成功:',res); }) } export default (req, res) => { // 加载模板内容 res.render(adminV()); // 给添加按钮监听click事件 document.querySelector('#addAdminBtn').addEventListener('click', addAdminExec) }
20 获取管理员数据并渲染页面
src/controllers/admin.js
控制器js// 导入 toastr import toastr from 'toastr'; import '../../node_modules/toastr/build/toastr.css'; // 导入发送请求的函数 import {postAdmin, getAdmin} from '../api/admin'; // 导入管理员列表表格的组件 import AdminTabelComponent from '../components/AdminTable'; // 导入模板页 import adminV from '@/views/admin'; // 执行添加管理员账号 const addAdminExec = () => { // 获取表单数据 const adminName = document.adminForm.adminName.value.trim(); const passWord = document.adminForm.passWord.value.trim(); const rePassWord = document.adminForm.rePassWord.value.trim(); // 管理员账号只能由英文字母组成 ^[a-zA-Z]+$ if (adminName.search(/^[a-zA-Z]+$/) === -1) { toastr.error('管理员账号只能由英文字母组成!'); return; } // 密码是6-18位的数字、字母、下划线 ^\w{6,18}$ if (passWord.search(/^\w{6,18}$/) === -1) { toastr.error('密码必须由6到18位数字、字母、下划线组成'); return; } // 确认密码 if (passWord !== rePassWord) { toastr.error('两次密码不一致'); return; } // 调用后端API 添加管理员 postAdmin({ adminName, passWord }) .then( res => { toastr.success('管理员添加成功'); console.log('成功:',res); }) } // 获取管理员信息 并渲染页面 const getAdminExec = () => { // 向后端API请求 getAdmin() .then(res => { // 将数据发送到管理员表格组件中 将组件设置到模板的指定位置 document.querySelector('#amdinListBox').innerHTML = AdminTabelComponent({adminList: res.data}) }); } export default (req, res) => { // 加载模板内容 res.render(adminV()); // 获取管理员信息 getAdminExec(); // 给添加按钮监听click事件 document.querySelector('#addAdminBtn').addEventListener('click', addAdminExec) }
src/components/AdminTable.ejs
组件javascript<table class="table table-bordered"> ... <tbody> <% data.adminList.forEach(adminItem => { %> <tr> <!-- <td>648d3b13dbeadd55199ab0a7</td> --> <td><%= adminItem.adminName %></td> <td><%= adminItem.lastLoginTime %></td> <td><%= adminItem.regTime %></td> <td> <button type="button" class="btn btn-danger" data-id="<%= adminItem._id %>">删除</button> <button type="button" class="btn btn-success">修改密码</button> </td> </tr> <% }) %> </tbody> </table>
21 完成添加管理员更新列表
src/controllers/admin.js
js// 导入 toastr import toastr from 'toastr'; import '../../node_modules/toastr/build/toastr.css'; // 导入发送请求的函数 import {postAdmin, getAdmin} from '../api/admin'; // 导入管理员列表表格的组件 import AdminTabelComponent from '../components/AdminTable'; // 导入模板页 import adminV from '@/views/admin'; // 执行添加管理员账号 const addAdminExec = () => { // 获取表单数据 const adminName = document.adminForm.adminName.value.trim(); const passWord = document.adminForm.passWord.value.trim(); const rePassWord = document.adminForm.rePassWord.value.trim(); // 管理员账号只能由英文字母组成 ^[a-zA-Z]+$ if (adminName.search(/^[a-zA-Z]+$/) === -1) { toastr.error('管理员账号只能由英文字母组成!'); return; } // 密码是6-18位的数字、字母、下划线 ^\w{6,18}$ if (passWord.search(/^\w{6,18}$/) === -1) { toastr.error('密码必须由6到18位数字、字母、下划线组成'); return; } // 确认密码 if (passWord !== rePassWord) { toastr.error('两次密码不一致'); return; } // 调用后端API 添加管理员 postAdmin({ adminName, passWord }) .then( res => { toastr.success('管理员添加成功'); // 关闭模态框 $('#addAdminModal').modal('hide'); // 清空表单 document.adminForm.reset(); // 更新列表 getAdminExec(); }); } // 获取管理员信息 并渲染页面 const getAdminExec = () => { // 向后端API请求 getAdmin() .then(res => { // 将数据发送到管理员表格组件中 将组件设置到模板的指定位置 document.querySelector('#amdinListBox').innerHTML = AdminTabelComponent({adminList: res.data}) }); } export default (req, res) => { // 加载模板内容 res.render(adminV()); // 获取管理员信息 getAdminExec(); // 给添加按钮监听click事件 document.querySelector('#addAdminBtn').addEventListener('click', addAdminExec) }
22 删除管理员
src/controllers/admin.js
js// 导入 toastr import toastr from 'toastr'; import '../../node_modules/toastr/build/toastr.css'; // 导入发送请求的函数 import {postAdmin, getAdmin, deleteAdmin} from '../api/admin'; // 导入管理员列表表格的组件 import AdminTabelComponent from '../components/AdminTable'; // 导入模板页 import adminV from '@/views/admin'; // 执行添加管理员账号 const addAdminExec = () => { // 获取表单数据 const adminName = document.adminForm.adminName.value.trim(); const passWord = document.adminForm.passWord.value.trim(); const rePassWord = document.adminForm.rePassWord.value.trim(); // 管理员账号只能由英文字母组成 ^[a-zA-Z]+$ if (adminName.search(/^[a-zA-Z]+$/) === -1) { toastr.error('管理员账号只能由英文字母组成!'); return; } // 密码是6-18位的数字、字母、下划线 ^\w{6,18}$ if (passWord.search(/^\w{6,18}$/) === -1) { toastr.error('密码必须由6到18位数字、字母、下划线组成'); return; } // 确认密码 if (passWord !== rePassWord) { toastr.error('两次密码不一致'); return; } // 调用后端API 添加管理员 postAdmin({ adminName, passWord }) .then( res => { toastr.success('管理员添加成功'); // 关闭模态框 $('#addAdminModal').modal('hide'); // 清空表单 document.adminForm.reset(); // 更新列表 getAdminExec(); }); } // 获取管理员信息 并渲染页面 const getAdminExec = () => { // 向后端API请求 getAdmin() .then(res => { // 将数据发送到管理员表格组件中 将组件设置到模板的指定位置 document.querySelector('#amdinListBox').innerHTML = AdminTabelComponent({adminList: res.data}) }); } // 执行管理员的删除 const deleteAdminExec = event => { // 判断目标元素是否是删除按钮 if (event.target.classList.contains('btn-danger')) { // 弹出确认框 if (confirm('确定删除?')) { // 调用后端API执行删除 deleteAdmin(event.target.dataset.id) .then(res => { alert('删除成功!'); // 更新列表 getAdminExec(); }) } } }; export default (req, res) => { // 加载模板内容 res.render(adminV()); // 获取管理员信息 getAdminExec(); // 给添加按钮监听click事件 document.querySelector('#addAdminBtn').addEventListener('click', addAdminExec) // 给管理员列表中的删除按钮监听单击事件 事件位置 document.querySelector('#amdinListBox').addEventListener('click', deleteAdminExec) }
23 使用 sweetalert2 优化确认框
安装依赖 产品依赖
shellnpm install sweetalert2
src/controllers/admin.js
js// 导入 sweetalert2 import Swal from 'sweetalert2'; // 执行管理员的删除 const deleteAdminExec = event => { // 判断目标元素是否是删除按钮 if (event.target.classList.contains('btn-danger')) { // 弹出确认框 Swal.fire({ title: '确定删除?', icon: 'warning', showCancelButton: true, confirmButtonText: '确定', cancelButtonText: '取消' }).then((result) => { if (result.isConfirmed) { // 调用后端API执行删除 deleteAdmin(event.target.dataset.id) .then(res => { // 更新列表 getAdminExec(); // 弹框提示删除成功 Swal.fire({ title: '删除成功!', icon: 'success' }) }) } }) } };
24 登录并显示账号名
src/controllers/login.js
控制jsimport toastr from 'toastr'; // 导入模板 import loginV from '@/views/login'; // 导入api函数 import {postLogin} from '../api/login'; // 执行登录的函数 const postLoginExec = event => { // 阻止默认行为 event.preventDefault(); // 获取表单数据 const adminName = document.loginForm.adminName.value.trim(); const passWord = document.loginForm.passWord.value.trim(); // 账号不能为空 if (adminName.length === 0) { toastr.error('请填写管理员账号!'); return; } // 密码不能为空 if (passWord.length === 0) { toastr.error('密码不能为空!'); return; } // 使用后端API 发送数据执行登录 postLogin({ adminName, passWord }) .then(res => { // 将用户信息记录到 localStorage localStorage.setItem('adminName', adminName); // 跳转管理员列表页 router.go('/index/admin'); }); } export default (req, res) => { // 渲染模板 res.render(loginV()); // 给表单监听提交事件 document.loginForm.addEventListener('submit', postLoginExec) }
src/views/login.ejs
模板文件javascript<div class="wrapper"> <div class="login-page"> <div class="login-box"> <!-- /.login-logo --> <div class="card"> <div class="card-body login-card-body"> <p class="login-box-msg">尚硅谷广告后台管理系统</p> <form name="loginForm"> <div class="input-group mb-3"> <input name="adminName" type="text" class="form-control" placeholder="管理员账号"> <div class="input-group-append"> <div class="input-group-text"> <span class="fas fa-user"></span> </div> </div> </div> <div class="input-group mb-3"> <input name="passWord" type="password" class="form-control" placeholder="请输入密码"> <div class="input-group-append"> <div class="input-group-text"> <span class="fas fa-lock"></span> </div> </div> </div> <div class="row"> <!-- /.col --> <div class="col-12"> <button type="submit" id="loginBtn" class="btn btn-primary btn-block">登陆</button> </div> <!-- /.col --> </div> </form> </div> <!-- /.login-card-body --> </div> </div> </div> </div>
src/api/login.js
APIjs// 导入实例 import advServer from '../request/advserver'; export const postLogin = body => { return advServer.post('/login', body); }
显示管理员账号名
src/components/Sidebar.ejs
javascript<!-- Sidebar user panel (optional) --> <div class="user-panel mt-3 pb-3 mb-3 d-flex"> <div class="info"> <a href="#" class="d-block">欢迎您:<%= localStorage.getItem('adminName') %></a> </div>
25 登录验证和退出登录
退出登录按钮所在的组件
src/components/Header.ejs
ejs<nav class="main-header navbar navbar-expand navbar-white navbar-light"> <!-- Left navbar links --> <ul class="navbar-nav"> <li class="nav-item"> <a class="nav-link" data-widget="pushmenu" href="#" role="button"><i class="fas fa-bars"></i></a> </li> </ul> <!-- Right navbar links --> <ul class="navbar-nav ml-auto"> <li class="nav-item"> <a class="nav-link" data-widget="control-sidebar" data-slide="true" href="javascript:;" role="button"> <button type="button" id="logoutBtn" class="btn btn-block btn-danger btn-sm">退出登陆</button> </a> </li> </ul> </nav>
src/controllers/index
该控制器导入了 Header 组件js// 导入组件 import HeaderComponent from '@/components/Header'; import SidebarComponent from '@/components/Sidebar'; import FooterComponent from '@/components/Footer'; import ContentComponent from '@/components/Content'; // 导入模板 import indexV from '@/views/index'; // 导入路由配置对象 import routes from '../routes'; export default ({url}, res, next) => { // next函数可以 让二级路由的页面内容 呈现该该路由页面内容的内部 next(indexV({ // 页头组件 Header: HeaderComponent(), // 侧边栏组件 Sidebar: SidebarComponent({ url, routes }), // 页脚组件 Footer: FooterComponent(), // 主要内容组件 Content: ContentComponent({ title: routes.find(item=>item.path===url).title, subRouteContent: res.subRoute() }), })); // 点击退出登录按钮 document.querySelector('#logoutBtn').addEventListener('click', () => { // 清空 localStorage localStorage.clear(); // 跳转到登录页面 router.go('/login'); }); }
全局验证是否登录,如果没有登录,跳转到登录页
src/app.js
js... // 如果没有登录 跳转到登录页 if (!localStorage.getItem('adminName')) { router.go('/login'); } // 遍历路由配置 设置路由 routes.forEach(routeItem => { router.route(routeItem.path, routeItem.element); })
26 使用 token 完善登录机制
前端 token 的使用步骤:
1. 登录之后,后端会创建 token 并响应给前端
2. 前端需要将 token 进行存储,localStorage或者sessionStorage或cookie
3. 以后,前端的每次请求,都在请求头里携带token, 如果没有token或者是无效的token,后端不会做出正确的响应
4. 退出登录,删除本地存储的 token
注意:token有时效率,超时会变无效token
{
ok: -2
msg: '登录异常,请求重新登录'
}
src/controllers/login.js
jsimport toastr from 'toastr'; // 导入模板 import loginV from '@/views/login'; // 导入api函数 import {postLogin} from '../api/login'; // 执行登录的函数 const postLoginExec = event => { // 阻止默认行为 event.preventDefault(); // 获取表单数据 const adminName = document.loginForm.adminName.value.trim(); const passWord = document.loginForm.passWord.value.trim(); // 账号不能为空 if (adminName.length === 0) { toastr.error('请填写管理员账号!'); return; } // 密码不能为空 if (passWord.length === 0) { toastr.error('密码不能为空!'); return; } // 使用后端API 发送数据执行登录 postLogin({ adminName, passWord }) .then(res => { console.log(res); // 将用户信息和用户名记录到 localStorage localStorage.setItem('adminName', adminName); localStorage.setItem('token', res.token); // 跳转管理员列表页 router.go('/index/admin'); }); } export default (req, res) => { // 渲染模板 res.render(loginV()); // 给表单监听提交事件 document.loginForm.addEventListener('submit', postLoginExec) }
src/request/advserver.js
js// 导入 axios import axios from 'axios'; import toastr from 'toastr'; // 创建 axios 实例 const advServer = axios.create({ baseURL: '/api', timeout: 5000 }); // 给实例添加请求拦截器 advServer.interceptors.request.use(config => { // 如果localStorage中有token,就添加到请求头 if (localStorage.getItem('token')) { config.headers.token = localStorage.getItem('token'); } return config; }); // 给 实例设置响应拦截器 advServer.interceptors.response.use(res => { if (res.data.ok === -1) { toastr.error(res.data.msg); // 返回不改变状态的 Promise,执行链结束了 return new Promise(()=>{}); } if (res.data.ok === -2) { toastr.error(res.data.msg); // 跳转到 登录页 router.go('/login'); // 清空localStorage localStorage.clear(); // 返回不改变状态的 Promise,执行链结束了 return new Promise(()=>{}); } return res.data; }, error => { toastr.error('请求错误!'); // 返回不改变状态的 Promise,执行链结束了 return new Promise(()=>{}); }); // 暴露实例 export default advServer;
27 添加广告:图片预览
src/controllers/adv.js
js// 导入模板页面 import advV from '@/views/adv'; // 执行图片预览 const prevImgExec = function() { // 获取file对象 const file = this.files[0]; // 判断 if (!file) { return; } // 实例化 FilerReader const reader = new FileReader() // 将文件读取成 base64 编码的形式 异步 reader.readAsDataURL(file); // 获取预览图元素 const preImgEle = document.querySelector('#preImg'); // 监听读取完毕 reader.onload = () => { // 将 base64编码的图片作为 src 属性的值 preImgEle.src = reader.result; preImgEle.style.display = 'block'; } } export default (req, res) => { // 渲染页面 res.render(advV()); // 给图片上传input监听change事件,实现图片实时预览 document.querySelector('#advPic').addEventListener('change', prevImgExec); }
src/views/adv.ejs
javascript<div class="col-sm-10 custom-file"> <!--可以选择多个文件上传--> <!--<input multiple type="file" class="form-control" name="advPic" id="advPic">--> <!-- 可以选择一个文件上传--> <input type="file" class="form-control" name="advPic" id="advPic"> </div> </div>
28 使用后端API添加广告
src/api/adv.js
js// 导入实例 import advServer from '../request/advserver'; export const postAdv = formdata => { return advServer.post('/adv', formdata) }
src/controllers/adv.js
jsimport toastr from 'toastr'; // 导入模板页面 import advV from '@/views/adv'; // 导入api函数 import {postAdv} from '../api/adv'; // 创建变量 记录预览图元素 let preImgEle; // 执行图片预览 const prevImgExec = function() { // 获取file对象 const file = this.files[0]; // 判断 if (!file) { return; } // 实例化 FilerReader const reader = new FileReader() // 将文件读取成 base64 编码的形式 异步 reader.readAsDataURL(file); // 监听读取完毕 reader.onload = () => { // 将 base64编码的图片作为 src 属性的值 preImgEle.src = reader.result; preImgEle.style.display = 'block'; } } // 执行广告添加 const addAdvExec = async event => { // 阻止默认行为 event.preventDefault(); // 创建 FomrData 对象,以 form元素作为参数 const fd = new FormData(document.addAdvForm); // 利用纯数组进行判断 const fdArr = Array.from(fd); if (!fdArr.every(item => item[1])) { toastr.error('请将表单填写完整!'); return; } if (fd.get('advPic').size === 0) { toastr.error('请选择要上传的图片!'); return; } // 使用后端API添加广告 const res = await postAdv(fd) console.log(res); // 模态框关闭 $('#advModal').modal('hide'); // 表单清空 document.addAdvForm.reset(); // 隐藏预览图 preImgEle.style.display = 'none'; // 更新列表 } export default (req, res) => { // 渲染页面 res.render(advV()); // 获取预览图元素 preImgEle = document.querySelector('#preImg'); // 给图片上传input监听change事件,实现图片实时预览 document.querySelector('#advPic').addEventListener('change', prevImgExec); // 给表单监听提交事件 document.addAdvForm.addEventListener('submit', addAdvExec); }
29 获取广告列表
src/api/adv.js
js// 导入实例 import advServer from '../request/advserver'; // 添加广告 export const postAdv = formdata => { return advServer.post('/adv', formdata) } // 获取广告列表 export const getAdv = (pageNo, pageSize, keyword) => { return advServer.get('/adv', { params: { pageNo, pageSize, keyword } }) }
src/controllers/adv.js
jsimport toastr from 'toastr'; // 导入模板页面 import advV from '@/views/adv'; // 导入组件 import AdvTableComponent from '../components/AdvTable'; // 导入api函数 import {postAdv, getAdv} from '../api/adv'; // 创建变量 记录预览图元素 let preImgEle; // 执行获取广告列表并渲染页面 const getAdvExec = async () => { // 定义广告类型的描述 1:轮播图广告 2:轮播图底部广告 3:热门回收广告 4:优品精选广告 const advTypeDes = { 1: '轮播图广告', 2: '轮播图底部广告', 3: '热门回收广告', 4: '优品精选广告' }; // 调用后端API获取数据 const {data} = await getAdv(); //console.log(res); // 将数据传入组件 document.querySelector('#advTable').innerHTML = AdvTableComponent({ advList:data, advTypeDes }); }; // 执行图片预览 const prevImgExec = function() { // 获取file对象 const file = this.files[0]; // 判断 if (!file) { return; } // 实例化 FilerReader const reader = new FileReader() // 将文件读取成 base64 编码的形式 异步 reader.readAsDataURL(file); // 监听读取完毕 reader.onload = () => { // 将 base64编码的图片作为 src 属性的值 preImgEle.src = reader.result; preImgEle.style.display = 'block'; } } // 执行广告添加 const addAdvExec = async event => { // 阻止默认行为 event.preventDefault(); // 创建 FomrData 对象,以 form元素作为参数 const fd = new FormData(document.addAdvForm); // 利用纯数组进行判断 const fdArr = Array.from(fd); if (!fdArr.every(item => item[1])) { toastr.error('请将表单填写完整!'); return; } if (fd.get('advPic').size === 0) { toastr.error('请选择要上传的图片!'); return; } // 使用后端API添加广告 const res = await postAdv(fd) console.log(res); // 模态框关闭 $('#advModal').modal('hide'); // 表单清空 document.addAdvForm.reset(); // 隐藏预览图 preImgEle.style.display = 'none'; // 更新列表 getAdvExec(); } export default (req, res) => { // 渲染页面 res.render(advV()); // 获取广告列表并渲染到页面 getAdvExec(); // 获取预览图元素 preImgEle = document.querySelector('#preImg'); // 给图片上传input监听change事件,实现图片实时预览 document.querySelector('#advPic').addEventListener('change', prevImgExec); // 给表单监听提交事件 document.addAdvForm.addEventListener('submit', addAdvExec); }
src/views/adv.ejs
ejs<div id="__sub-route-view"> <div class="card"> <div class="card-header"> <button type="button" id="addAdvBtn" class="btn btn-primary" data-toggle="modal" data-target="#advModal">添加广告</button> <div class="card-tools"> <div class="input-group input-group-md"> <input type="text" class="form-control" id="keyword" placeholder="搜索关键字"> <div class="input-group-append"> <div class="btn btn-primary" id="searchAdv"> <i class="fas fa-search"></i> </div> </div> </div> </div> </div> <div id="advTable"></div> </div> <div class="modal fade show" id="advModal" style="padding-right: 17px;" aria-modal="true" role="dialog"> <div class="modal-dialog"> <div class="modal-content"> <div class="modal-header"> <h4 class="modal-title">添加广告</h4> <button type="button" class="close" data-dismiss="modal" aria-label="Close"> <span aria-hidden="true">×</span> </button> </div> <form name="addAdvForm" class="form-horizontal" id="addAdvForm"> <div class="card-body"> <div class="form-group row"> <label class="col-sm-2 col-form-label" for="advTitle">标题:</label> <div class="col-sm-10"> <input id="advTitle" name="advTitle" type="text" class="form-control" placeholder="请输入广告标题"> </div> </div> <div class="form-group row"> <label class="col-sm-2 col-form-label" for="advType">类别:</label> <div class="col-sm-10"> <select name="advType" id="advType" class="form-control"> <option value="1">轮播图广告</option> <option value="2">轮播图底部广告</option> <option value="3">热门回收广告</option> <option value="4">优品精选广告</option> </select> </div> </div> <div class="form-group row"> <label class="col-sm-2 col-form-label" for="advHref">链接:</label> <div class="col-sm-10"> <input id="advHref" name="advHref" type="text" class="form-control" placeholder="请输入广告链接"> </div> </div> <div class="form-group row"> <label class="col-sm-2 col-form-label" for="orderNum">排序:</label> <div class="col-sm-10"> <input id="orderNum" name="orderNum" type="text" class="form-control" placeholder="请输入广告排序,数字越大越靠前"> </div> </div> <div class="form-group row"> <label class="col-sm-2 col-form-label" for="advPic">图片:</label> <div class="col-sm-10 custom-file"> <!-- 可以选择多个文件上传 --> <!-- <input multiple type="file" class="form-control" name="advPic" id="advPic">--> <!-- 可以选择一个文件上传 --> <input type="file" class="form-control" name="advPic" id="advPic"> </div> </div> <div> <img style="display: none;" id="preImg" height="100"> </div> </div> </form> <div class="modal-footer justify-content-between"> <button type="button" class="btn btn-default" id="adv-cancel" data-dismiss="modal">取消</button> <button type="submit" class="btn btn-primary" id="adv-save" form="addAdvForm">提交</button> </div> </div> <!-- /.modal-content --> </div> <!-- /.modal-dialog --> </div> </div>
src/components/AdvTable.ejs
ejs<div class="card-body table-responsive"> <table id="" class="table table-bordered"> <thead> <tr> <th>标题</th> <th>图片</th> <th>类别</th> <th>链接</th> <th>排序</th> <th>添加时间</th> <th>修改时间</th> <th>操作</th> </tr> </thead> <tbody> <% data.advList.forEach(advItem => { %> <tr> <td><%= advItem.advTitle %></td> <td><img height="80" src="/api/<%= advItem.advPic %>" alt=""> </td> <td><%= data.advTypeDes[advItem.advType] %></td> <td><%= advItem.advHref %></td> <td><%= advItem.orderNum %></td> <td><%= advItem.addTime %></td> <td><%= advItem.upTime %></td> <td> <button type="button" class="btn btn-danger btn-sm">删除</button> <button type="button" data-id="<%= advItem._id %>" class="btn btn-success btn-sm btn-edit">修改</button> </td> </tr> <% }) %> </tbody> </table> </div> <div class="card-footer clearfix" id="pageDiv"> <ul class="pagination pagination-sm m-0 float-right"> <li class="page-item"><a href="javascript:;" data-i="0" class="page-link">«</a></li> <li class="page-item active"> <a class="page-link" data-i="1" href="javascript:;">1</a> </li> <li class="page-item"><a href="javascript:;" data-i="2" class="page-link">»</a></li> </ul> </div>
30 分页
src/controllers/adv.js
jsimport toastr from 'toastr'; // 导入模板页面 import advV from '@/views/adv'; // 导入组件 import AdvTableComponent from '../components/AdvTable'; // 导入api函数 import {postAdv, getAdv} from '../api/adv'; // 创建变量 记录预览图元素 let preImgEle; // 执行获取广告列表并渲染页面 const getAdvExec = async (no=1, size=3) => { // 定义广告类型的描述 1:轮播图广告 2:轮播图底部广告 3:热门回收广告 4:优品精选广告 const advTypeDes = { 1: '轮播图广告', 2: '轮播图底部广告', 3: '热门回收广告', 4: '优品精选广告' }; // 调用后端API获取数据 const {data, pageNo, pageSum} = await getAdv(no, size); //console.log(res); // 将数据传入组件 document.querySelector('#advTable').innerHTML = AdvTableComponent({ advList:data, advTypeDes, pageNo, pageSum }); }; // 执行分页渲染的函数 const setPageExec = event => { // 判断是否是分页按钮 if (event.target.classList.contains('page-link')) { getAdvExec(event.target.dataset.i); } } // 执行图片预览 const prevImgExec = function() { // 获取file对象 const file = this.files[0]; // 判断 if (!file) { return; } // 实例化 FilerReader const reader = new FileReader() // 将文件读取成 base64 编码的形式 异步 reader.readAsDataURL(file); // 监听读取完毕 reader.onload = () => { // 将 base64编码的图片作为 src 属性的值 preImgEle.src = reader.result; preImgEle.style.display = 'block'; } } // 执行广告添加 const addAdvExec = async event => { // 阻止默认行为 event.preventDefault(); // 创建 FomrData 对象,以 form元素作为参数 const fd = new FormData(document.addAdvForm); // 利用纯数组进行判断 const fdArr = Array.from(fd); if (!fdArr.every(item => item[1])) { toastr.error('请将表单填写完整!'); return; } if (fd.get('advPic').size === 0) { toastr.error('请选择要上传的图片!'); return; } // 使用后端API添加广告 const res = await postAdv(fd) console.log(res); // 模态框关闭 $('#advModal').modal('hide'); // 表单清空 document.addAdvForm.reset(); // 隐藏预览图 preImgEle.style.display = 'none'; // 更新列表 getAdvExec(); } export default (req, res) => { // 渲染页面 res.render(advV()); // 获取广告列表并渲染到页面 getAdvExec(); // 点击分页按钮 事件委托 document.querySelector('#advTable').addEventListener('click', setPageExec); // 获取预览图元素 preImgEle = document.querySelector('#preImg'); // 给图片上传input监听change事件,实现图片实时预览 document.querySelector('#advPic').addEventListener('change', prevImgExec); // 给表单监听提交事件 document.addAdvForm.addEventListener('submit', addAdvExec); }
src/components/AdvTable.ejs
ejs<div class="card-body table-responsive"> <table id="" class="table table-bordered"> <thead> <tr> <th>标题</th> <th>图片</th> <th>类别</th> <th>链接</th> <th>排序</th> <th>添加时间</th> <th>修改时间</th> <th>操作</th> </tr> </thead> <tbody> <% data.advList.forEach(advItem => { %> <tr> <td><%= advItem.advTitle %></td> <td><img height="80" src="/api/<%= advItem.advPic %>" alt=""> </td> <td><%= data.advTypeDes[advItem.advType] %></td> <td><%= advItem.advHref %></td> <td><%= advItem.orderNum %></td> <td><%= advItem.addTime %></td> <td><%= advItem.upTime %></td> <td> <button type="button" class="btn btn-danger btn-sm">删除</button> <button type="button" data-id="<%= advItem._id %>" class="btn btn-success btn-sm btn-edit">修改</button> </td> </tr> <% }) %> </tbody> </table> </div> <div class="card-footer clearfix" id="pageDiv"> <ul class="pagination pagination-sm m-0 float-right"> <li class="page-item"> <a href="javascript:;" data-i="<%= data.pageNo - 1 %>" class="page-link">«</a> </li> <% for (let i = 1; i <= data.pageSum; i ++) { %> <li class="page-item <%= data.pageNo === i ? 'active' : '' %>"> <a class="page-link" data-i="<%= i %>" href="javascript:;"><%= i %></a> </li> <% } %> <li class="page-item"> <a href="javascript:;" data-i="<%= data.pageNo + 1 %>" class="page-link">»</a> </li> </ul> </div>
31 搜索
src/controllers/adv.js
jsimport toastr from 'toastr'; // 导入模板页面 import advV from '@/views/adv'; // 导入组件 import AdvTableComponent from '../components/AdvTable'; // 导入api函数 import {postAdv, getAdv} from '../api/adv'; // 创建变量 记录预览图元素 let preImgEle; // 执行获取广告列表并渲染页面 const getAdvExec = async (no=1, size=3) => { console.log(no); // 定义广告类型的描述 1:轮播图广告 2:轮播图底部广告 3:热门回收广告 4:优品精选广告 const advTypeDes = { 1: '轮播图广告', 2: '轮播图底部广告', 3: '热门回收广告', 4: '优品精选广告' }; // 获取搜索框中的数据 const kw = document.querySelector('#keyword').value.trim(); // 调用后端API获取数据 const {data, pageNo, pageSum} = await getAdv(no, size, kw); //console.log(res); // 将数据传入组件 document.querySelector('#advTable').innerHTML = AdvTableComponent({ advList:data, advTypeDes, pageNo, pageSum }); }; // 执行分页渲染的函数 const setPageExec = event => { // 判断是否是分页按钮 if (event.target.classList.contains('page-link')) { getAdvExec(event.target.dataset.i); } } // 执行图片预览 const prevImgExec = function() { // 获取file对象 const file = this.files[0]; // 判断 if (!file) { return; } // 实例化 FilerReader const reader = new FileReader() // 将文件读取成 base64 编码的形式 异步 reader.readAsDataURL(file); // 监听读取完毕 reader.onload = () => { // 将 base64编码的图片作为 src 属性的值 preImgEle.src = reader.result; preImgEle.style.display = 'block'; } } // 执行广告添加 const addAdvExec = async event => { // 阻止默认行为 event.preventDefault(); // 创建 FomrData 对象,以 form元素作为参数 const fd = new FormData(document.addAdvForm); // 利用纯数组进行判断 const fdArr = Array.from(fd); if (!fdArr.every(item => item[1])) { toastr.error('请将表单填写完整!'); return; } if (fd.get('advPic').size === 0) { toastr.error('请选择要上传的图片!'); return; } // 使用后端API添加广告 const res = await postAdv(fd) console.log(res); // 模态框关闭 $('#advModal').modal('hide'); // 表单清空 document.addAdvForm.reset(); // 隐藏预览图 preImgEle.style.display = 'none'; // 清空搜索框 document.querySelector('#keyword').value = ''; // 更新列表 getAdvExec(); } export default (req, res) => { // 渲染页面 res.render(advV()); // 获取广告列表并渲染到页面 getAdvExec(); // 点击分页按钮 事件委托 document.querySelector('#advTable').addEventListener('click', setPageExec); // 点击搜索按钮 document.querySelector('#searchAdvBtn').addEventListener('click', () => { getAdvExec(); }); // 获取预览图元素 preImgEle = document.querySelector('#preImg'); // 给图片上传input监听change事件,实现图片实时预览 document.querySelector('#advPic').addEventListener('change', prevImgExec); // 给表单监听提交事件 document.addAdvForm.addEventListener('submit', addAdvExec); }
``src/views/adv.ejs`
<div class="input-group input-group-md">
<input type="text" class="form-control" id="keyword" placeholder="搜索关键字">
<div class="input-group-append">
<div class="btn btn-primary" id="searchAdvBtn">
<i class="fas fa-search"></i>
</div>
</div>
</div>
32 打开修改广告的模态框和添加广告的模态框
src/api/adv.js
js// 导入实例 import advServer from '../request/advserver'; // 添加广告 export const postAdv = formdata => { return advServer.post('/adv', formdata) } // 获取广告列表 export const getAdv = (pageNo, pageSize, keyword) => { return advServer.get('/adv', { params: { pageNo, pageSize, keyword } }) } // 根据id获取广告信息 export const getAdvById = id => { return advServer.get('/adv/'+id); }
src/controllers/adv.js
jsimport toastr from 'toastr'; // 导入模板页面 import advV from '@/views/adv'; // 导入组件 import AdvTableComponent from '../components/AdvTable'; // 导入api函数 import {postAdv, getAdv, getAdvById} from '../api/adv'; // 创建变量 记录预览图元素 let preImgEle; // 执行获取广告列表并渲染页面 const getAdvExec = async (no=1, size=3) => { console.log(no); // 定义广告类型的描述 1:轮播图广告 2:轮播图底部广告 3:热门回收广告 4:优品精选广告 const advTypeDes = { 1: '轮播图广告', 2: '轮播图底部广告', 3: '热门回收广告', 4: '优品精选广告' }; // 获取搜索框中的数据 const kw = document.querySelector('#keyword').value.trim(); // 调用后端API获取数据 const {data, pageNo, pageSum} = await getAdv(no, size, kw); //console.log(res); // 将数据传入组件 document.querySelector('#advTable').innerHTML = AdvTableComponent({ advList:data, advTypeDes, pageNo, pageSum }); }; // 执行分页渲染的函数 const setPageExec = event => { // 判断是否是分页按钮 if (event.target.classList.contains('page-link')) { getAdvExec(event.target.dataset.i); } } // 打开添加广告的模态框 const openAddModalExec = () => { // 清空表单 document.addAdvForm.reset(); // 隐藏预览图 preImgEle.style.display = 'none'; // 修改标题 document.querySelector('#advModal .modal-title').innerHTML = '添加广告'; // 打开模态框 $('#advModal').modal('show'); } // 打开修改广告的模态框 获取当前广告的详细信息, const openEditModalExec = async event => { // 判断点击是修改按钮 if (event.target.classList.contains('btn-edit')) { // 获取该条广告的信息 const {data} = await getAdvById(event.target.dataset.id); // 将广告信息显示在表单里,作为表单控件的默认值 document.addAdvForm.advTitle.value = data.advTitle; document.addAdvForm.advType.value = data.advType; document.addAdvForm.advHref.value = data.advHref; document.addAdvForm.orderNum.value = data.orderNum; // 显示预览图 preImgEle.style.display = 'block'; preImgEle.src = '/api/' + data.advPic; // 修改模态框标题 document.querySelector('#advModal .modal-title').innerHTML = '修改广告'; // 打开模态框 $('#advModal').modal('show'); } } // 执行图片预览 const prevImgExec = function() { // 获取file对象 const file = this.files[0]; // 判断 if (!file) { return; } // 实例化 FilerReader const reader = new FileReader() // 将文件读取成 base64 编码的形式 异步 reader.readAsDataURL(file); // 监听读取完毕 reader.onload = () => { // 将 base64编码的图片作为 src 属性的值 preImgEle.src = reader.result; preImgEle.style.display = 'block'; } } // 执行广告添加 const addAdvExec = async event => { // 阻止默认行为 event.preventDefault(); // 创建 FomrData 对象,以 form元素作为参数 const fd = new FormData(document.addAdvForm); // 利用纯数组进行判断 const fdArr = Array.from(fd); if (!fdArr.every(item => item[1])) { toastr.error('请将表单填写完整!'); return; } if (fd.get('advPic').size === 0) { toastr.error('请选择要上传的图片!'); return; } // 使用后端API添加广告 const res = await postAdv(fd) console.log(res); // 模态框关闭 $('#advModal').modal('hide'); // 表单清空 document.addAdvForm.reset(); // 隐藏预览图 preImgEle.style.display = 'none'; // 清空搜索框 document.querySelector('#keyword').value = ''; // 更新列表 getAdvExec(); } export default (req, res) => { // 渲染页面 res.render(advV()); // 获取广告列表并渲染到页面 getAdvExec(); // 点击分页按钮 事件委托 document.querySelector('#advTable').addEventListener('click', setPageExec); // 点击搜索按钮 document.querySelector('#searchAdvBtn').addEventListener('click', () => { getAdvExec(); }); // 点击添加广告的按钮 document.querySelector('#addAdvBtn').addEventListener('click', openAddModalExec); // 点击表格中的修改按钮 事件委托 document.querySelector('#advTable').addEventListener('click', openEditModalExec); // 获取预览图元素 preImgEle = document.querySelector('#preImg'); // 给图片上传input监听change事件,实现图片实时预览 document.querySelector('#advPic').addEventListener('change', prevImgExec); // 给表单监听提交事件 document.addAdvForm.addEventListener('submit', addAdvExec); }
src/views/adv.ejs
ejs<div id="__sub-route-view"> ... <div id="advTable"></div> </div>
33 执行修改或者添加
1. 根据formdata中id是否有值作为判断依据,有值修改,无值添加
src/api/adv.js
- javascript
// 修改广告 export const putAdv = formdata => { return advServer.put('/adv', formdata); }
src/controllers/adv.js
jsimport toastr from 'toastr'; // 导入模板页面 import advV from '@/views/adv'; // 导入组件 import AdvTableComponent from '../components/AdvTable'; // 导入api函数 import {postAdv, getAdv, getAdvById, putAdv} from '../api/adv'; // 创建变量 记录预览图元素 let preImgEle; // 执行获取广告列表并渲染页面 const getAdvExec = async (no=1, size=3) => { // 定义广告类型的描述 1:轮播图广告 2:轮播图底部广告 3:热门回收广告 4:优品精选广告 const advTypeDes = { 1: '轮播图广告', 2: '轮播图底部广告', 3: '热门回收广告', 4: '优品精选广告' }; // 获取搜索框中的数据 const kw = document.querySelector('#keyword').value.trim(); // 调用后端API获取数据 const {data, pageNo, pageSum} = await getAdv(no, size, kw); //console.log(res); // 将数据传入组件 document.querySelector('#advTable').innerHTML = AdvTableComponent({ advList:data, advTypeDes, pageNo, pageSum }); }; // 执行分页渲染的函数 const setPageExec = event => { // 判断是否是分页按钮 if (event.target.classList.contains('page-link')) { getAdvExec(event.target.dataset.i); } } // 打开添加广告的模态框 const openAddModalExec = () => { // 清空表单 document.addAdvForm.reset(); // 清空隐藏域 document.addAdvForm.id.value = ''; // 隐藏预览图 preImgEle.style.display = 'none'; // 修改标题 document.querySelector('#advModal .modal-title').innerHTML = '添加广告'; // 打开模态框 $('#advModal').modal('show'); } // 打开修改广告的模态框 获取当前广告的详细信息, const openEditModalExec = async event => { // 判断点击是修改按钮 if (event.target.classList.contains('btn-edit')) { // 获取该条广告的信息 const {data} = await getAdvById(event.target.dataset.id); // 将广告信息显示在表单里,作为表单控件的默认值 document.addAdvForm.advTitle.value = data.advTitle; document.addAdvForm.id.value = data._id; document.addAdvForm.advType.value = data.advType; document.addAdvForm.advHref.value = data.advHref; document.addAdvForm.orderNum.value = data.orderNum; // 显示预览图 preImgEle.style.display = 'block'; preImgEle.src = '/api/' + data.advPic; // 修改模态框标题 document.querySelector('#advModal .modal-title').innerHTML = '修改广告'; // 打开模态框 $('#advModal').modal('show'); } } // 执行图片预览 const prevImgExec = function() { // 获取file对象 const file = this.files[0]; // 判断 if (!file) { return; } // 实例化 FilerReader const reader = new FileReader() // 将文件读取成 base64 编码的形式 异步 reader.readAsDataURL(file); // 监听读取完毕 reader.onload = () => { // 将 base64编码的图片作为 src 属性的值 preImgEle.src = reader.result; preImgEle.style.display = 'block'; } } // 执行广告添加或修改 const addAdvExec = async event => { // 阻止默认行为 event.preventDefault(); // 创建 FomrData 对象,以 form元素作为参数 const fd = new FormData(document.addAdvForm); // 如果表单内容中有id if ((fd.get('id'))) { // 执行修改 await putAdv(fd); toastr.success('修改成功!'); } else { // 执行添加 // 将 id 从fd中删除 fd.delete('id'); // 利用纯数组进行判断 const fdArr = Array.from(fd); if (!fdArr.every(item => item[1])) { toastr.error('请将表单填写完整!'); return; } if (fd.get('advPic').size === 0) { toastr.error('请选择要上传的图片!'); return; } // 使用后端API添加广告 await postAdv(fd) toastr.success('添加成功!'); } // 模态框关闭 $('#advModal').modal('hide'); // 表单清空 document.addAdvForm.reset(); // 隐藏预览图 preImgEle.style.display = 'none'; // 清空搜索框 document.querySelector('#keyword').value = ''; // 更新列表 getAdvExec(); } export default (req, res) => { // 渲染页面 res.render(advV()); // 获取广告列表并渲染到页面 getAdvExec(); // 点击分页按钮 事件委托 document.querySelector('#advTable').addEventListener('click', setPageExec); // 点击搜索按钮 document.querySelector('#searchAdvBtn').addEventListener('click', () => { getAdvExec(); }); // 点击添加广告的按钮 document.querySelector('#addAdvBtn').addEventListener('click', openAddModalExec); // 点击表格中的修改按钮 事件委托 document.querySelector('#advTable').addEventListener('click', openEditModalExec); // 获取预览图元素 preImgEle = document.querySelector('#preImg'); // 给图片上传input监听change事件,实现图片实时预览 document.querySelector('#advPic').addEventListener('change', prevImgExec); // 给表单监听提交事件 添加或修改 document.addAdvForm.addEventListener('submit', addAdvExec); }
src/views/adv.ejs
表单中添加隐藏域<input type=hidden name="" value="">
ejs<form name="addAdvForm" class="form-horizontal" id="addAdvForm"> <!-- 隐藏域 --> <input type="hidden" name="id" value=""> ... </form>
34 打包 生产模式下配置
安装依赖
shellnpm i mini-css-extract-plugin -D npm i css-minimizer-webpack-plugin -D
生产配置
config/webpack.prod.config.js
抽离css并压缩jsconst MiniCssExtractPlugin = require("mini-css-extract-plugin"); const CssMinimizerPlugin = require("css-minimizer-webpack-plugin"); const {merge} = require('webpack-merge'); const baseConfig = require('./webpack.base.config'); module.exports = merge(baseConfig, { // loaders module: { rules: [ { test: /\.less$/, use: [MiniCssExtractPlugin.loader, 'css-loader', 'less-loader'] }, { test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader'] } ] }, // 插件 plugins: [ new MiniCssExtractPlugin({ filename: 'css/main.[hash:12].css' }) ], optimization: { minimizer: [ // 在 webpack@5 中,你可以使用 `...` 语法来扩展现有的 minimizer(即 `terser-webpack-plugin`),将下一行取消注释 `...`, new CssMinimizerPlugin(), ], }, // 模式 mode: 'production', // sourceMap devtool:'source-map', })
开发配置
config/webpack.dev.config.js
jsconst {merge} = require('webpack-merge'); const baseConfig = require('./webpack.base.config'); module.exports = merge(baseConfig, { // 模式 mode: 'development', // sourceMap devtool:'cheap-module-source-map', // 自动开启服务 devServer: { host: 'shirly.com', port: '80', open: true, // 开发中,允许前端路由使用 history 模式 historyApiFallback: true, proxy: { // 默认替换方式:/api/admin -> http://127.0.0.1:8088/api/admin // '/api': 'http://127.0.0.1:8088' '/api': { target: 'http://127.0.0.1:8088', // /api/admin -> http://127.0.0.1:8088/admin 去掉 /api pathRewrite: { '^/api': ''}, // 默认值请求中 Host http://shirly.com // 设置为 true 请求头 Host http://127.0.0.1:8088 changeOrigin: true, } } } })
35 生产模式和开发模式使用不同的 baseURL
后端服务地址: http://api.fuming.site:54254/
代理服务器地址: /api
DefinePlugin默认的插件
- javascript
const webpack = require ('webpack');
开发配置
config/webpack.dev.config.js
jsplugins: [ new webpack.DefinePlugin({ // SERVICE_URL: "/api" ,后边的默认是代码片段 /api 会执行,然后报错,经过stringify处理会转为 字符串,SERVICE_URL: 全局变量 SERVICE_URL: JSON.stringify('/api') }) ], // 模式 mode: 'development',
生产配置
config/webpack.prod.config.js
js// 插件 plugins: [ // SERVICE_URL: 全局变量 new webpack.DefinePlugin({ SERVICE_URL: JSON.stringify('http://api.fuming.site:54254') }) ],
项目中使用
src/request/advserver.js
js// 导入 axios import axios from 'axios'; import toastr from 'toastr'; // 创建 axios 实例 const advServer = axios.create({ baseURL: SERVICE_URL, timeout: 5000 }); // 给实例添加请求拦截器 advServer.interceptors.request.use(config => { // 如果localStorage中有token,就添加到请求头 if (localStorage.getItem('token')) { config.headers.token = localStorage.getItem('token'); } return config; }); // 给 实例设置响应拦截器 advServer.interceptors.response.use(res => { if (res.data.ok === -1) { toastr.error(res.data.msg); // 返回不改变状态的 Promise,执行链结束了 return new Promise(()=>{}); } if (res.data.ok === -2) { toastr.error(res.data.msg); // 跳转到 登录页 router.go('/login'); // 清空localStorage localStorage.clear(); // 返回不改变状态的 Promise,执行链结束了 return new Promise(()=>{}); } return res.data; }, error => { toastr.error('请求错误!'); // 返回不改变状态的 Promise,执行链结束了 return new Promise(()=>{}); }); // 暴露实例 export default advServer;
src/AdvTable.ejs
<td><%= advItem.advTitle %></td>
<td><img height="80" src="SERVICE_URL/<%= advItem.advPic %>" alt="">
36 部署上线
1. 将输出目录中的东西上传至前端静态资源服务
示例地址: http://adv.manage.fuming.site
该静态资源服务器使用nginx搭建
2. 在后端服务器允许跨域, 允许前端静态资源服务器的协议、域名、端口 http://adv.manage.fuming.site
3. 在前端静态资源服务器配置,允许history模式
4. node后端 linux管理工具 pm2 list,部署后端
5. 更改nginx配置文件:前端history路由,nginx设置
try_files $uri /index.html
附录
功能需求分析
管理员列表
添加管理员
删除管理员
修改密码(扩展)
登录
广告列表
添加广告
修改广告
删除广告(扩展)
后端API 文档
后端程序源码获取 https://gitee.com/sh230320/backend_project
[获取管理员列表]
请求地址:/admin
请求方式:GET
请求参数:无
响应体类型:application/json
响应结果示例(成功)
{
"ok": 1,
"data": [
{
"_id": "648a159c4d6466fa02cbd9c7",
"adminName": "xiaowang",
"passWord": "2f9d236cc82263310be1131d0d13d6c3",
"regTime": "2023-06-15 03:31:40",
"lastLoginTime": "2023-06-15 03:31:40"
},
{
"_id": "648a159c4d6466fa02cbd9c7",
"adminName": "xiaowang",
"passWord": "2f9d236cc82263310be1131d0d13d6c3",
"regTime": "2023-06-15 03:31:40",
"lastLoginTime": "2023-06-15 03:31:40"
},
...
]
}
响应结果示例(失败)
{
"ok": -1,
"msg": "数据获取失败"
}
[添加管理员]
请求地址:/admin
请求方式:POST
请求体类型:application/json
请求体示例:
{
"adminName":"shirly",
"passWord":"123123"
}
响应体类型:application/json
响应结果示例(成功)
{
"ok":1,
"msg":"添加管理员成功"
}
响应结果示例(失败)
{
"ok": -1,
"msg": "管理员账号已经存在!"
}
[删除管理员]
请求地址:/admin/:id
请求方式:DELETE
说明:需要在路径中指定ID
响应体类型:application/json
响应结果示例(成功)
{
"ok": 1,
"msg": "删除成功"
}
响应结果示例(失败)
{
"ok": -1,
"msg": "删除失败"
}
[修改管理员密码]
请求地址:/changepwd
请求方式:PATCH
请求体类型:application/json
请求体示例:
{
"id": "648a159c4d6466fa02cbd9c7",
"oldPwd":"123456",
"newPwd":"123123"
}
响应体类型:application/json
响应结果示例(成功)
{
"ok": 1,
"msg": "修改成功"
}
响应结果示例(失败)
{
"ok": -1,
"msg": "修改失败"
}
[登陆]
请求地址:/login
请求方式:POST
请求体类型:application/json
请求体示例:
{
"adminName":"管理员账号"
"passWord":"管理员密码"
}
响应体类型:application/json
响应结果示例(成功)
{
"ok": 1,
"msg": "登陆成功",
"token": "token信息"
}
响应结果示例(失败)
{
"ok": -1,
"msg": "账号或密码错误"
}
[获取广告列表]
请求地址:/adv
请求方式:GET
请求参数
pageNo:指定页码,如果不传,默认是1
pageSize:每页显示的条数,如果不传,默认是3
keyword:搜索的关键词,如果不传,默认是空字符
响应体类型:application/json
响应结果示例(成功)
{
"ok": 1,
"data": [
{
"_id": "6485dae7721b3e41fb8aeb20",
"advTitle": "小米平板2",
"advHref": "http://www.xiaomi.com/product/121212.html",
"orderNum": 1212,
"advPic": "2023/06/11/2c29e4c12ba5953776e2eaf00.jpg",
"advType": 1,
"addTime": "2023-06-11 22:32:07",
"upTime": "2023-06-11 22:35:07"
},
...
],
"pageNo": 1, // 当前的页码
"pageSum": 2 // 总页数
}
响应结果示例(失败)
{
"ok": -1,
"msg": "数据获取失败"
}
[根据 ID 获取广告信息]
请求地址:/adv/:id
请求方式:GET
说明:需要在路径中指定ID
响应体类型:application/json
响应结果示例(成功)
{
"ok": 1,
"data": {
"_id": "6485dae7721b3e41fb8aeb20",
"advTitle": "莱斯来说广告",
"advHref": "http://atguigu.com",
"orderNum": 1212,
"advPic": "2023/06/11/2c29e4c12ba5953776e2eaf00.jpg",
"advType": 1,
"addTime": "2023-06-11 22:32:07",
"upTime": "2023-06-11 22:35:07"
}
}
响应结果示例(失败)
{
"ok": -1,
"msg": "数据获取失败"
}
[添加广告]
请求地址:/adv
请求方式:POST
请求体类型:multipart/form-data
请求体字段要求:
advTitle: 广告名称
advType: 广告类型 1:轮播图广告 2:轮播图底部广告 3:热门回收广告 4:优品精选广告
advHref: 广告链接
orderNum: 排序编号
advPic: (图片的二进制数据)
响应体类型:application/json
响应结果示例(成功)
{
"ok":1,
"msg":"添加成功"
}
响应结果示例(失败)
{
"ok": -1,
"msg": "添加失败"
}
[修改广告]
请求地址:/adv
请求方式:PUT
请求体类型:multipart/form-data
请求体字段要求:
id:需要指定ID
advTitle: 广告名称
advType: 广告类型 1:轮播图广告 2:轮播图底部广告 3:热门回收广告 4:
advHref: 广告链接
orderNum: 排序编号
advPic: (图片的二进制数据)
响应体类型:application/json
响应结果示例(成功)
{
"ok":1,
"msg":"修改成功"
}
响应结果示例(失败)
{
"ok": -1,
"msg": "修改失败"
}
[删除广告]
请求地址:adv/:id
请求方式:DELETE
说明:需要在路径中指定ID
响应体类型:application/json
响应结果示例(成功)
{
"ok": 1,
"msg": "删除成功"
}
响应结果示例(失败)
{
"ok": -1,
"msg": "删除失败"
}
[token 验证失败]
token 验证失败返回的响应结果如下:
{
"ok": -2
"msg": '登录异常,请求重新登录'
}
项目目录结构分析
19-axios二次封装
┣ config
┃ ┣ webpack.base.config.js
┃ ┣ webpack.dev.config.js
┃ ┗ webpack.prod.config.js
┣ src
┃ ┣ api
┃ ┃ ┣ admin.js
┃ ┃ ┣ adv.js
┃ ┃ ┗ login.js
┃ ┣ assets
┃ ┃ ┗ less
┃ ┃ ┃ ┗ app.less
┃ ┣ components
┃ ┃ ┣ Content.ejs
┃ ┃ ┣ Footer.ejs
┃ ┃ ┣ Header.ejs
┃ ┃ ┗ Sidebar.ejs
┃ ┣ controllers
┃ ┃ ┣ admin.js
┃ ┃ ┣ adv.js
┃ ┃ ┣ index.js
┃ ┃ ┗ login.js
┃ ┣ request
┃ ┃ ┗ advserver.js
┃ ┣ routes
┃ ┃ ┗ index.js
┃ ┣ views
┃ ┃ ┣ admin.ejs
┃ ┃ ┣ adv.ejs
┃ ┃ ┣ index.ejs
┃ ┃ ┗ login.ejs
┃ ┗ app.js
┣ package-lock.json
┗ package.json
结构图
项目结构1
项目结构2
项目结构3