-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathsearch.json
1 lines (1 loc) · 53 KB
/
search.json
1
[{"title":"Vue + NodeJS + MySQL 搭建文章后台管理系统","url":"/Projects/article-admin/","content":"\n功能、目录结构、设计说明和一些小细节。\n\n\n\n概览Article Admin 是一个前后端分离的文章/博客管理系统。前端采用Vue2.x并结合ElementUI,后端使用Node.js的Express框架,数据库为MySQL8.0。\n本项目是HITSZ 21春数据库系统课程实验四的作品。\n\n在线demo怕被攻击就不放出了🤐\n\nrepository: [github]WoodenStone/article_admin\nSETUP克隆项目git clone https://github.com/WoodenStone/article_admin.git\n\n数据库将articleAdmin.sql导入MySQL即可生成数据库。\n\n ⭕由于存在同一张表上存在多个触发器的情况,MySQL版本不能低于5.7。\n\n后端# 进入项目目录cd article_admin/node_back_end# 安装依赖npm install# 启动项目node app.js\n\n注意:需要修改数据库用户名、密码。\n前端# 进入项目目录cd article_admin/vue_front_end# 安装依赖npm install# 启动项目npm run dev\n\n访问 http://localhost:9529 查看前端界面。\n部署数据库:\n安装不低于5.7版本的MySQL,并导入articleAdmin.sql执行建表。\n前端:\n根据需要配置生产环境的路径,然后运行:\n# 生成静态文件npm run build\n\n将/dist目录下的文件放在服务器可访问路径下(如/www)。\n后端:\n修改/src/sql/db.js中的数据库配置,修改/app.js中的图片上传路径。\n使用pm2或forever等工具启动项目,保持后端为开启状态。\n最后修改nginx配置,通过ip或域名访问查看效果。\n预览\n\n\n\n\n\n\n主要功能- 注册- 登录 / 注销- 用户信息更改- 文章管理\t- 发布文章\t- 删除文章\t- 编辑文章\t- 模糊搜索\t- 点赞\t- 收藏到特定收藏夹\t- Markdown编辑器及图片插入\t- 文章标签\t- 按特定方式排序(时间倒序、赞数降序、评论数降序、收藏数降序)- 评论回复\t- 评论文章\t- 回复用户\t- 查看个人收到的评论、回复\t- 收藏夹\t- 添加 / 删除收藏夹\t- 更改收藏夹名及描述\t- 内部文章查看、删除、移动- 用户关注\t- 关注和取关- 站内信\t- 收 / 发站内信\t- 阅读状态标记\t- 输入错误地址时重定向至404\n\n\n\n目录结构.|-- node_back_end\t\t# 后端工程文件| |-- app.js\t\t\t# 主要API| |-- package-lock.json| |-- package.json\t\t# 配置文件| |-- public\t\t\t# 静态目录| |-- src\t\t\t# 数据库相关操作| |-- upload\t\t\t# 上传图片存放|-- vue_front_end\t\t# 前端工程文件 |-- babel.config.js |-- package-lock.json |-- package.json\t\t# 项目配置 |-- public\t\t\t# html模板和网站图标 | |-- favicon.ico | |-- index.html |-- src\t\t\t# 主要代码 | |-- App.vue | |-- assets\t\t# 静态资源,如图标字体等 | |-- components\t\t# 公用组件 | | |-- ArticleList\t# 文章列表组件 | | |-- BreadCrumb\t# 导航栏面包屑 | | |-- Collection\t# 收藏夹组件 | | |-- Comment\t\t# 评论、回复组件 | | |-- Message\t\t# 站内信(私信)组件 | | |-- Tags\t\t# 文章标签组件 | |-- layout\t\t# 页面基础布局 | |-- main.js\t\t# 入口文件 加载组件等 | |-- plugins\t\t# 引入的插件 | |-- router\t\t# 路由 | |-- settings.js | |-- store\t\t# 全局store管理 | |-- styles\t\t# 全局样式 | |-- utils\t\t# 常用方法 | |-- views\t\t# 各页面 | |-- error-page\t# 404页面 | |-- form\t\t# 表单 实现文章修改和创建 | |-- home\t\t# 站点主页面 | |-- login\t\t# 登录、注册 | |-- table | | |-- detail.vue\t# 文章详情 | | |-- index.vue\t# 文章列表 | |-- user\t\t# 用户相关页面 | |-- change.vue\t\t\t# 修改用户信息 | |-- collection.vue\t\t# 收藏夹 | |-- components | | |-- UserInfo.vue\t\t# 用户信息主页面 | |-- favorite.vue\t\t# 收藏的文章 | |-- index.vue\t\t\t# 路由入口 | |-- message.vue\t\t\t# 站内信页面 | |-- visitor.vue\t\t\t# 访客界面 |-- vue.config.js\t\t# vue-cli配置\n\n设计详细说明数据库概览数据库设计共包含8个实体,14个联系。\n\n努力地想让数据库满足第三范式……\n\nER图:\n\n\n补充说明\ncomments - 评论回复表\n\n对于评论表,数据库字段如下:\ncomment_id | publisher_id | recipient_id | article_commented_id | content | create_time | is_reply | comment_index\n\ncomment_id 为主键,标识某条评论或回复的唯一 ID。 \npublisher_id,recipient_id 和 article_commented_id 均为外键,分别对应 user_info 用户信息表中的 user_id、user_id 和 article 文章信息表中的 id, 表示发布者 id,接收方 id 和被评论文章的 id。 \n其 中 comment_id 、 publisher_id 、 article_commented_id 、 content 和 create_time 均不能为空,表示需要唯一确定某篇文章下由某个用户所发表的某条评论。而 recipient_id 可以为空,因为如果用户直接对某篇文章发表回复,就不需要特意指定接收者 ID(即文章作者 ID);相应地,如果不为空,则需要在 is_reply 中指定为 1,并且指定接收者 ID 和该评论在该文章中的索引。comment_index 字段的设置是由于一个用户可能在某篇 文章下发表多条评论,直接查找 comment_id 过于繁琐,因此显式指定其文章内索引。下面是 api 接口返回的一个实例:\n[ { "comment_id": 35, "publisher_id": 1, "recipient_id": null, "article_commented_id": 20, "content": "月が綺麗ですね", "create_time": "2021-11-22T12:54:36.000Z", "is_reply": null, "comment_index": 0, "children": [ { "comment_id": 36, "publisher_id": 1, "recipient_id": 1, "article_commented_id": 20, "content": "月が綺麗ですね", "create_time": "2021-11-22T12:54:40.000Z", "is_reply": 1, "comment_index": 0, "publisher_name": "admin", "recipient_name": "admin" } ], "publisher_name": "admin" }]\n\n\n\n后端后端没有完整的架构,仅提供RESTful API用于操作数据库,以便增删查改。\nAPI均以/api/前缀,并在注释简要说明了所提供的功能,主要包括:\n\n\n\n名称\n方法\n简要功能\n\n\n\nLogin\nPOST\n用户登录\n\n\nRegister\nPOST\n用户注册\n\n\nuserInfoChange\nPOST\n用户信息更改\n\n\nuploadAvatar\nPOST\n头像上传\n\n\nArticle\nGET\n获取带有标签的文章列表\n\n\npersonalArticle\nGET\n获取某用户的文章\n\n\narticleDetail\nGET\n获取某篇文章信息及标签\n\n\ndeleteArticle\nDELETE\n删除某一篇文章\n\n\naddArticle\nPOST\n添加文章\n\n\nupdateArticle\nPOST\n更新某一篇文章\n\n\nimgUpload\nPOST\n文章内图片上传\n\n\ngetAvatar\nGET\n由用户ID获取用户头像\n\n\ngetAvatarByName\nGET\n由name获取头像\n\n\ngetIdByName\nGET\n由用户名获取id\n\n\nthumbupStatus\nGET\n获取用户对某篇文章的点赞状态\n\n\nfavoriteStatus\nGET\n获取用户对某篇文章的收藏状态\n\n\nthumbupStatus\nPOST\n变更点赞状态\n\n\nfavoriteStatus\nPOST\n变更收藏状态\n\n\ngetRelationStatusOne\nGET\n获取一对一关注状态\n\n\nfollowStatusChange\nPOST\n变更关注状态\n\n\ngetFollowerNumber\nGET\n获取被关注数\n\n\ngetFollowingNumber\nGET\n获取关注数\n\n\ncommentsOfOneArticle\nGET\n获取某篇文章的评论和回复\n\n\nComment\nPOST\n添加文章评论或回复\n\n\nFollowersList\nGET\n获取被关注列表\n\n\nFollowingList\nGET\n获取关注人列表\n\n\nCommentsReceived\nGET\n某用户收到的评论和回复\n\n\nMessageNum\nGET\n某用户收到的站内信总数\n\n\nMessageNumOut\nGET\n某用户发出的站内信总数\n\n\ncurrentDirectMessage\nGET\n分页获取某用户收到的站内信\n\n\ncurrentMessageOut\nGET\n分页获取某用户发出的站内信\n\n\nmessageReadStatus\nPOST\n更改某条站内信的阅读状态\n\n\nnewMessage\nPOST\n发送站内信\n\n\nmessage\nDELETE\n删除某条站内信\n\n\ncollection\nGET\n获取某用户的收藏夹\n\n\nfavoriteArticle\nGET\n获取某用户某收藏夹的文章\n\n\nnewCollection\nPOST\n创建新收藏夹\n\n\ncollectionInfo\nPOST\n修改收藏夹信息\n\n\ncollection\nDELETE\n删除收藏夹\n\n\ncollectionStatus\nGET\n查找某用户将某篇文章收藏于哪些收藏夹\n\n\narticleFavorite\nPOST\n添加某篇文章到某些收藏夹\n\n\nArticleThumbupDesc\nGET\n获取赞数降序的文章列表\n\n\nArticleFavoriteDesc\nGET\n获取收藏降序的文章列表\n\n\nArticleCommentsDesc\nGET\n获取回复评论数降序的文章列表\n\n\nsearch\nPOST\n模糊搜索,包含标题、内容、作者\n\n\nsearchName\nPOST\n用户名输入提示\n\n\nstatistic\nGET\n首页创作统计数据\n\n\n补充说明文章标签由于文章和标签是多对多关系,故数据库设计将文章标签id和文章id单独抽取出来组成一个描述映射的关系,而文章表(article)和标签表(tag)独立存在。这就给修改标签带来了麻烦。\n在更新文章时,每个标签都可能被更改、删除,因此采用的方式是在先删除该文章原有的标签映射(article_tag表),再进行标签表(tag)的更新,最后重新建立该文章和标签的映射(article_tag表)。这个流程是:\ndelete tag mappings -> add tags -> add tag mappings \n\n可能效率比较低,或许后续能找到更好的方式。\n图片上传图片上传采用了multer中间件,用到的地方有用户头像上传和文章内图片上传。主要思路都是:\n\n将图片上传至服务器\n将服务器路径存入数据库\n将服务器路径返回,前端回显\n\n具体的操作有细微不同,且需要进行静态资源路径的设置。\n评论和回复评论和回复的sql逻辑不太明显,因为数据库表字段的设计造成了一些麻烦。\n两个主要的功能:①查询某篇文章下的评论回复和②查询某用户收到的评论回复。\n\n查询某篇文章下的评论回复\n\n由于需要返回的是一个最多2层高的树,示例如:\n- 评论1\t- 回复1\t- 回复2- 评论2\t- 回复1\t- 回复2- 评论3\n\n故采取的方式是先找到该文章下的所有评论,得到一个包含全部评论id的数组,再依次查找每条评论下是否有存在回复,如果存在回复,就拼接到children数组中。\n在查找回复的sql中,不能指定接收者的id(即comments表中的recipient_id),因为回复有可能是楼中楼的沟通,如:\n- A 评论[content]\t- B 回复 A \t# 回复1\t- C 回复 B\t# 回复2\t- C 回复 C\t# 回复3\n\n如果指定接收用户id,可能导致回复2和回复3都无法收取到。\n这里也比较降低效率的是需要反复地获取用户名(或者进行表的连接),因为数据库设计都是以user_id作为外键关联。\n\n查询某用户收到的评论回复\n\n以查询某用户收到的评论为例,说明一下sql的逻辑。\n首先,要在article表中找到该用户所发表的文章id,然后根据文章id在comments表中查询收到的评论(排除自己发表的评论),最后需要拼接上发表者用户名和文章标题。\nsql:\nSELECT ar.title, co.publisher_name, co.publisher_id, co.article_commented_id, co.content, co.create_time, co.is_replyFROM (SELECT us.user_name AS publisher_name, uc.publisher_id, uc.article_commented_id, uc.content, uc.create_time, uc.is_reply FROM (SELECT c.publisher_id, c.article_commented_id, c.content, c.create_time, is_reply FROM comments c, article a, user_info u WHERE c.article_commented_id = a.id AND a.author_id = u.user_id AND u.user_id = ${uid} AND is_reply is null AND publisher_id <> ${uid}) AS uc, user_info us WHERE uc.publisher_id = us.user_id ) AS co, article ar WHERE co.article_commented_id = ar.id;\n\n${uid}是传入的参数。\n这两个功能使用db()分别返回一个Promise,最后使用Promise.all()一同处理,将得到的结果拼接,返回给前端。\n\n后端现在基本改用参数化查询了,会对双引号等做转义,并且能预防SQL注入。\n各种原理还是不太懂,不过到处在用promise、async / await,对这些更了解了。\n\n前端概览前端根据数据库设计,主要有登录注册、个人主页、文章、站内信、收藏几个主要路由,分为登录注册、文章增删查改、站内信、评论、站内信、收藏几个主要模块来实现。\n采用vue-cli脚手架搭建项目,主要使用ES6语法编写代码。使用vue-router进行路由管理,Less作为CSS预处理器,Axios进行前后端数据交互。\n补充说明登录注册第一版:登录采取的是比较简陋(不科学)的方式:用户输入用户名、密码后向服务端验证正确性,若正确则将信息存入 localStorage,权限也是写死在用户信息中的(作为数据库表中的一个字段存在)。这是考虑到作为一个博客后台管理系统,或者说带有部分社交属性(私信、评论)的系统,管理员的权限并不需要和普通用户做出非常大的区分。登录信息过期通过代码设置 localStorage 的有效期为 7 天。\n\n21/12/28 update\n注册登录现在采用了jwt做验证。具体流程是:\n\n用户第一次登录时,后端签发token,设置有效期7天\n前端拿到token存入cookie中(or localStorage),利用axios拦截器在每次发送请求时都在请求头上塞进这个token(这里叫aa-token)\n后端对除白名单内的路由进行token校验(白名单就是登录、注册这两个),如果校验失败,根据消息提示告诉前端是token过期了还是没有登录;否则正常处理\n如过期,前端用相应拦截器根据返回的错误状态码展示提示,并跳转到登录页;否则正常处理\n\n同时,前端还使用route.beforeEach在进入每个路由前都进行判断是否有token存在。如果token过期,不发请求应该是感受不到的。如果进行操作,就会进入到登录页。\n如果要无感保持登录,最好加上refresh token。\n由于原本的很多代码都是基于localStorage中的信息写的,为了尽可能地少改点代码(否则真的要重写了),在登录后还是会将信息存到localStorage中。不过它的过期时间已经没意义了,都由token进行外层的保护。\n还有一点修改的是头像上传由于用的是el-upload组件,因此要手动挂上请求头,返回的时候一样处理。如果恰好在头像上传的过程中token过期了,也需要在失败处理中重定向到登录页。\n\n文章列表展示文章列表的主要组件位于components/ArticleList下,实现功能为文章列表的展示,可选项包括:\n\n是否展示包含新增、搜索和排序的工具栏 - showHeader\n是否显示作者 - showAuthor\n是否展示内容预览 - showContent\n使用场景:个人文章 - personal\n使用场景:收藏夹内 - collection\n\n该组件在文章列表(路由/table)、个人收藏夹内页面(/user/favorite)、用户个人文章(包含自己的和访客所见的文章: /user/index和/user/visitor)页面均有使用。\n\n现采用前端分页。\n\n文章排序排序方式有:默认时间倒序、按赞数降序、按收藏数降序和按评论数降序,后三种后端返回的都是一个文章id数组,按指定方式降序排列。\n如按赞数降序返回的是一个形如[6, 9, 10, 1]的数组,表明赞数为6>9>10>1>其它,未出现的文章赞数为0(表中根本没有这篇文章被赞过的记录)。前端根据这个数组进行交换排序:\n/** * @description:: 根据传入的index索引对array进行交换排序 * @param {Array} index * @param {Array} array * @return {*} * @author: WoodenStone */interchange (index, array) { for (let i = 0; i < index.length; i++) { if (array[i].id !== index[i].id) { let temp = {} for (let j = i + 1; j < array.length; j++) { if (array[j].id === index[i].id) { // 用set或者splice来更新视图 // 现在采用了分页,不需要set了,见后面的分页 temp = array[j] this.$set(array, j, array[i]) this.$set(array, i, temp) } } } }},\n\n\n这么写是在后端排好序返回一个大数据包和前端排序进行交换哪个更快进行了选择,不过或许哪个都不是好的选择……\n\n分页\n新增功能\n\n需要分页的地方有两个,一个是文章的列表,一个是收到 / 发出的站内信。现在采用的实现方法是:文章列表用前端分页,站内信用查询分页。\n文章列表分页列表的前端分页和分页查询怎么选择?如果选择分页查询的话,需要考虑:\n\n按各种不同排序的查询分页\n按不同场景查询的分页(总的文章列表、个人收藏、个人的文章、别人的文章)\n\n需要修改的接口很多。\n如果是前端分页,虽然第一次要请求所有的数据,但是由于不用请求文章内容,只需要每次将标题、作者、时间这样的一条条数据拿过来,负担不会很大。对于不同的排序方式,只需要在排序完更新当前需要展示的页面即可,相对来说改动的工作量不大。\n综合上面的考虑选择了前端分页。\n在不同方式进行排序更新的页面的时候,也不需要用$set更新articles,因为视图展示依赖的数据不是articles,只要在排完序之后更新一下当前页面展示的数据即可:\ncurrentPageData () { this.currentPageArticles = this.articles.slice( (this.currentPage - 1) * this.pageSize, this.currentPage * this.pageSize )}\n\n站内信分页一般来说看站内信就看个最新收到,没有必要一次性把所有的都select出来,两种方式修改的工作量差不多,考虑到可能有的大量垃圾信还是用了查询分页,SQL也很简单,加上合适的limit和offset参数就行了。不过一次查一点需要先查一下总的条目数,单独返回一个total,这里新增了两个接口。\n典型的SQL,用逆序主键保证查出来的是最新的:\nselect d.message_id, d.addresser_id, d.read_status, d.delivery_time, d.content, d.title, u.user_name as addresser_name \tfrom direct_message d, user_info u where d.consignee_id = ${req.query.userID} and d.addresser_id = u.user_id order by message_id desc limit ${req.query.pageSize} offset ${req.query.offset};\n\n文章标签核心组件位于/src/components/Tags,主要实现功能为输入标签,按回车键添加标签,按DELETE键删除标签。单个标签的字数和一篇文章最多可有的标签数均作出限制。\n评论回复找了一些开源轮子,没找到满意的,最后还是自己实现一个。核心组件位于/src/components/Comment下,分为单个回复(ReplyItem)、单个评论(包含回复,CommentItem)和所有评论(CommentGroup),给index传入正确的数据即可展示,评论是最多两层的树,效果如下图:\n\n\n评论的回复采取的是ElementUI中的dialog组件实现,并使用开源轮子封装的v-dialogDrag指令使得dialog框可以拖动。在回复和评论中的“回复”按钮是在CommentGroup中组装的,换言之CommentItem和ReplyItem其实是兄弟关系。这样做是因为想把“回复”这个需要调用接口,传递数据的功能尽可能集中起来,就不用再使用$emit()等方法传参了。不过从设计上来看,可能设计为父子关系会更为直观一些。\n\n前端是越看需要改进的地方越多😑 重构❎ 重写✅\n虽然这个项目叫做“xxx后台管理系统”,但是只是为了符合“xxx后台管理系统”的叫法……从实现上来看,文章详情页、收藏、站内信都不能说很符合这个场景,更像是一个综合在一起的应用。\n最后,自己给自己提需求实在是太困难了!每当开始思考用户画像的时候,都会陷入“这种东西真的会有人想用吗”的怀疑中。从功能上来说,其实脑海里想的是lofter,但是lofter的目标群体还是挺明确的,而且它网页版和手机版差得不是一点半点,用起来感觉也怪怪的。最终还是在“既然想了就得做出来吧”这种念头的鼓励下做了。嗯,用来放点自己的零散想法还是可以的。第一个项目,之后还是想尝试点别的,RN?electron?以后再说咯。\n\n参考vue-element-admin的基础模板\n","categories":["Projects"],"tags":["vue","nodejs","MySQL"]},{"title":"写个脚本保护视力","url":"/Whim/create-my-dark-mode/","content":"\n用 JS 给网页加个深色模式保护眼睛(和一个失败的实现)。\n\n\n\n起因最近在看的一些技术文档都是用VuePress 写的,虽然很简洁好看,但是大部分网页都是白屏黑字,对于一个每天除了睡觉就是盯着电子屏幕的人来说,#fff的背景色配上黑色小字实在是太伤眼睛了。这我大JS不就派上用场了?为了保护视力,写个脚本加个深色模式吧!\n开干首先分析下页面结构,主页面是个叫page的class,侧边栏是个叫sidebar的class,那么很简单,直接用qeurySelector改改颜色就行了:\ndocument.querySelector(".page").style.backgroundColor = '#111';document.querySelector(".page").style.color = '#ccc';document.querySelector(".sidebar").style.backgroundColor = '#111';\n\n改完之后的效果:\n\n\n果然舒服多了。而且由于前端路由的特性,页面变化相当于在document这个容器中装各种各样的内容,这样切换到别的页面也还是深色模式。但是技术文档的特点就是有很多行内代码块,在VuePress中颜色是#476582,深色背景下就不是很友好,比如:\n\n\n这也很简单,行内代码都是用<code></code>包裹的,那直接选中改色不就完了:\nconst codeList = Array.from(document.querySelectorAll("code"));codeList.forEach(item => {item.style.color = "#54f36f";});\n\n大功告成,现在的效果是这样的:\n\n\n但是,当我们跳转到另一个页面,行内代码的颜色又变回去了🤒\n因此,新的问题就是:怎样在路由变化时也能让行内代码变为想要的颜色呢?\n根据Vue Router的两种实现方式:Hash Router和History Router,可以很容易的想到只要监听路由变化,在路由变化的时候改个颜色就行了呀!如果是Hash Router,就非常简单,直接监听hashchange事件就行了。但是很多网站为了优雅,都是使用的History Router模式(但我怀疑使用Hash Router是不是没法用锚点链接和copy link to highlight了?在自己的站点上测试了下貌似没法用)。\n复习下History Router的实现方式,主要就是利用History API:\n\n用pushState和replaceState来实现页面内容的更新和会话历史栈的更新\n用popState来监听前进后退等事件\n\npushState和replaceState是相辅相成的,前者记录历史,后者切换url,这样点击浏览器的前进后退时也能滚动到锚点位置,而且有好看的滚动动画。那么监听哪个事件呢?\n从使用场景上来想,如果是在一个文档中有不同的标题,那么进入文档时,首先进行pushState记录历史(url),然后使用replaceState定位到当前的锚点链接(标题),在滑动浏览的过程中,不断使用replaceState来更新当前的锚点链接,而直接点击某个小标题时,则使用pushState保证回退时可以回退到正确位置。这样看来应该是使用pushState执行的次数会少一些。\n接下来就开始实现了。由于个人使用,不用考虑兼容性什么的问题(日常用IE那是不可能的),直接用ES6的proxy做代理即可:\nhistory.pushState = new Proxy(history.pushState, { apply: function (target, thisBinding, args) { const codeList = Array.from(document.querySelectorAll("code")); if(codeList[0].style.color !== "rgb(84, 243, 111)") { codeList.forEach(item => {item.style= "color: #54f36f";}); } return target.apply(thisBinding, args); },});\n\n判断是为了减少反复更改的次数。不过这么写还有一个问题,就是如果从侧边栏进入到另一个文档,第一次视图不会更新。这个问题不知道该怎么解决,我目前的做法是手动再push一遍,如下:\nhistory.pushState = new Proxy(history.pushState, { apply: function (target, thisBinding, args) { const codeList = Array.from(document.querySelectorAll("code")); if(codeList[0].style.color !== "rgb(84, 243, 111)") { codeList.forEach(item => {item.style= "color: #54f36f";}); } window.pushState(window.location.href)\t\t// 加上这一句 return target.apply(thisBinding, args); },});\n\n这样会导致得回退两次才能退回去,且加载时会有一闪而过的白屏。\n接着给window添加一个onload事件,让第一次进入页面时代码就亮起来:\nwindow.onload = function() { const codeList = Array.from(document.querySelectorAll("code")); codeList.forEach(item => {item.style.color = "#54f36f";});}\n\n最后是侧边栏,不过我觉得侧边栏不是很有必要,稍微写一下:\nArray.from(document.querySelectorAll(".sidebar-link:not(.sidebar-link.active)")).forEach(item => {item.style.color="#aaaaaa"})\n\n最终效果:\n\n\n最后为了保护眼睛折腾了好半天,最后实现的效果还不尽如人意,健康真费劲啊。从结果上说,可能看屏幕一小时就出去晒晒太阳是更经济的选择。不过借这个突发奇想复习了下history router,也挺好的。\n","categories":["Whim"],"tags":["技术随笔","History Router"]},{"title":"如何为 Node.js 命令行工具添加单元测试","url":"/Projects/how-to-add-unit-test-for-nodejs-cli-tools/","content":"\n使用 jest 和 @oclif/test 为基于 oclif 的 CLI 工具编写单元测试。\n\n\n\n背景实习入职以来第一个遇到的比较有意思的问题:如何为 CLI 添加单元测试?在此之前,不仅对于 Node 如何实现 CLI 一窍不通,对单元测试也是一窍不通😥。需要添加单测的 CLI 工具基于 oclif,这是一个非常简便好用、能快速上手的 CLI 开发框架,相比于历史悠久应用甚广的 commander.js ,它提供了更好的 multi command 支持,便于扩展的 Commander 类,内置的前处理后处理 hook,使得开发者能够专注于功能命令的开发。(当然,这并不代表 commander.js 就不能实现一样的优雅开发,事实上团队内部一个基于 commander.js 的 CLI 的架构封装的非常巧妙精致,各种 IoC 手到擒来,并实现了前处理后处理和命令基类,让人受益匪浅。不过本文的探索过程与 oclif 框架有一定关系,因此先在此处说明。)在官方文档中,它推荐使用 @oclif/test 进行单测,但在使用的过程中,我依然遇到了一些问题。本文将记录从零探索的过程,并且给出我的解决方案。\n尝试@oclif/test既然是官方文档推荐,就不能不体验一下。文档中给出的例子非常简单:\n// 来源于文档import {expect, test} from '@oclif/test'describe('auth:whoami', () => { test .nock('https://api.heroku.com', api => api .get('/account') // user is logged in, return their name .reply(200, {email: '[email protected]'}) ) .stdout() .command(['auth:whoami']) .it('shows user email when logged in', ctx => { expect(ctx.stdout).to.equal('[email protected]\\n') })})\n\n这个例子使用 nock(nock,一个用来模拟 http 请求的包)模拟发送一个 http 请求,然后 mock 标准输出stdout,再执行真正的命令 auth:whoami,最后从ctx中获取stdout进行断言。这里引入的test 和 expect是封装的 oclif/fancy-test,而它又是基于 Mocha,简言之就是一个能更少写 setup/teardown 的链式调用单测库,expect 使用的是 Chai 语法。\n看到如此简单的示例,我不禁满头问号,其主要依赖的 mock 方式是直接代理 http 请求,这固然是非常符合直觉的,因为每一个命令中确实都需要发送 http 请求,然而发送 http 请求有时可能也是一件山路十八弯的事,简要列举一些问题:\n\n直接 mock http 请求要求给出详细的路径,比如https://exmaple.com/api/User和get、post等方法,然后模拟返回值,非常死板\n\n对于封装了多层的 api 调用(例如 api 调用可能需要经过各种签名,还可能是通过 sdk 调用,最终暴露出来的已经是不知道转发了多少层的小小的接口),如果直接这样写单测的话,会很难找到具体需要调用哪个接口。且各个请求的路径可能完全一样,操作通过 Body / Header 中的某些字段区分,这就导致很难精准 mock\n\n无法做到连续 mock 多个 http 调用,这意味着下面的情况😢:\ntest .nock("https://example.com", api => api .post('/api/User') //... )// 这里不能再接`.nock`了\n\n当然,它也并非一无是处。使用.stdout()来 mock 输出还是非常方便的。另外仔细地翻看各个仓库文档,可以发现它可以通过插桩代理支持 mock 用户输入(stdin)的,例如使用 cli-ux获取输入时,可以这么写:\n test .stub(cli, "prompt", () => async () => mockUserName).stub(cli, "prompt", () => async () => mockPassword) .stdout() .command(["auth:login"]) .it("should login successfully", (ctx) => { expect(ctx.stdout).toContain(`login successfully`); });\n\n简单来说,它缺失了最重要的能力:函数或模块级别的 mock ,而非单纯代理 http 请求。\njestJest 是一个非常流行的测试框架,并且它提供了优秀的函数&模块级别的 mock 能力,这恰好就是我们所需要的。利用它的代理函数和模块能力,可以像这样来模拟 api 的调用:\n// 这也是那常见的一长串 {__esModule: true, ...originalModule, ...} 的简洁写法jest.mock("../path/to/api", () => ({ ...jest.requireActual("../path/to/api"), functionNeedToMock: jest.fn().mockResolvedValue({ MockKey: 'mockValue' }), anotherFunctionNeedToMock: jest.fn().mockResolvedValue(true),}));\n\n上面的这段代码就可以代理../path/to/api这个模块中的functionNeedToMock和anotherFunctionNeedToMock两个函数,而不修改其它的函数。\n另外一个问题是如果需要代理的是位于Command 类之内的函数,例如在下面这个GetShoppingCartStatus类中:\n// 修改自文档import Command from '@oclif/command'export class GetShoppingCartStatus extends Command { // 需要代理的函数 async function checkLoginStatus() {/* ... */} async run() { try { await checkLoginStatus() /* some other code */ } catch (err) { if (err.statusCode === 401) { this.error('not logged in', {exit: 100}) } throw err } }}\n\n我们需要代理的是checkLoginStatus这个函数,那么我们就需要使用spyOn来“监听”GetShoppingCartStatus这个类的原型,示例:\nconst checkLoginStatus = jest .spyOn(GetShoppingCartStatus.prototype, "checkLoginStatus") .mockImplementation(() => {/* ... */} );\n\nspyOn是一个非常强大的功能,它在 CLI 工具的单测中有一个更为重要的作用,那就是“监听”stdout。可以像这样来获取stdout的输出结果:\nlet stdout;beforeEach(() => { stdout = []; jest .spyOn(process.stdout, "write") .mockImplementation((val) => stdout.push(val));});afterEach(() => jest.restoreAllMocks());\n\n当需要断言的时候,就使用stdout就可以了。\n总结现在,我们已经明白了两种框架的优劣:\n\n@oclif/test直接代理 http 请求的方式有很大的局限性,但它链式的调用方式使得模拟用户输入和监听输出很方便;\njest能够进行函数&模块级别的 mock,但用它捕获 stdout、模拟处理用户输入却显得繁琐。\n\n各取长处,对于这样一个命令:\n// 假设其命令为 run:uploadimport Command from '@oclif/command'import { cli } from 'cli-ux';import { zipDir } from '../utils'export class UploadFilesCommand extends Command { // 需要代理的函数 async function checkLoginStatus() {/* ... */} // 用户输入 static flags = { path: flags.string({ char: 'p', description: '文件路径'}) } async run() { try { await checkLoginStatus() await cli.prompt('请输入文件路径', { required: true }) await zipDir(/* ... */) /* some other code */ } catch (err) { if (err.statusCode === 401) { this.error('not logged in', {exit: 100}) } throw err } }}\n\n我们可以这样利用这两个框架:\n// uploadFiles.test.jsimport cli from "cli-ux";import { test } from "@oclif/test";// 代理 zipDir 函数jest.mock("../path/to/utils", () => { return { zipDir: jest.fn().mockResolvedValue(true), };});// 代理 checkLoginStatus 函数const checkLoginStatus = jest .spyOn(UploadFilesCommand.prototype, "checkLoginStatus") .mockImplementation(() => {/* ... */});// 测试describe('run:upload', () => { test .stub(cli,'prompt', () => async () => 'a/b/c') // 对 cli 插桩,由于是链式调用,如果有多个用户输入,可以插桩多次 .stdout() .command(['run:upload']) .it('should upload files successfully', (ctx) => { expect(ctx.stdout).toEqual(/* or other assets */); });});\n\n在这段代码中,我们既利用了 jest 模拟函数和模块的功能,也使用了 @oclif/test 简洁地处理输入输出,融合了两种框架的优点,我们就可以顺畅地进行单测编写了。\n补充在探索的过程中,我发现关于给 Node 开发的 CLI 添加单测的资料少得可怜,这不能不说是一种遗憾。此外,我还走了很大一圈弯路,包括因为项目本身 ts 版本导致的 ts-jest 无法使用,使用各种奇怪的 stdin方法,用奇怪的方式试图直接调用 API 等等。由于缺乏经验,在编写单测的过程中,我发现很难把握 mock 的粒度,例如一个内部用到5个函数的命令,我甚至需要 mock 掉4个,导致大部分代码都没有被执行到,这或许意味着命令级别的单测要建立在 api 正确的基础之上。当然它反映的问题更在于,我不知道单测应该测什么,或许在不知不觉间,我已经在测试实现细节了。此外,CLI 几乎是不可避免地会用到一些 fs 方法,在 mock 某些方法的时候,我发现整个工具直接 crash 了,导致只能 mock 一个很大粒度的函数。这可能是在编码的时候就需要注意的问题。最后,或许更应当关注的是哪些函数被调用了,而直接断言CLIstdout可能根本就不是一个好的选择,因为各种成功/失败/出错根本就不是有规则的、不能变动的输出啊😲!\nReferenceDavid Díaz | Testing OCLIF apps with Jest (martianwabbit.com)\nUnit testing node CLI apps with Jest | by Jon Short | Medium\n","categories":["Projects"],"tags":["技术随笔","CLI","unit test"]},{"title":"2021 回顾展望","url":"/Prose/my2021/","content":"\n欲速则不达\n\n\n\n2021是疫情的第二年,是在大学的第2和2.5年,今年也刚好20岁了,算是人生里一个重要的节点吧。很少写年终总结,但是回顾一下今年做了什么也不坏,留给以后的自己看也挺好。\n流水账首先是1月。1月的第一天,或者准确来说是上一年的12月31号的晚上,发生了一些不愉快的事,导致去年很投入的兴趣受到了比较大的冲击,那段时间有点浑浑噩噩,花了一段时间才恢复到现实生活中来。接着是军训,军训有印象的事不是很多,因为训了两天膝盖就受不了跟训了,看了一本人类学的书忘了叫什么,还看了大半本《规训与惩罚》,全景敞视监狱让人印象很深刻。\n2月放寒假,应该是大半个月吧,总之很短暂,和家人一起的时光总是很短暂,然后回学校开始无缝学习。这段时间学了什么课已经忘了,有印象的是学了JavaScript OOP,不过当时很多概念都没搞清楚,一个是对OOP本身不了解,继承封装抽象多态一个都说不出来;二是对JS不了解,之前是大概半年前学过慕课网的初级课程,水平大概是能写出for循环吧。总之是敲了点代码,做了笔记,现在看内容主要是原型、类、各种ES5的继承方法,整体思路还是挺明确的。\n接着是3月,3月印象里还不怎么忙,主要是计算机组成原理吧,课下在学的是Python。具体因为什么想学的已经忘了,总之是看文档、看视频、敲代码这样学,印象里学了一点Django,学了怎么用决策树分类并且画出来,总之是了解性质的学。出于兴趣写了点爬虫爬微博B站,有些没爬多久IP就挂了。娱乐上当时全收集了寒假没收集完的Ori 2,小小地激动了一把。\n4-6月,这段时间戒掉了去年投入很多然而并无益处的爱好,更多的时间用来打游戏学习。一是计组的实验还得写Verilog,对波形调试更累人点;二是毛概由于未曾预料到的恶性事件变成了闭卷无范围考试课,得花时间背书;三是各门课的考试还是得复习;四是选了一门硬核建筑专业必修课得花很多时间画图;五是愈发感觉到了自己水平的不足,需要多多补习。这段时间课下断断续续学了JS的DOM、BOM、AJAX,复习了HTML、CSS,写了一点静态页面,不过也没明白自己到底为什么要学,只能说是一种惯性的推动,或者说是朴素的兴趣的推动。\n娱乐上这段时间推了《弹丸论破》系列,包括1、2和V3,不知不觉就掉进深坑里了。沉迷虚拟世界算是一个心知肚明的逃避现实的手段,将现实生活中少见的情感集中地组合与浓缩,在提供正向情绪价值的同时,其负向情绪也能削弱一些真实存在的负面情感对心理的压迫。如果把情绪的感知总量看作是一个定值,那么当投入到虚拟世界的情感增多,对现实世界的负面情绪的感知也就会变弱,相应地带来的影响也就会减弱。不过投入情感也是需要时间的,这个时间究竟”值得“与否还得另作考虑。\n然后是7月,一个痛苦的月份,课程上的痛苦在于要在一个月内又要写汇编,又要写一个单周期CPU,还要写一个流水线CPU。从写汇编的时候就已经开始头疼了,单周期CPU的设计和编写还算顺利,流水线的只能说是痛苦。本来设计就是一个很大的挑战,数据冒险和控制冒险都是必须得解决的难题,对前递和停顿的设计都走了很多弯路。好不容易写出来能跑过测试了,结果上板又是功率上不去,而且时间非常非常非常之赶,因此整个7月基本上都处于焦虑和烦闷中。一个月大概玩了十几个小时的《世界末日俱乐部》,剧情还能接收,可惜ACT手感烂,加剧了焦虑和烦闷。到月底大概26号连夜画图赶了整整28页的报告,结果得知了家里两位长辈一前一后去世的噩耗。回老家办理丧事。\n8月,短暂的假期。真正的假期。完美的假期。开心的假期。在虚拟机上搭了本地的搜索引擎,没有学其它的,因为每天基本都在沉迷《异度之刃2》,推完了一周目,开了一堆稀有异刃,还玩了《弹丸论破外传:绝对绝望少女》。最重要的是陪家人,和家人聊日常聊理想聊人生聊各种事情,然后思考后半年应该要做点什么。\n8月底开学,建了一个私有仓库开始补习数据结构。大一时候数据结构留下的心理阴影太深了,特别是已经取消了的限时提交,当时用C语言写BFS最后一个测试用例总是爆栈,实在是很痛苦。总之从最简单的开始学起,都用JS写,顺带熟悉一些常用的API。9月中旬,当时看到了这么一篇文章:如何在5天内学会Vue?聊聊我的学习方法! - SegmentFault 思否,超级心动!于是心动不如行动,先花3天看Vue 2的文档,然后花2天看vue-admin-template是怎么组织结构、怎么写组件的,还看了开发者的”手把手系列“教程,然后就开始想写自己的项目。最开始目标很简单,”学习Vue的使用“,”学习ElementUI的使用“,因为在学数据库所以要”学习MySQL“的使用,于是直接就开始写了,先确定大致的功能,然后开写页面,遇到需要调接口的地方就写一个接口(数据库甚至是做到一半才重新设计、迁移的)。总之是怎么流畅怎么来,只能说是too young too simple, sometimes naive. 不过不管怎样最后东西是做出来了,而且带来了比硬件开发强很多的成就感,让人非常急迫地想要学到更多的知识,写出更优雅的代码,做出真正有用的东西。说实话作为一条疫情期间都在躺尸的咸鱼,已经很久没有体会到这种强烈的求知欲了,于是趁热打铁,想赶紧学点别的。\n但是由于之前每天只写三四个小时,这时候已经大概10月底了,操作系统的实验难度上去了,数据库整了个引以为豪的”封装得好也得至少一千行“的实验,模式识别得考试了,总之先忙着整理学业。十一月开学React,先写了井字棋,然后想想要做什么,这时候每天的计划还是在用表格整理,想起之前看到的这个视频:一位拖延症患者的自我救赎:把自己逼成一个自律的人!告别那些没有灵魂的任务计划丨目标管理丨任务管理丨时间管理哔哩哔哩bilibili,打算给自己写一个APP用。于是很自然地就想用React Native写。说干就干,加上一开始学的是Class的写法,也想用用Hook(面向未来听起来也太高大上了),就确定了用Hook来写。\n先花两个下午学了react navigation,照着文档把几个主要的导航都敲了一遍,打算先写出各个页面的路由来。这里其实遇到了第一个问题,nest navigation没想清楚什么在里层什么在外层。然后封装async storage写页面和CRUD,基本的功能差不多就完成了。\n接下来就开始折腾了,自己用肯定得符合自己的需求,作为一个夜猫子做的APP肯定得有夜间模式呀,然后就打算给APP加个夜间模式。说实话在写之前完全没有想到这会是整个APP写得最痛苦的一个部分,状态管理什么的完全不了解啊!而且就为了这一个功能去用Redux,也太大材小用了吧?最后采用的是useContext的扩展版本。\n这个小项目断断续续写,到自己手机上能用的时候已经12月了,一边准备考试,一边写HLS实验,一边补习各种知识,包括以前一直没搞懂的作用域链、原型链,都花时间去看。月底投了实习,不过要么是简历挂,要么没回音,总之技术水平还是不行呀。\n9-12月太忙了,基本上没玩游戏,短暂地玩了几小时switch《弹丸论破 Decadence》的弹丸S;云了《大逆转裁判编年史》,个人觉得比《逆转裁判123》更合胃口;看了一些英剧和日剧,印象最深刻的是《半泽直树2》就差要说”为人民服务“了;看了一些技术类书籍,经典的红宝书之类;看了一些展览;看了一些严肃文学;看了一些电影;看了……看了什么也忘了,忘了就算了。\n分主题学业学业没有长进,但是经过数字逻辑、计组和计设的捶打,深刻地认识到自己不适合搞硬件开发:不仅兴趣缺缺,而且很难获得成就感。硬件描述语言和高级语言只是有着相似的外壳,写起来的逻辑和感受大相径庭,而且调试成本非常高,一套仿真上板下来半个小时都算快的,更有数不清的玄学错误,写完只能让人感受到狂奔逃过了一群豺狼的夺命追赶而侥幸不死的疲惫,而很难产生设计开发实现的成就感。这学期的HLS开发更甚一筹,无论是加速器设计还是深度学习都不是一日两日都能掌握的,居然会提供错误的IP核、根据错误的要求达成不可能完成的测试任务,实在是消磨兴趣。不过Verilog确实能更好地帮助理解一些计算机原理,写HLS也算增广见识,Vivado我们来(hou)日(hui)再(wu)见(qi)。\n技术总体来看今年还是有学到一些东西。除了Python,HTML / CSS / JS都算是系统地学过一遍,Vue 2停留在会用的程度,React (Native)初步了解,Node 勉强算是会用,SQL 写了好些,对整体的前后端究竟怎么交互的也算有了个轮廓上的认知,现在感到特别奇缺的是计网方面的知识。一方面下学期有开计网课可以跟着听,另一方面自己还得先掌握些常规知识,图解HTTP是看了忘忘了看,还是得花功夫记忆和练习。\n在实践的过程中得把理论学习结合起来,OS学进程线程、同步异步的时候刚好在看红宝书异步编程部分,又刚好项目里天天在Promise、async / await,基本上是看到了什么立刻就能有实际的体会,很有效率。今年看的OSTEP (Operating Systems: Three Easy Pieces)也是一本不可多得的好书,思路清晰又妙趣横生,一口气能看好久,可以列入今年看的技术书籍TOP3。最近看到的最喜欢的博客是这个Inside look at modern web browser (part 1) | Web | Google Developers系列,一共四个部分,每一部分都内容充实,配有生动简洁的插图,以提纲挈领又不失细节的视角介绍和审视了浏览器,引人入胜!看这种技术文章比看所谓的“八股文”有意义多了,知识的深广也不是一个维度,十分具有启发性。\n今年感到遗憾的是没有学编译原理,也希望了解一些AST树、词法分析方面的知识,希望以后有机会补上。\n娱乐今年削减了一些爱好的投入时间,就有更多的时间投入到别的爱好中去。占比最大的应该是游戏,主要是单机剧情向和平台跳跃类游戏,前者让人可以沉浸在另一个世界中,后者可以随玩随停,像是在做永远没有DDL的任务。其次是看剧,也是接收情节输入的爱好,用虚构世界的人生百态来填充单调生活的色彩,不仅已经是一个习惯,而且成了一种必需。接着是阅读,这两年严肃文学看得少,一是因为纸质书带着很不方便,二是因为很难阻止自己往深处想去体会背后的沉重情感,控制情感的投入就可以控制时间的投入,严肃文学在共情吸引上太像一块超强磁体了。取而代之的是看一些技术相关的,看书、看文档学习比跟着视频学习效率高了不知几倍,一些文档的写作技巧也很值得学习。\n不过接收这样需要大量剧情才能连结成一个完整故事的习惯也带来了一定的副作用,就是很难对一些短小的情节片段产生情感波动,能接收到刺激的阈值提高,从而需要更多的时间去领略完整的故事。表现就是碎片化的时间更不知道该如何利用了,更容易无聊,期待有一定重量的故事。\n生活今年新认识的朋友中有两位令我印象深刻。一位是年初认识的,对爱好非常有热情,热爱阅读,对生活有着很平和和“船到桥头自然直”的态度,这种心态很值得我学习。一位是年中认识的,无论是学业、科研、聪明机智还是幽默程度都令我佩服,有着很坚定的目标和清晰的人生规划,并为之付出坚持不懈的努力。尤其是这位同学提到自己以前每周休息一天,但是现在一天都不休息了因为觉得没必要休息,让我深感其自制力之强。提到自制力,某舍友的自制力真是令我五体投地,能取得各种成就都是需要付出坚持努力的。以往认识的朋友也是时间越久越觉可贵,能与社会建立一点关联我感到无比幸运(如果能和我把双人成行打完就更好了)\n展望2022是要找工作、决定去向的一年,虽然说第一份工作怎样很重要,但我更希望我能在新的一年里多尝试一些领域内的新事物,探索一些领域外的新方向,结合自己的喜好和考量去决定自己想要做的方向。虽然说很大程度上要做什么都是“命运的安排”,但是个人的选择毕竟也会起到不可忽视、乃至是决定性的作用。印象很深刻的是我在高中的时候一直希望以后去读化学相关专业,但在第二年化学竞赛某次培训的一个晚上,和同学讨论问题时突然发现一个靠我们当时的知识水平没法完美解释的迷茫点,具体是在讨论什么已经不记得了,但是那种一瞬间整个知识体系开始出现裂痕的场景令人很难忘记,在之后这个裂痕不断地加深,最终彻底将心中想学化学的想法抹消。现在我正处在初入一个领域的阶段,无论是新学了个API或者新掌握一种工具的用法都会让我激动,但这毕竟只是最浅表的层面,在阳光下波光粼粼的海面下方也有着骇人的巨兽,不单单是有无面对的勇气的问题,更在于是否光想到这头可能存在的巨兽就会让人感到痛苦?如果是这样的话,趁年轻我希望我能转换方向,及时停止对自己的热爱的消耗。\n第二点是希望自己能多一些逻辑的思考,少一些直觉的判断。靠直觉来判断固然是简单轻松、又看似有着经验支撑的思维方式,但编程不比语言,不是靠“某种感觉”就能让程序跑起来的。动脑是一件需要耗费体力和精力的事,但人既然长了脑子,每天有20%的能量供它使用,如果它不能动的话究竟用来干嘛呢?出于直觉的判断确实是最省力的,但直觉只是经经验加工后潜意识产生的倾向,与客观事实可能并不保持一致。长期依赖直觉所养成的思维习惯可以被认为是一种惰性吗?这种惰性可能会导致令人追悔莫及的后果。而思考的能力却能越练越明,因为“归纳法”终究只是总结,而“演绎法”需要创造性地推导。\n第三,希望能初步建立对自我和外界的自洽的感知,包括如何认识自我、如何认识外界,如何向外界推介自我和自我说服一些事物的运行逻辑。这个外界指的是广泛的社会存在,小到与朋友之间交往,大到对一个社会事件的发展逻辑的看法。这样做的目的是为了找到自己的安放之处,虽然说在巨大的社会机器上可能每个人都是颗小螺丝钉,但是钉在排水管上和钉在导弹舱上肯定不可相提并论,认识和推介是建立关联的第一步,具体的方法论多种多样,但是始终围绕着一个目标进行探索和实验,才能集中地总结经验和教训。事实上我之前一直没有意识到“有意识地建立关联”的重要性,这会带来几个问题,一是自我认知定位模糊,二是对他人和环境的认知模糊,三是由于各种模糊带来的盲人摸象式的生活方式,四是漫无目的的生活方式最终导致的一事无成。人是一切社会关系的总和,关系并不是天然就清楚明白的,有些时候我会误把“工具”当成我需要着重处理的客体,但事实上工具只是达成目的的手段,真正重要的是从中“我”——主体,和“我所需要的服务的对象/满足的要求”——客体。(就是在吐槽自己居然会把学elementUI怎么用的当成一个目标,蠢到家了)\n写得有点久,2022就要来了。就这样吧,纲领清晰了,详细的checklist也没啥写的必要,平静地迎接新年的太阳吧~\n","categories":["Prose"],"tags":["生活随笔"]}]