Node-留言列表小项目

该项目是仿照某些网站的留言列表做的一些小功能,项目没有划分 M V C,使用的前台技术是 Bootstrap,后台使用的是 Node.js,模板引擎使用的是 EJS,数据库是 MySQL。

依赖

  • Bootstrap
  • jQuery
    • 使用 jQuery 的 ajax 做异步无刷新列表加载
  • EJS
  • Node.js
    • express
      • body-parser express 的中间件(middleware),可以用来解析 post 请求体数据
    • mysql
    • moment
  • js-cookie

安装依赖

1
$ npm install bootstrap jquery express art-template --save

package.json (包括项目后面流程安装的依赖包)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"name": "feedback",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "lpz <mail@lipengzhou.com> (http://www.lipengzhou.com/)",
"license": "MIT",
"dependencies": {
"art-template": "^3.0.3",
"body-parser": "^1.15.2",
"bootstrap": "^3.3.7",
"ejs": "^2.5.2",
"express": "^4.14.0",
"jquery": "^3.1.1",
"js-cookie": "^2.1.3",
"moment": "^2.16.0",
"mysql": "^2.12.0"
}
}

页面搭建:Bootstrap

最终完整页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>反馈留言本</title>
<link rel="stylesheet" href="/node_modules/bootstrap/dist/css/bootstrap.css">
</head>
<body>
<!-- 页头 -->
<div class="container">
<div class="page-header">
<h1>反馈系统<small>留言本</small></h1>欢迎:<%= user.username %>
</div>
</div>
<!-- 页头 -->
<!-- 留言列表区域 -->
<div class="container">
<ul class="list-group">
<li class="list-group-item list-group-item-info">留言(<span id="message_count"></span>条)</li>
</ul>
</div>
<!-- /留言列表区域 -->
<!-- 留言表单区域 -->
<div class="container">
<div class="panel panel-warning">
<div class="panel-heading">
<h3 class="panel-title">我要发表看法</h3>
</div>
<div class="panel-body">
<!-- 后台指定提交的接口 -->
<form id="form" action="/message/add" method="post" role="form">
<div>
<label for="">您的留言</label>
<textarea class="form-control" required name="message" rows="5"></textarea>
</div>
<div>
<label for="">您的大名</label>
<input type="text" class="form-control" required name="name" placeholder="请输入您的大名">
</div>
<div class="form-group">
<label for="">电子邮件</label>
<input type="email" class="form-control" required name="email" placeholder="请输入您的电子邮件">
</div>
<button type="submit" class="btn btn-success">发表</button>
</form>
</div>
</div>
</div>
<!-- /留言表单区域 -->
<script type="text/template" id="tpl">
{{each messages as message index}}
<li class="list-group-item">{{message.name}}:{{message.message}}<span class="pull-right">{{message.date}}</span></li>
{{/each}}
</script>
<script src="/node_modules/art-template/dist/template.js"></script>
<script src="/node_modules/jquery/dist/jquery.js"></script>
<script>
loadMessages();
function loadMessages() {
// ajax 已死, 未来 fetch 可能会成为标准
window.fetch('/message');
.then(res => {
return res.json();
})
.then(data => {
$('.list-group li:gt(0)').remove();
// 前台渲染页面
const result = template('tpl', data);
$('#message_count').html(data.messages.length);
$('.list-group').append(result);
});
}
$('#form').on('submit', function (e) {
// 加上这句,表单不会默认提交了
e.preventDefault();
$.ajax({
url: $(this).attr('action'),
type: $(this).attr('method'),
data: $(this).serialize(),
dataType: 'json'
})
.then(data => {
// 是否成功由后台告诉前台
if (data.code === 1000) {
// 成功后清空留言输入框
$('#form [name]').val('');
loadMessages();
window.alert('留言成功');
}
});
// 和 e.preventDefault(); 作用相同
// return false;
});
</script>
</body>
</html>

数据库设计: feedback

类型 长度 小数点 不是null 主键
id int yes yes
message text yes
name varchar 50 yes
email varchar 50 yes
data datatime yes

页面入口/后台数据处理 app.js

安装依赖:

1
$ npm install body-parser moment ejs --save

app.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
const express = require('express');
const path = require('path');
const db = require('./db');
const bodyParser = require('body-parser');
const moment = require('moment');
const app = express();
// 公开静态资源
app.use('/node_modules', express.static(path.join(__dirname, 'node_modules')));
app.use('/public', express.static(path.join(__dirname, 'public')));
// 配置模板引擎
// 配置视图存放路径,不配置默认就是 views
app.set('views', path.join(__dirname, 'views'));
// 默认后缀名是 ejs,这样配置就可以使用 html 后缀名
// xTemplate 模板引擎配置也是如此
app.engine('.html', require('ejs').__express);
app.set('view engine', 'html');
// 配置解析普通表单 POST 请求体的中间件
// 任何请求进来,如果是 POST 请求,则该中间件会自动解析 POST 请求体
// 解析成一个对象,然后挂载给 req 请求对象一个属性:body,然后调用下一个中间件
// 也就是说在后续某一个被匹配到的处理函数中可以直接通过 req.body 来使用表单 POST 提交的数据
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
// 请求网站根路径
app.get('/', (req, res) => {
// 渲染首页
res.render('index');
});
app.get('/message', (req, res, next) => {
// 1. 查询数据库,获取数据库中的内容
// 2. 可能会处理一下获取到的数据
// 3. 发送响应
db.query('SELECT * FROM `feedback` ORDER BY `date` DESC', (err, rows) => { // 按日期排序
if (err) {
return next(err);
}
// 处理时间
rows.forEach(r => r.date = moment(r.date).format('YYYY-MM-DD HH:mm:ss'));
res.json({
messages: rows
});
});
});
app.get('/signup', (req, res) => {
res.render('signup');
});
// 注册请求处理(异步请求表单不会刷新)
app.post('/signup', (req, res, next) => {
// 1. 接收请求数据:做数据的合法性校验
// 2. 处理请求:先校验用户名是否被占用、
// 如果已存在,告诉用户,用户名被占用了;如果不存在,执行注册
// 注册成功:告诉用户:操作成功;如果失败,也告诉用户,操作失败
// 3. 发送响应
const body = req.body;
// 前端传递给后台的数据,一定要再次做合法性校验
db.query('SELECT * FROM `users` WHERE `username`=?', [body.username], (err, rows) => {
if (err) {
return next(err);
}
if (rows[0]) {
// 说明被占用了
return res.json({
// 异步交互返回结果状态码,业务状态码
code: 2001,
message: 'username already exists'
});
}
// 执行到这里,可以插入数据了
db.query('INSERT INTO `users`(`username`, `password`, `email`, `create_time`, `last_signin_time`) VALUES(?, ?, ?, ?, ?)', [
body.username,
body.password,
body.email,
moment().format('YYYY-MM-DD HH:mm:ss'),
moment().format('YYYY-MM-DD HH:mm:ss')
], (err, stat) => {
if (err) {
return next(err);
}
res.json({
code: 2000,
message: 'success',
});
});
});
});
app.get('/signin', (req, res) => {
res.render('signin');
});
// post 处理留言页面异步表单提交,可以不使用 form,但是最好加上;后台会指定需要哪些数据
// body { message: '', name: '', email: '' }
app.post('/message/add', (req, res, next) => {
// 1. 接收客户端提交的数据
// 2. 处理客户端提交的数据:2.1、对数据做合法性校验;2.2、 校验通过之后,插入数据库
// 3. 给当前请求客户端发送响应
const body = req.body;
db.query('INSERT INTO `feedback`(`message`, `name`, `email`, `date`) VALUES(?, ?, ?, ?)', [
body.message,
body.name,
body.email,
moment().format('YYYY-MM-DD HH:mm:ss')
], (err, stat) => {
if (err) {
return next(err);
}
// 重定向对 ajax 请求没有用,重定向只对客户端同步请求有效
// 例如输入了一个地址敲回车,点了一个 a 连接,提交了一个表单都是同步请求
res.json({
code: 1000,
message: 'success'
});
});
});
// 千万不要少些最后一个 next
// 如果少了最后的参数 next,则现在这个中间件就是处理请求的中间件 req res next
// 所以错误处理中间件一定要使用四个参数:err req res next
// 只要在之前的任何中间件中调用 next 的时候,传递了参数,就一定会执行下面这个错误处理中间件
app.use((err, req, res, next) => {
res.send(`500 ErrorMessage:${err.message}`);
});
// 监听端口,启动服务
app.listen(3000, () => {
console.log('server is running at port 3000.');
});

渲染页面 EJS

1
2
3
{{each messages as message index}}
<li class="list-group-item">{{message.name}}:{{message.message}}<span class="pull-right">{{message.date}}</span></li>
{{/each}}

前台 ajax 请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$('#form').on('submit', function (e) {
// 加上这句,表单不会默认提交了
e.preventDefault();
$.ajax({
url: $(this).attr('action'),
type: $(this).attr('method'),
data: $(this).serialize(),
dataType: 'json'
})
.then(data => {
// 是否成功由后台告诉前台
if (data.code === 1000) {
$('#form [name]').val('');
loadMessages();
window.alert('留言成功');
}
});
// 和 e.preventDefault(); 作用相同
// return false;
});

后台 post 请求处理

express 的 body-parser 中间键

配置中间键:

1
2
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());

日期格式化包:

1
$ npm install moment --save

格式化日期方法:

1
moment().format('YYYY-MM-DD HH:mm:ss')

后台处理 post 请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
app.post('/message/add', (req, res, next) => {
const body = req.body;
db.query('INSERT INTO `feedback`(`message`, `name`, `email`, `date`) VALUES(?, ?, ?, ?)', [
body.message,
body.name,
body.email,
moment().format('YYYY-MM-DD HH:mm:ss')
], (err, stat) => {
if (err) {
return next(err);
}
res.json({
code: 1000,
message: 'success'
});
});
});

响应给前台后,前台处理响应成功逻辑:

1
2
3
4
5
6
// 是否成功由后台告诉前台
if (data.code === 1000) {
$('#form [name]').val('');
loadMessages();
window.alert('留言成功');
}

注册登录模块

登录页面

注册页面

流程

添加表 users

索引 类型 长度 小数点 不是null 主键
id int 50 yes yes
username varchar 50 yes
password varchar 50 yes
email varchar 50 yes
create_time datetime yes
last_signin_time datetime yes

处理注册页面后台逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 注册请求处理(异步请求表单不会刷新)
app.post('/signup', (req, res, next) => {
// 1. 接收请求数据:做数据的合法性校验
// 2. 处理请求:先校验用户名是否被占用、
// 如果已存在,告诉用户,用户名被占用了;如果不存在,执行注册
// 注册成功:告诉用户:操作成功;如果失败,也告诉用户,操作失败
// 3. 发送响应
const body = req.body;
// 前端传递给后台的数据,一定要再次做合法性校验
db.query('SELECT * FROM `users` WHERE `username`=?', [body.username], (err, rows) => {
if (err) {
return next(err);
}
if (rows[0]) {
// 说明被占用了
return res.json({
// 异步交互返回结果状态码,业务状态码
code: 2001,
message: 'username already exists'
});
}
// 执行到这里,可以插入数据了
db.query('INSERT INTO `users`(`username`, `password`, `email`, `create_time`, `last_signin_time`) VALUES(?, ?, ?, ?, ?)', [
body.username,
body.password,
body.email,
moment().format('YYYY-MM-DD HH:mm:ss'),
moment().format('YYYY-MM-DD HH:mm:ss')
], (err, stat) => {
if (err) {
return next(err);
}
res.json({
code: 2000,
message: 'success',
});
});
});
});

处理注册页面前台逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$('form').on('submit', function (e) {
e.preventDefault();
$.ajax({
url: $(this).attr('action'),
type: $(this).attr('method'),
data: $(this).serialize(),
dataType: 'json'
})
.then(data => {
if (data.code === 2000) {
window.alert('恭喜:注册成功');
// 可以通过控制 location 的 href ,让浏览器跳转
window.location.href = '/';
} else if (data.code === 2001) {
window.alert('用户名已存在,请更换重试');
}
});
});

记住用户名(通过前台来写)

方法一:

安装插件:

1
$ npm install js-cookie --save

设置 cookie:

1
2
3
4
5
6
7
8
var username = Cookies.get('username');
if (username) {
document.querySelector('#username').value = username;
}
$('form').on('submit', function (e) {
e.preventDefault();
Cookies.set('username', $('#username').val());
});

安装:

1
$ npm install cookie-parser --save

配置:

1
2
3
4
const express = require('express');
const cookieParser = require('cookie-parser');
const app = express();
app.use(cookieParser());

方法二:session 保持状态

session 和 cookie 结合使用。

在 express 中使用 session

安装:

1
$ npm install express-session --save

配置:

1
2
3
4
5
6
7
8
9
const express = require('express');
const session = require('express-session');
const app = express();
app.use(session({
// 表示生成钥匙的时候根据这个字符串生成
secret: 'mhq',
resave: false,
saveUnitialized: true
}));

处理用户注册和登录的状态

1
$ npm install express-session --save

最终的 app.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
// 引入包
const express = require('express');
const path = require('path');
const db = require('./db');
const bodyParser = require('body-parser');
const moment = require('moment');
const session = require('express-session');
const app = express();
// 暴露静态资源
app.use('/node_modules', express.static(path.join(__dirname, 'node_modules')));
app.use('/public', express.static(path.join(__dirname, 'public')));
// 配置模板引擎
app.set('views', path.join(__dirname, 'views'));
app.engine('.html', require('ejs').__express);
app.set('view engine', 'html');
// 配置 body-parser 中间件
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
// 配置 Session 中间件,在进入路由之前配置
app.use(session({
secret: 'mhq',
resave: false,
saveUninitialized: true
}));
// 没有登录时跳到登录页面
app.get('/', (req, res) => {
if (!req.session.user) {
return res.redirect('/signin');
}
res.render('index', {
// session 中保存了 user
user: req.session.user
});
});
// 处理消息页面的 form 表单数据
app.get('/message', (req, res, next) => {
db.query('SELECT * FROM `feedback` ORDER BY `date` DESC', (err, rows) => {
if (err) {
return next(err);
}
rows.forEach(r => r.date = moment(r.date).format('YYYY-MM-DD HH:mm:ss'))
res.json({
messages: rows
});
});
});
// 登录成功后跳到首页
app.get('/signup', (req, res) => {
if (req.session.user) {
return res.redirect('/');
}
res.render('signup');
});
// 处理注册 post 请求
app.post('/signup', (req, res, next) => {
const body = req.body;
db.query('SELECT * FROM `users` WHERE `username`=?', [body.username], (err, rows) => {
if (err) {
return next(err);
}
if (rows[0]) {
// 说明被占用了
return res.json({
code: 2001,
message: 'username already exists'
});
}
// 执行到这里,可以插入数据了
db.query('INSERT INTO `users`(`username`, `password`, `email`, `create_time`, `last_signin_time`) VALUES(?, ?, ?, ?, ?)', [
body.username,
body.password,
body.email,
moment().format('YYYY-MM-DD HH:mm:ss'),
moment().format('YYYY-MM-DD HH:mm:ss')
], (err, stat) => {
if (err) {
return next(err)
}
// 注册成功,保存用户登陆状态
req.session.user = {
username: body.username
};
res.json({
code: 2000,
message: 'success',
});
});
});
});
app.get('/signin', (req, res) => {
if (req.session.user) {
return res.redirect('/');
}
res.render('signin');
});
// 处理登录页面 post 请求
app.post('/signin', (req, res, next) => {
const username = req.body.username;
const password = req.body.password;
// 验证用户名是否存在,密码是否正确
db.query('SELECT * FROM `users` WHERE `username`=?', [username], (err, rows) => {
const user = rows[0];
if (!user) {
return res.json({
code: 3001,
message: 'username not exists'
});
}
if (password !== user.password) {
return res.json({
code: 3002,
message: 'password error'
})
}
// 登陆成功,保存状态
req.session.user = {
username: username
};
res.json({
code: 3000,
message: 'login success'
});
});
});
// 处理留言页面的 post 请求的数据
app.post('/message/add', (req, res, next) => {
const body = req.body;
db.query('INSERT INTO `feedback`(`message`, `name`, `email`, `date`) VALUES(?, ?, ?, ?)', [
body.message,
body.name,
body.email,
moment().format('YYYY-MM-DD HH:mm:ss')
], (err, stat) => {
if (err) {
return next(err);
}
res.json({
code: 1000,
message: 'success'
});
});
});
app.use((err, req, res, next) => {
res.send(`500 ErrorMessage:${err.message}`);
});
app.listen(3000, () => {
console.log('server is running at port 3000.');
});

处理登录页面

最终的 singin.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<!-- ... -->
<body>
<div class="container">
<form action="/signin" method="post" class="form-signin" role="form">
<h2 class="form-signin-heading">用户登陆</h2>
<input type="text" id="username" name="username" class="form-control" placeholder="请输入用户名" required autofocus>
<input type="password" name="password" class="form-control" placeholder="Password" required>
<div class="checkbox">
<label>
<input type="checkbox" value="remember-me"> Remember me
</label>
</div>
<button class="btn btn-lg btn-primary btn-block" type="submit">登陆</button>
</form>
</div>
<script src="/node_modules/jquery/dist/jquery.js"></script>
<script src="/node_modules/js-cookie/src/js.cookie.js"></script>
<script>
<script>
$('form').on('submit', function (e) {
e.preventDefault();
$.ajax({
url: $(this).attr('action'),
type: $(this).attr('method'),
data: $(this).serialize(),
dataType: 'json'
}).then(data => {
switch (data.code) {
case 3000:
window.location.href = '/'
break;
case 3001:
window.alert('该用户不存在');
break;
case 3002:
window.alert('密码错误了');
break;
default:
window.alert('未知错误');
break;
}
});
});
</script>
</body>
</html>

处理注册页面

最终的 singup.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>用户登陆</title>
<link rel="stylesheet" href="/node_modules/bootstrap/dist/css/bootstrap.css">
<link rel="stylesheet" href="/public/css/signin.css">
</head>
<body>
<div class="container">
<form action="/signup" method="post" class="form-signin" role="form">
<h2 class="form-signin-heading">用户注册</h2>
<input type="text" class="form-control" name="username" placeholder="请输入用户名" required autofocus>
<input type="email" class="form-control" name="email" placeholder="请输入邮箱" required>
<input type="password" class="form-control" name="password" placeholder="请输入密码" required>
<input type="confirm" class="form-control" name="confirm" placeholder="请确认密码" required>
<button class="btn btn-lg btn-primary btn-block" type="submit">注册</button>
</form>
</div>
<script src="/node_modules/jquery/dist/jquery.js"></script>
<script>
// 处理前台逻辑
$('form').on('submit', function (e) {
e.preventDefault();
$.ajax({
url: $(this).attr('action'),
type: $(this).attr('method'),
data: $(this).serialize(),
dataType: 'json'
})
.then(data => {
if (data.code === 2000) {
window.alert('恭喜:注册成功');
// 可以通过控制 location 的 href ,让浏览器跳转
window.location.href = '/';
} else if (data.code === 2001) {
window.alert('用户名已存在,请更换重试');
}
});
});
</script>
</body>
</html>

数据库连接

连接数据库关键代码:db.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
const mysql = require('mysql');
// 使用连接,提高操作数据库效率
// 创建一个连接池,池子存放的连接数量是 100 个
const pool = mysql.createPool({
connectionLimit: 100,
host: 'localhost',
user: 'root',
password: '*********',
database: 'feedback'
});
// rest 参数
// 作为函数参数的最后一个参数出现,以 ... 开头,后面跟一个名字
// rest 参数就代替了 arguments
exports.query = function (sql, ...values) {
let callback;
let params = [];
if (values.length === 3) {
params = values[1];
callback = values[2];
} else if (values.length === 2) {
callback = values[1];
}
pool.getConnection((err, connection) => {
if (err) {
return callback(err);
}
// 如果传递了两个参数,则第二个参数就是 callback,也就是说这种情况下,params 就是 callback
// 后面的 参数就忽略不计了
// 如果传递了三个参数,那就是一一对应
connection.query(sql, params, (err, result) => {
connection.release();
if (err) {
return callback(err);
}
callback(null, result);
})
});
};

css

1
2
3
4
5
6
7
8
body { padding-top: 40px; padding-bottom: 40px; background-color: #eee; }
.form-signin {max-width: 330px; padding: 15px; margin: 0 auto;}
.form-signin .form-signin-heading,.form-signin .checkbox {margin-bottom: 10px;}
.form-signin .checkbox {font-weight: normal;}
.form-signin .form-control { position: relative; height: auto; box-sizing: border-box; padding: 10px; font-size: 16px; }
.form-signin .form-control:focus {z-index: 2;}
.form-signin input[type="email"] {margin-bottom: -1px; border-bottom-right-radius: 0; border-bottom-left-radius: 0;}
.form-signin input[type="password"] { margin-bottom: 10px; border-top-left-radius: 0; border-top-right-radius: 0; }
感谢您的支持!