前言
待过军事训练役的人肯定有着假日还要忙着回报休假状况,而回报由于还是在line里面,要马就是要麻烦班头整理,要马就是在那边卡来卡去,实在是麻烦至极,于是我趁中间的假日开始思考什么样的流程可以优化这个操作呢,最直观想到的当然是linebot,让每个人在回覆时下达指令,后端帮忙整理,最后再用特定指令叫出来,但实际写出来给邻兵试用后,发现全文字介面以及死板的指令操作造成相当大的负面回应,于是开始往具有图形介面的googleSheet编辑,再利用lineBot调用googleSheetAPI的方案走。结果遇到由于共编手机板需要下载,以及看起来弱弱的、......等原因导致推广失败。最后决定使用网页做为载体,提供单纯的入口以及图形化与特化的操作,终于成功推广,以下进行技术说明
环境架构与技术
使用者操作流程
实际画面
设定页
班级专属页
设定班级成员範例图
班级代号规则如文字说明,仅连支援中文,支持三个回报时间,进入班级页时会自动挑选下一个时间,下方按照希望呈现的顺序输入成员号码及叙述文字,最后案创建即可,不支援修改,所以创建者创建需一步到位,不然得由管理者于资料库中进行手动删除。
创建成功后取得专属网址
输入代号按进入,确定呈现内容无误即可複製该网页网址到line定为公告。
複製网址
技术说明
引用express框架
利用Visual Studio 2019 community 引用Express4框架
加载额外套件以及架构预设设定变更
加载额外套件
右键

架构预设设定变更
画面渲染引擎为ejs,页面副档名设为html,预设为别的,在这边进行修改// view engine setupapp.engine('.html', require('ejs').__express)app.set('views', path.join(__dirname, 'views')); //注意path要require一下app.set('view engine', 'html')
最后的app.js(要改的只有上面一步和删除用不到的路由,其他都是visual studio自己生成的)'use strict';var debug = require('debug');var express = require('express');var path = require('path');var favicon = require('serve-favicon');var logger = require('morgan');var cookieParser = require('cookie-parser');var bodyParser = require('body-parser');var routes = require('./routes/index');var app = express();// view engine setupapp.engine('.html', require('ejs').__express)app.set('views', path.join(__dirname, 'views')); //注意path要require一下app.set('view engine', 'html')// uncomment after placing your favicon in /public//app.use(favicon(__dirname + '/public/favicon.ico'));app.use(logger('dev'));app.use(bodyParser.json());app.use(bodyParser.urlencoded({ extended: false }));app.use(cookieParser());app.use(express.static(path.join(__dirname, 'public')));app.use('/', routes);//// catch 404 and forward to error handler//app.use(function (req, res, next) {// var err = new Error('Not Found');// err.status = 404;// next(err);//});//// error handlers//// development error handler//// will print stacktrace//if (app.get('env') === 'development') {// app.use(function (err, req, res, next) {// res.status(err.status || 500);// res.render('error', {// message: err.message,// error: err// });// });//}//// production error handler//// no stacktraces leaked to user//app.use(function (err, req, res, next) {// res.status(err.status || 500);// res.render('error', {// message: err.message,// error: {}// });//});app.set('port', process.env.PORT || 1451);var server = app.listen(app.get('port'), function () { debug('Express server listening on port ' + server.address().port); console.log(process.env.PORT || 1451);});
档案架构
后端路由(index.js)
资料库
建立一个库来储存所有班级资料
db: armyUserscollections: 班级代号(按照上面提过的命名规则)element:成员座号=>ex:30(在此设定前方的0都要省略,所以不会有030)element:成员叙述=>ex:31030 王大明(学号+空格+姓名)每个班级有一个库可以储存每次回报的内容
dbs:班级代号(按照上面提过的命名规则)collections: 年\月\日 时(ex:109/10/10 11时回报)elements:回报内容=>30:1000 在家睡觉(座号:在做的事)API
网站进入点共有2句API提供给使用者进入设定页或班级专属页
//the URL that we can connect to this Web(door of this Web)//进入班级专属页router.get("/index/:token", function (req, res) { res.render("index", { token: req.params.token });});//进入设定页router.get("/", function (req, res) { res.render("set", {});});
班级专属页为了使一句API提供给多个班级,使用了动态路由的技巧
2. 创建新班级
router.post("/buildClass", function (req, res) { //name:collection name, data: the elements in collections, it is a string can be split by <-> and <_> const name = req.body.name; const data = req.body.data;//num_include-num_include-..........-num_include MongoClient.connect(url, function (err, client) { if (err) throw err; IsExistCollection(name, client) .then(bool => insertClassData(name, data, client)) .then(bool => res.end("success")) .catch(bool => res.end("error")) .finally(bool => client.close()) });});//check if this class have existfunction IsExistCollection(name, client) { return new Promise((resolve, reject) => { var db = client.db(dbUsers); db.listCollections({ name: name }) .next(function (err, collinfo) { err ? reject(false) : (collinfo ? reject(true) : resolve(false)); }); });}//record all users in the classfunction insertClassData(name, data, client) { return new Promise((resolve, reject) => { var table = client.db(dbUsers).collection(name); //split "data" var numList = []; var includeList = []; data.split("-").forEach(element => { numList.push(element.split("_")[0]); includeList.push(element.split("_")[1]); }); var jsonList = []; for (var i in numList) { var json = {}; json["num"] = numList[i]; json["include"] = includeList[i]; jsonList.push(json); } table.insertMany(jsonList, function (err, result) { err ? reject(false) : resolve(true); }) });}
该API接受两个参数,前者为获取该创建班级的代号,后者为获取班级所有使用者的号码以及叙述(格式于上方注解中有说明)
做法是先检查有没有已创建该班级,可以藉由检查armyUsers内有没有collection名子为该班及代号来完成。
下一步是把第二个参数拆成jsonList插入资料库,collection名为班级代号。
3. 个人进行回报
router.post("/send", function (req, res) { const token = req.body.token;//each class have it's own db to save data,db's name is it's token const when = req.body.when; const who = req.body.who; const what = req.body.what; MongoClient.connect(url, function (err, client) { if (err) throw err; //console.log("Connected successfully to server"); const db = client.db(token); const collection = db.collection(when); // Insert some documents collection.updateOne({ num: who }, { $set: { num: who, include: what } }, { upsert: true }, function (err, result) { if (err) res.send("error"); else { client.close(); res.send("success"); } }); });});
该API接受以下4个参数
班级代号回报时间节点回报者座号回报内容做法是对名称为'班级代号'的DB,名称为'回报时间节点'的collection,更新一个document,内容含'回报者座号'及'回报内容'
而且要设为更新,让使用者可以进行修改,upsert要设为true,这样没有得更新时才能改为插入。
4. 刷新班级看板
router.post("/refresh", function (req, res) { const token = req.body.token;//each class have it's own db to save data,db's name is it's token const when = req.body.when; //console.log(req.body); MongoClient.connect(url, function (err, client) { if (err) throw "error"; getUsers(token, client) .then(pkg => getResponse(pkg, token, when, client)) .then(re => res.send(re)) .catch(error => res.send(error)) .finally(re => client.close()) });});function getUsers(token, client) { return new Promise((resolve, reject) => { var table = client.db(dbUsers).collection(token); table.find({}).sort({ _id : 1 }).toArray(function (err, result) { err ? reject({ result: "connect error" }) : resolve(result); }) });}function getResponse(pkg,token,when,client) { return new Promise((resolve, reject) => { var table = client.db(token).collection(when); table.find({}).toArray(function (err, result) { if (err) reject("connect error"); else { var json = {}; for (var i in result) json[result[i].num] = result[i].include; var str = ""; for (var i in pkg) str += "\n" + pkg[i].include + " : " + (json[pkg[i].num] != null ? json[pkg[i].num] : '<strong style="background-color: gray;">尚未回覆</strong>'); //console.log(reply(token, when, result.length, str)); //console.log("reply"); resolve(reply(token.split('~'), when, result.length, str)); } }) });}function reply(token,when, length, str) { return ( when + "\n" + decodeURI(token[1]).toString() + "连训员 第" + token[2] + "班\n今日看诊人员:共0员\n发烧人员:共0员\n应到:" + token[3] + "员 \n实到:" + length + "员" + str );}
这句API接受班级代号与时间节点两个参数,
作法是先到armyUsers找到名称为班级代号的collection取得班级所有设定资料
,再到名称为'班级代号'的DB,名称为'回报时间节点'的collection取得该班该时间节点的回报讯息
组合这两个资讯进行排序再回传结果字串。
完整后端(源码)
要自己加入资料库连结
'use strict';var express = require('express');var router = express.Router();const MongoClient = require("mongodb").MongoClient;// Connection URL//local mongoDB URL//const url = "mongodb://localhost:27017";//cloud mongoDB URLconst url = "这里要放云端mongoDB的连结URL"// Database Nameconst dbUsers = "armyUsers";//the URL that we can connect to this Web(door of this Web)router.get("/index/:token", function (req, res) { res.render("index", { token: req.params.token });});router.get("/", function (req, res) { res.render("set", {});});/******************************************************post: buildClass ,use it to build a collection which can let Web know who are in the classdb:armyUsers collection: (营)~(连)~(班)~(人数)~(第一时间)~(第二时间)~(第三时间)=>班级编号 element: num=>30, include=>31030 林小明token: ex:3~步一~10~17~11~14~19 => (营)~(连)~(班)~(人数)~(第一时间)~(第二时间)~(第三时间)*******************************************************/router.post("/buildClass", function (req, res) { //name:collection name, data: the elements in collections, it is a string can be split by <-> and <_> const name = req.body.name; const data = req.body.data;//num_include-num_include-..........-num_include MongoClient.connect(url, function (err, client) { if (err) throw err; IsExistCollection(name, client) .then(bool => insertClassData(name, data, client)) .then(bool => res.end("success")) .catch(bool => res.end("error")) });});//check if this class have existfunction IsExistCollection(name, client) { return new Promise((resolve, reject) => { var db = client.db(dbUsers); db.listCollections({ name: name }) .next(function (err, collinfo) { err ? reject(false) : (collinfo ? reject(true) : resolve(false)); }); });}//record all users in the classfunction insertClassData(name, data, client) { return new Promise((resolve, reject) => { var table = client.db(dbUsers).collection(name); //split "data" var numList = []; var includeList = []; data.split("-").forEach(element => { numList.push(element.split("_")[0]); includeList.push(element.split("_")[1]); }); var jsonList = []; for (var i in numList) { var json = {}; json["num"] = numList[i]; json["include"] = includeList[i]; jsonList.push(json); } table.insertMany(jsonList, function (err, result) { err ? reject(false) : resolve(true); }) });}/***************************************** post:send Record => 'when'? 'who' do "what" db:army collection:109/XX/XX XX点回报 element: num=>30,include=>1000在家睡觉 *****************************************/router.post("/send", function (req, res) { const token = req.body.token;//each class have it's own db to save data,db's name is it's token const when = req.body.when; const who = req.body.who; const what = req.body.what; MongoClient.connect(url, function (err, client) { if (err) throw err; //console.log("Connected successfully to server"); const db = client.db(token); const collection = db.collection(when); // Insert some documents collection.updateOne({ num: who }, { $set: { num: who, include: what } }, { upsert: true }, function (err, result) { if (err) res.send("error"); else { client.close(); res.send("success"); } }); });});/***************************************** post:refresh => use token to find db, and use when to get goal, finally,return it *****************************************///date + "\n一连训员 第2班\n今日看诊人员:共0员\n发烧人员:共0员\n应到:16员 \n实到:" + result.length + "员" + strrouter.post("/refresh", function (req, res) { const token = req.body.token;//each class have it's own db to save data,db's name is it's token const when = req.body.when; //console.log(req.body); MongoClient.connect(url, function (err, client) { if (err) throw "error"; getUsers(token, client) .then(pkg => getResponse(pkg, token, when, client)) .then(re => res.send(re)) .catch(error => res.send(error)); });});function getUsers(token, client) { return new Promise((resolve, reject) => { var table = client.db(dbUsers).collection(token); table.find({}).toArray(function (err, result) { err ? reject({ result: "connect error" }) : resolve(result); }) });}function getResponse(pkg,token,when,client) { return new Promise((resolve, reject) => { var table = client.db(token).collection(when); table.find({}).toArray(function (err, result) { if (err) reject("connect error"); else { var json = {}; for (var i in result) json[result[i].num] = result[i].include; var str = ""; for (var i in pkg) str += "\n" + pkg[i].include + " : " + (json[pkg[i].num] != null ? json[pkg[i].num] : '<strong style="background-color: gray;">尚未回覆</strong>'); //console.log(reply(token, when, result.length, str)); //console.log("reply"); resolve(reply(token.split('~'), when, result.length, str).replace()); } }) });}function reply(token,when, length, str) { return ( when + "\n" + decodeURI(token[1]).toString() + "连训员 第" + token[2] + "班\n今日看诊人员:共0员\n发烧人员:共0员\n应到:" + token[3] + "员 \n实到:" + length + "员" + str );}module.exports = router;
前端
因为较为简单不进行一个一个的说明仅对重点做叙述
设定页(set.html)
禁止网页放大
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
encodeURI,decodeURI
$.post("/buildClass", { name: encodeURI($("#newClassToken").val()), data: getData() }, function (result) {
由于'连'要支援中文,但之后藉由动态路由会出现再网址,要避免错误,于是利用encodeURI来进行转换,于后端(index.js)最下面的reply函数中有decodeURI把其在输出时转换回中文。
输入server网域
在最下方的函数,进行页面跳转,要加入伺服器的网域名
源码
<!DOCTYPE html><html><head> <title>放假回报</title> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"> <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script></head><body> <header> <div name="Title" class="jumbotron mb-0 "> <div class="text-center align-self-center"> <h1>放假回报</h1> </div> </div> </header> <div class="container" style="font-family:Microsoft JhengHei;font-size:100%"> <div id="input" style="text-align:center"> <label>请输入你的班级代号</label> <input id="classToken" type="text" /> <input id="jumpPage" type="button" value="进入班级回报版" /> <br> <hr /> <label>新增班级回报版</label> <br> <p>班级代号规则 -> ex:3~步一~10~17~11~14~19 => (营)~(连)~(班)~(人数)~(第一时间)~(第二时间)~(第三时间)。</p> <input type="text" id="newClassToken" placeholder="请输入班级代号"><br> <label>请按照顺序输入班级内所有成员的座号(左)及资讯(右)</label><br> <div style="display:none">01.<input id="n1" type="text" placeholder="ex:1"/><input type="text" id="s1" placeholder="ex:31001 王大明"><br></div> <div style="display:none">02.<input id="n2" type="text" placeholder="ex:2" /><input type="text" id="s2" placeholder="ex:31002 王大明"><br></div> <div style="display:none">03.<input id="n3" type="text" placeholder="ex:3" /><input type="text" id="s3" placeholder="ex:31003 王大明"><br></div> <div style="display:none">04.<input id="n4" type="text" placeholder="ex:4" /><input type="text" id="s4" placeholder="ex:31004 王大明"><br></div> <div style="display:none">05.<input id="n5" type="text" placeholder="ex:5" /><input type="text" id="s5" placeholder="ex:31005 王大明"><br></div> <div style="display:none">06.<input id="n6" type="text" placeholder="ex:6" /><input type="text" id="s6" placeholder="ex:31006 王大明"><br></div> <div style="display:none"> 07.<input id="n7" type="text" placeholder="ex:7" /><input type="text" id="s7" placeholder="ex:31007 王大明"><br></div> <div style="display:none">08.<input id="n8" type="text" placeholder="ex:8" /><input type="text" id="s8" placeholder="ex:31008 王大明"><br></div> <div style="display:none">09.<input id="n9" type="text" placeholder="ex:9" /><input type="text" id="s9" placeholder="ex:31009 王大明"><br></div> <div style="display:none"> 10.<input id="n10" type="text" placeholder="ex:10" /><input type="text" id="s10" placeholder="ex:31010 王大明"><br></div> <div style="display:none">11.<input id="n11" type="text" placeholder="ex:11" /><input type="text" id="s11" placeholder="ex:31011 王大明"><br></div> <div style="display:none">12.<input id="n12" type="text" placeholder="ex:12" /><input type="text" id="s12" placeholder="ex:31012 王大明"><br></div> <div style="display:none">13.<input id="n13" type="text" placeholder="ex:13" /><input type="text" id="s13" placeholder="ex:31013 王大明"><br></div> <div style="display:none">14.<input id="n14" type="text" placeholder="ex:14" /><input type="text" id="s14" placeholder="ex:31014 王大明"><br></div> <div style="display:none">15.<input id="n15" type="text" placeholder="ex:15" /><input type="text" id="s15" placeholder="ex:31015 王大明"><br></div> <div style="display:none">16.<input id="n16" type="text" placeholder="ex:16" /><input type="text" id="s16" placeholder="ex:31016 王大明"><br></div> <div style="display:none">17.<input id="n17" type="text" placeholder="ex:17" /><input type="text" id="s17" placeholder="ex:31017 王大明"><br></div> <div style="display:none">18.<input id="n18" type="text" placeholder="ex:18" /><input type="text" id="s18" placeholder="ex:31018 王大明"><br></div> <div style="display:none">19.<input id="n19" type="text" placeholder="ex:19" /><input type="text" id="s19" placeholder="ex:31019 王大明"><br></div> <div style="display:none">20.<input id="n20" type="text" placeholder="ex:20" /><input type="text" id="s20" placeholder="ex:31020 王大明"><br></div> <br> <input id="PushUser" type="button" value="增加成员" /> <input id="PopUser" type="button" value="减少成员" /> <br /><br /> <button id="buildClass" class="btn btn-success">创建班级</button> </div> </div> <br> <br> <script> var count = 16; function getData() { var str = $("#n1").val() + "_" + $("#s1").val(); for (var i = 2; i <= count; i++) str += "-" + $("#n" + i).val() + "_" + $("#s" + i).val(); return str; } $(document).ready(function () { for (var i = count; i >= 1; i--) $("#s" + i).parent("div").show(); $("#PushUser").click(function () { if (count < 20) $("#s" + (++count)).parent("div").show(); else alert("20人为班级人数的极限"); }); $("#PopUser").click(function () { if (count > 2) $("#s" + (count--)).parent("div").hide(); else alert("2人为班级人数的最小值"); }); $("#buildClass").click(function () { if ($("#newClassToken").val() != null) { $.post("/buildClass", { name: encodeURI($("#newClassToken").val()), data: getData() }, function (result) { alert(result); }) } else alert("请填入课程代号"); }); $("#jumpPage").click(function () { location.href = "这里输入伺服器网域名" +"/index/" + $("#classToken").val(); //location.href = "http://127.0.0.1:1337/index/" + encodeURI($("#classToken").val()); }); }); </script></body></html>
班级页(index.html)
複製按钮
function Copy(str) { //创建一个textarea标籤,由于该网页API操作仅可对此标籤进行 var clip_area = document.createElement('textarea'); //把内容放入标籤 clip_area.textContent = str; //新增标籤至实际网页 document.body.appendChild(clip_area); //选取该标籤 clip_area.select(); //执行複製指令 document.execCommand('copy'); //移除标籤 clip_area.remove(); alert("已複製好,可黏贴"); }
源码
<!DOCTYPE html><html><head> <title>放假回报</title> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"> <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script></head><body> <header> <div name="Title" class="jumbotron mb-0 "> <div class="text-center align-self-center"> <h1>放假回报</h1> </div> </div> </header> <div class="container" style="font-family:Microsoft JhengHei;font-size:100%"> <div id="input" style="text-align:center"> <label>请输入你的学号末两码</label> <input type="number" id="number"> <br> <label>回报时间</label> <select id="select"> <option id="option1" value=""></option> <option id="option2" value=""></option> <option id="option3" value=""></option> </select> <br> <input type="text" id="text" placeholder="请输入回报内容"> <button id="send">传送</button> <pre id="include"></pre> <button id="duplicate">複製</button> <button id="refresh">刷新</button> </div> </div> <script> $(document).ready(function () { var token = encodeURI('<%=token%>'); //console.log(token); var dt = new Date(); var now = dt.getHours(); var select = document.getElementById("select"); const times = token.split('~'); $("#option1").val(times[4] + "时回报"); $("#option2").val(times[5] + "时回报"); $("#option3").val(times[6] + "时回报"); $("#option1").html(times[4] + "时回报"); $("#option2").html(times[5] + "时回报"); $("#option3").html(times[6] + "时回报"); if (now <= parseInt(times[4]) + 1) select.options[0].selected = true; else if (now <= parseInt(times[5]) + 1) select.options[1].selected = true; else select.options[2].selected = true; if (localStorage.getItem("num")) $("#number").val(localStorage.getItem("num")); $.post("/refresh", { token: token, when: (parseInt(dt.getFullYear()) - 1911).toString() + "/" + (parseInt(dt.getMonth()) + 1).toString() + "/" + dt.getDate().toString() + " " + $("select").val() }, function (result) { $("pre").html(result); }) $("#send").click(function () { if (parseInt($("#number").val()) >= 0) { $.post("/send", { token: token, when: (parseInt(dt.getFullYear()) - 1911).toString() + "/" + (parseInt(dt.getMonth()) + 1).toString() + "/" + dt.getDate().toString() + " " + $("select").val(), who: parseInt($("#number").val()).toString(), what: $("#text").val() }, function (result) { $.post("/refresh", { token: token, when: (parseInt(dt.getFullYear()) - 1911).toString() + "/" + (parseInt(dt.getMonth()) + 1).toString() + "/" + dt.getDate().toString() + " " + $("select").val() }, function (result) { $("pre").html(result); $("#text").val(''); }) }) localStorage.setItem("num", $("#number").val().toString()); } else { alert('请检查你的学号是否输入正确'); } }); $("#refresh").click(function () { $.post("/refresh", { token: token, when: (parseInt(dt.getFullYear()) - 1911).toString() + "/" + (parseInt(dt.getMonth()) + 1).toString() + "/" + dt.getDate().toString() + " " + $("select").val() }, function (result) { $("pre").html(result); }) }); function Copy(str) { var clip_area = document.createElement('textarea'); clip_area.textContent = str; document.body.appendChild(clip_area); clip_area.select(); document.execCommand('copy'); clip_area.remove(); alert("已複製好,可黏贴"); } $("#duplicate").click(function () { Copy($("pre").html().replace(/<[^>]+>/g, "")); }) $("select").change(function () { $.post("/refresh", { token: token, when: (parseInt(dt.getFullYear()) - 1911).toString() + "/" + (parseInt(dt.getMonth()) + 1).toString() + "/" + dt.getDate().toString() + " " + $("select").val() }, function (result) { $("pre").html(result); }) }); }) </script></body></html>
建构
若要实际发布此网站,在引用架构,编写所有档案后还不够还有资料库与伺服器的问题,不过由于不是本篇重点,所以我将简单带过。
资料库
资料库我是使用MongoDB Altis,帐号申办简单,以本专案来说也有相当够用的免费存储空间。
伺服器
我个人用过GCP(google clooud platform)在刚使用有一定额度的免费,但个人经验一下就用完了,而且使用複杂度相对较高。
其他就是各种云伺服器。
不过以我个人而言最喜欢使用的方案是安装在个人可连接外网的机器里,用pm2发布。
后话
git连结:
https://github.com/leon123858/soldiers_response_system/tree/main/Web/Web
若想直接使用,记得在index.js加入资料库连结网址,在set.html加入伺服器网域才可以顺利运作,在上方内容源码区都有用中文补在该插入的地方。
此外
若讲述不好欢迎建议或补充,
若讲述有误欢迎指正。