待过军事训练役的人肯定有着假日还要忙着回报休假状况,而回报由於还是在line里面,要马就是要麻烦班头整理,要马就是在那边卡来卡去,实在是麻烦至极,於是我趁中间的假日开始思考什麽样的流程可以优化这个操作呢,最直观想到的当然是linebot,让每个人在回覆时下达指令,後端帮忙整理,最後再用特定指令叫出来,但实际写出来给邻兵试用後,发现全文字介面以及死板的指令操作造成相当大的负面回应,於是开始往具有图形介面的googleSheet编辑,再利用lineBot调用googleSheetAPI的方案走。结果遇到由於共编手机板需要下载,以及看起来弱弱的、......等原因导致推广失败。最後决定使用网页做为载体,提供单纯的入口以及图形化与特化的操作,终於成功推广,以下进行技术说明
後端环境 | 前端环境 | 後端框架 | 前端框架 | 资料库 | 资料库介面 |
---|---|---|---|---|---|
node | line浏览器 | express | boostrap | mongodb | mongoClient(mongoDB原生) |
设定页
班级专属页
设定班级成员范例图
创建成功後取得专属网址
复制网址
利用Visual Studio 2019 community 引用Express4框架
加载额外套件
架构预设设定变更
// view engine setup
app.engine('.html', require('ejs').__express)
app.set('views', path.join(__dirname, 'views')); //注意path要require一下
app.set('view engine', 'html')
'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 setup
app.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);
});
建立一个库来储存所有班级资料
每个班级有一个库可以储存每次回报的内容
//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 exist
function 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 class
function 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 URL
const url = "这里要放云端mongoDB的连结URL"
// Database Name
const 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 class
db: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 exist
function 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 class
function 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 + "员" + str
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));
});
});
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;
因为较为简单不进行一个一个的说明仅对重点做叙述
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
$.post("/buildClass", { name: encodeURI($("#newClassToken").val()), data: getData() }, function (result) {
由於'连'要支援中文,但之後藉由动态路由会出现再网址,要避免错误,於是利用encodeURI来进行转换,於後端(index.js)最下面的reply函数中有decodeURI把其在输出时转换回中文。
在最下方的函数,进行页面跳转,要加入服务器的网域名
<!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>
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加入服务器网域才可以顺利运作,在上方内容源码区都有用中文补在该插入的地方。
此外
若讲述不好欢迎建议或补充,
若讲述有误欢迎指正。
<<: 【元件如何正确使用 ?】元件耦合性三大原则 : ADP、SDP、SAP
>>: Gulp 合并来自 npm 的 Javascript的资源 DAY96
在 @IBAction 里 令 vc 为 UIImagePickerController let v...
Skipfish 是一个主动的Web应用程序安全测试工具 透过执行递归爬网和基於字典的探测 易於使用...
tags: ItIron2021 Javascript 前言 对点进来的你说一下,恭喜你撑到今天! ...
今年的疫情蛮严重的,希望大家都过得安好,希望疫情快点过去, 能回到一些线下技术聚会的时光~ 祝福大家...
咦?To-Do-List 怎麽突然结束了!? 恩…主要是最近接到了不少新的任务,而我想,To Do ...