效率提升-书签管理系统

431 阅读8分钟

为什么要自己做书签管理

  本人日常涉及的工作大多是 B/S 相关的产品,为了方便测试系统,我本地安装了很多款浏览器,包括 Chrome,Edge,FireFox,360 技术,360 安全,QQ 浏览器等,虽然很多双核浏览器都是基于 Chromium 引擎的,大方向上没什么太大的问题。但是也经常会碰到很多诡异的问题: 比如对元素进行了某种特定排序,在Chrome下和双核浏览器下顺序是倒装的;比如不同浏览器下对颜色的渲染是有差异的等等。此为背景。

  浏览器一多,带来的问题就是书签这块怎么管理,每个浏览器都有自己的管理体系,同一个浏览器可以上传到云端然后同步到不同的电脑。但是同一台电脑下,不同的浏览器之间书签怎么同步,如果通过导入导出html的方式那就成本太大了。这也跟我个人习惯有关系,我大部分场景都是随机选择浏览器打开,然后看网页什么的,然后碰到好的网页,就收藏下,这样同步起来就更难受了。

也一直尝试用同一款浏览器去查看网页,但是一直改不过来,有时候用一款浏览器调试代码的时候,自然而然会选择用这个浏览器去查资料。

怎么样的书签符合我要求

  基于我自己的工作经验总结,对我来说想要达到的书签效果:

  1. 书签保存在独立于浏览器以外的云端(互联网可达),不受浏览器影响
  2. 看到网页,随时用右键就能保存到书签系统
  3. 浏览器有快捷方式可以直接打开书签(云端有一个书签列表,随时可以打开),不考虑断网的情况,现在可以随时手机发布热点上网。

大概是这么一个架构:

书签系统.png

制作书签系统过程

书签管理页面

页面展示

  书签管理系统包含书签的增删改查,检索等,数据库采用的是mysql。

功能不复杂,包括分组管理,书签管理,拖拽排序,网址检索等,基本上常规用到的功能。

添加目录

添加书签

拖拽排序

  • 书签拖拽排序

  • 目录拖拽排序

网址检索

代码实现

  技术栈: 前端(Vue2) + 样式(Ant Design Vue) + 后台(SpringBoot)

简易认证

  认证没有太复杂的逻辑,本身数据也不是太敏感的,只是防君子不防小人。

//前端路由守卫
router.beforeEach((to, from, next) => {
  if (to.path === "/login") {
    next();
  } else {
    //路由守卫,判断没有token就跳转到登录页面
    //.....
    if (localStorage.username) {
      next();
    } else {
      next({ path: "/login" });
    }
  }
});
/**
后台token生成
*/
public static String CreateToken(Map<String,String> payload){

    String json = JSONObject.toJSONString(payload);
    String compactJws = Jwts.builder()
            .setPayload(json).signWith(SignatureAlgorithm.HS512, key).compact();

    System.out.println("jwt key:" + new String(key.getEncoded()));
    System.out.println("jwt payload:" + payload);
    System.out.println("jwt encoded:" + compactJws);
    return compactJws;
}

/**
 后台token校验
*/
public static String ValidateToken(String token){
    Jws<Claims> claimsJws = Jwts.parser().setSigningKey(key).parseClaimsJws(token);
    JwsHeader header = claimsJws.getHeader();
    Claims body = claimsJws.getBody();
    String   strExpTime = body.get("expire_time", String.class);
    if(Long.valueOf(strExpTime)<System.currentTimeMillis()){
        return "当前token已过期,请重新登录!";
    }
    return null;
}

统一返回格式处理
@ApiModel(value="返回结果格式化类(ResponseBodyVo)")
public class ResponseBodyVo<T extends Object> {

    @ApiModelProperty("status:状态,一般200是成功,其他都是失败")
    private Integer status;
    @ApiModelProperty("msg:一般返回请求错误信息")
    private String msg;

    @JsonIgnore
    private String realMsg;
    @ApiModelProperty("data:返回正确结果,可以是任何类型")
    private T data;

    @ApiModelProperty("code:返回代码,0是成功,1是失败")
    public String getCode() {
        return status == 200 ? "0" : "1";
    }

    public Integer getStatus() {
        return status;
    }

    public String getMsg() {
        return msg;
    }

    public String getMessage(){
        return msg;
    }

    public T getData() {
        return data;
    }

    public String getRealMsg() {
        return realMsg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }

    @ApiModelProperty("success:true表示成功,false表示失败")
    public boolean isSuccess() {
        return 200 == status;
    }

    public void setStatus(Integer status) {
        this.status = status;
    }

    public void setRealMsg(String realMsg) {
        this.realMsg = realMsg;
    }

    public void setData(T data) {
        this.data = data;
    }

    /**
     * 序列化使用
     */
    public ResponseBodyVo() {

    }

    public ResponseBodyVo(T data) {
        this.data = data;
        this.status = ResponseStatus.OK.getStatus();
        this.msg = "OK";
        this.realMsg = "OK";
    }

    public ResponseBodyVo(T data, String msg) {
        this.data = data;
        this.status = ResponseStatus.OK.getStatus();
        this.msg = msg;
    }

    public ResponseBodyVo(Integer statusE, String msg, T data) {
        this.data = data;
        this.status = statusE;
        this.msg = msg;
    }

    public ResponseBodyVo(ResponseStatus statusE, String msg) {
        this.status = statusE.getStatus();
        this.msg = msg;
        this.realMsg = msg;
    }

    public ResponseBodyVo(ResponseStatus statusE) {
        this(statusE, statusE.getMsg());
    }

}

/**
* 根据条件检索书签信息
* @param bookMarkRequestEntity
* @return
*/
@ApiOperation(value = "通过指定条件查询书签",notes = "通过指定条件查询书签")
@PostMapping(value="findMarkByCondition")
public ResponseBodyVo<List<BookMarkEntity>> findMarkByCondition(@RequestBody @ApiParam("书签请求实体")  BookMarkRequestEntity bookMarkRequestEntity){
    return new ResponseBodyVo<>(bookMarkService.findMarksByCondition(bookMarkRequestEntity));
}
拖拽实现

目录树

采用的是 ant design vue 中 tree 组件的 draggable 功能

<a-tree
  class="draggable-tree"
  @expand="onExpand"
  :expandedKeys="expandedKeys"
  draggable
  showIcon
  @rightClick="rightHandle"
  @dragenter="onDragEnter"
  @dragstart="dragstart"
  :autoExpandParent="true"
  @drop="onDrop"
  @select="onSelect"
  :selectedKeys="selectedKeys"
  :treeData="gData"
  style="margin-top:80px"
>
  <a-icon type="diff" slot="parentFolder" />
  <a-icon type="diffChild" slot="childFolder" />
  <a-icon type="down" slot="switcherIcon" />
  <a-icon slot="smile" type="smile-o" />
  <a-icon slot="meh" type="smile-o" />
  <template slot="custom" slot-scope="{selected}">
    <a-icon :type="selected ? 'frown':'frown-o'" />
  </template>
  <template slot="title" slot-scope="{title}">
    <!--建议处理,文件夹检索时,关键字红色标识-->
    <span v-if="title.toLowerCase().indexOf(searchValue.toLowerCase()) > -1">
      {{title.substr(0,
      (title.toLowerCase().indexOf(searchValue.toLowerCase())))}}
      <span style="color: #f50">{{searchValue}}</span>
      {{title.substr(title.toLowerCase().indexOf(searchValue.toLowerCase()) +
      searchValue.length)}}
    </span>
    <span v-else>{{title}}</span>
  </template>
</a-tree>
//拖拽逻辑实现
onDrop(info) {
    const dropKey = info.node.eventKey;
    const dragKey = info.dragNode.eventKey;
    const dropPos = info.node.pos.split("-");
    const dropPosition = info.dropPosition - Number(dropPos[dropPos.length - 1]);
    const loop = (data, key, callback) => {
        data.forEach((item, index, arr) => {
            if (item.key === key) {
                return callback(item, index, arr);
            }
            if (item.children) {
                return loop(item.children, key, callback);
            }
        });
    };
    const data = [...this.gData];
    // 查找被拖拽元素
    let dragObj;
    loop(data, dragKey, (item, index, arr) => {
        arr.splice(index, 1);
            dragObj = item;
    });
    if (!info.dropToGap) {
        loop(data, dropKey, item => {
            item.children = item.children || [];
            // where to insert 示例添加到尾部,可以是随意位置
            item.children.push(dragObj);
        });
    } else if (
        (info.node.children || []).length > 0 && // 是否包含子节点
        info.node.expanded && // 是否展开
        dropPosition === 1 // On the bottom gap
    ) {
        loop(data, dropKey, item => {
            item.children = item.children || [];
            item.children.unshift(dragObj);
        });
    } else {
        let ar;
        let i;
        loop(data, dropKey, (item, index, arr) => {
            ar = arr;
            i = index;
        });
        if (dropPosition === -1) {
            ar.splice(i, 0, dragObj);
        } else {
            ar.splice(i + 1, 0, dragObj);
        }
    }
    //按照拖拽完成以后的树节点,更新节点的order字段信息,便于下一次打开的时候可以直接排序
    this.setOrder(data);
    this.gData = data;
    //更新数据到后台保存
    updateFolderOrder(data).then(res => {
        if (!res.success) {
            this.$message.error(res.msg);
        }
    });
},
setOrder(data) {
    data.forEach((value, index) => {
        value["order"] = index + 1;
        if (value["children"] != undefined && value["children"].length > 0) {
            this.setOrder(value["children"]);
        }
    });
}

书签列表

使用了vuedraggable组件

"vuedraggable": "^2.23.2",

<draggable
  v-model="bookMarkList"
  @update="datadragEnd"
  :options="{draggable:'.item'}"
>
  <transition-group>
    <div
      :ref="bookMark.id"
      v-for=" (bookMark,index) in bookMarkList"
      :key="bookMark.id"
      @click="showBackground(bookMark.id)"
      @dblclick="showHtml(bookMark.url)"
    >
      <!--被拖拽元素-->
    </div>
  </transition-group>
</draggable>
import draggable from "vuedraggable";

//列表拖拽会比数结构拖拽简单很多,vuedraggable自动完成了拖拽过程
datadragEnd(evt) {
    evt.preventDefault();
    //更新列表的order属性,便于下次直接排序
    this.bookMarkList.forEach((value, index) => {
        value["order"] = index + 1;
    });
    //数据更新到后台保存
    updateOrder(this.bookMarkList).then(res => {
        if (!res.success) {
            this.$message.error(res.msg);
        }
    });
},
网址检索

  因为数据量比较小,所以这里也没什么复杂技术,就是通过模糊匹配网址名称和网址地址。

书签导入

  我原来浏览器里有很多的网址收藏了,初始的时候也不可能一个个去录入,所以做了一个简单的网址导入工具,原来浏览器的书签导出成 html 文件,然后通过接口把 html 文件内容导入到书签管理系统。

当时为了学习技术,没有用纯前端或者 JAVA 去解析,而是采用了 NodeJS 去解析

浏览器导出的 HTML 文件大致是这个样子的,有很明确的层级和参数

//加载html文件
var html = require("./analysishtml");
const fs = require("fs");
fs.readFile("Bookmark.html", "utf-8", function(err, data) {
  if (err) {
    console.log("error");
  } else {
    var json = html.analysis(data);
    console.log(JSON.stringify(json));
  }
});

解析 html 用到了 cheerio

"cheerio": "^1.0.0-rc.3",

//analysishtml.js
var cheerio = require("cheerio");
function parse(html) {
  // 加载html,使用常用的$符号
  var $ = cheerio.load(html);
  // 获取最外层的dt标签
  var $dl = $("dl").first();
  //var $dt = $dl.children("dt").eq(0);
  var $dt = $dl.children("dt");
  var array = [];
  for (var i = 0; i < $dt.length; i++) {
    var obj = foo($dt.eq(i));
    array.push(obj);
  }
  // 将对象转化为json字符串,添加额外参数使json格式更易阅读
  var s = JSON.stringify(array, null, 4);
  // 将json字符串写入json文件
  fs.writeFileSync("output.json", s);
  function foo($dt) {
    // h3标签为文件夹名称
    var $h3 = $dt.children("h3");
    if ($h3.length == 0) {
      // a标签为网址
      var $a = $dt.children("a");
      // 返回该书签的名称和网址组成的对象
      return $a.length > 0 ? { name: $a.text(), href: $a.attr("href") } : null;
    }
    var h3 = $h3.text();
    var arr = [];
    var obj = {};
    // 获取下一级dt标签集合
    var $dl = $dt.children("dl");
    var $dtArr = $dl.children("dt");
    for (var i = 0; i < $dtArr.length; i++) {
      // 遍历下一级dt标签
      var tmp = foo($dtArr.eq(i));
      // 将返回的对象push至子文件数组
      arr.push(tmp);
    }
    // 创建文件夹与子文件数组的键值对
    obj[h3] = arr;
    // 返回该对象
    return obj;
  }
}
exports.analysis = parse;

最终返回结果

[
    {
        "技术类": [
            {
                "新技术研究": [
                    {
                        "性能测试": [
                            {
                                "name": "LoadRunner监控mysql利器-SiteScope - 简书",
                                "href": "https://www.jianshu.com/p/fce30e333578"
                            },
                            {
                                "name": "转载:Loadrunner 接口测试的两种方法 - wq19860122的个人空间 - 51Testing软件测试网 51Testing软件测试网-中国软件测试人的精神家园",
                                "href": "http://www.51testing.com/html/18/631118-853737.html"
                            },
                            {
                                "name": "web_custom_request函数详解 - Defias - 博客园",
                                "href": "https://www.cnblogs.com/yezhaohui/p/3280239.html"
                            },
                            {
                                "name": "LoadRunner 函数之 web_custom_request - 鲨鱼逛大街 - 博客园",
                                "href": "https://www.cnblogs.com/guanfuchang/p/6208563.html"
                            },
                            //......

                        ]
                    }
                ]
            }
        ]
    }
]

解析JSON导入到数据库

//分析json
public void AnalysisJSON(JSONArray jsonArray, String parentId){

    //............
    for(int i=0;i<jsonArray.length();i++){
        Object t = jsonArray.get(i);
        if(t instanceof JSONObject){
            JSONObject jsonObject = (JSONObject) t;
            for(String key : jsonObject.keySet()){
                String name = key;
                String id = UUID.randomUUID().toString();
                JSONArray arrayChild  = (JSONArray)jsonObject.get(name);
                //写入数据库
                AnalysisJSON(arrayChild,id);
            }
        }
        else if(t instanceof  JSONArray){
              //............
        }
    }
}

Chrome插件

页面展示

  前面也提到我想碰到要收藏的网页的时候,可以快速右键保存到书签系统,并且有类似浏览器插件的按钮,可以直接点开我的书签列表(我前面申请了域名,给书签列表也分配了二级域名,手动输入也是比较方便,但是还是没有按钮来的方便)。

因为用到的浏览器其实都是基于Chromium的,所以基于Chrome开发了一个插件,FireFox没做。

这个就是浏览器插件

右键添加到收藏夹

自动带入网页名称和地址,然后可以保存

最开始想直接就自动保存,但是发现经常要修改下页面名称(更直观点),就做成直接跳转到添加界面,赋上默认值,如果要修改,也可以直接修改,然后手动保存下,不想保存可就叉掉页面就行。

代码实现

大致的代码结构

manifest.json

   这个文件主要来定义插件的相关信息

{
	"manifest_version": 2,
	"name": "书签管理程序",
	"version": "1.2.0",
	"short_name": "BookMark",
	"description": "书签管理程序",
	"icons":
	{ 
        //插件的图标信息
		"16": "img/logo.png",
		"48": "img/logo.png",
		"128": "img/logo.png"
	},
    //插件的运行环境,后续的右键事件等都是在这里注册的
	"background":
	{
        //主页面
		"page": "background.html"
	},
	"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
    // 插件点击操作,打开popup.html
	"browser_action": 
	{
		"default_icon": "img/logo.png",
		"default_title": "在线书签管理程序",
		"default_popup": "popup.html"
	},
	"content_scripts": 
	[
		{
			"matches": ["<all_urls>"],
			"js": ["js/jquery-1.8.3.js", "js/content-script.js"],
			"css": [],
			"run_at": "document_start"
		},
		{
			"matches": ["*://*/*.png", "*://*/*.jpg", "*://*/*.gif", "*://*/*.bmp"],
			"js": ["js/show-image-content-size.js"]
		}
	],
	"permissions":
	[
		"contextMenus",
		"tabs",
		"notifications",
		"webRequest",
		"webRequestBlocking",
		"storage",
		"http://*/*",
		"https://*/*"
	],
	"web_accessible_resources": ["js/inject.js"],
	"default_locale": "zh_CN"

}
popup.js

因为不需要什么交互,所以popup.html都是空壳,只要实现点击打开书签列表,然后注册右键事件就行了

//popup.js
/**
 * 作用就是判断,点击插件的时候打开->popup.html,页面初始化加载的时候做逻辑处理(因为不需要交互)
 * 首先查询书签列表是否被打开过,根据tab的title去查找(title是固定的),如果已经存在就激活原来的页面,如果不存在,就新打开一个tab页
 */
$(function(){
	chrome.tabs.query(
		{ title: 'bkm',currentWindow: true },
		tabActive => {
			var url = "http://书签列表域名"; //自己想要打开的地址
			if (tabActive.length == 0) {
				window.open(url)
			}
			else {
				chrome.tabs.highlight({ windowId: tabActive[0].windowId, tabs: tabActive[0].index });
			}
		}
	);
})

background.js

主要就是注册右键事件

chrome.contextMenus.create({
	title: "添加到收藏夹", //右键菜单名称
	contexts: ['page'], //表示页面右键就存在。如果添加选中文字右键搜索功能,这里可以设置selection, 表示有选中页面内容了,右键这个菜单才会出现
	onclick: function (info) {
		chrome.tabs.query(
			{ active: true, currentWindow: true }, //获取当前激活的tab页
			tabs => {

                //查找是否已经打开tab页了
				chrome.tabs.query(
					{ title: 'bkm',currentWindow: true },
					tabActive => {
                        //会访问书签列表页面,并带上当前页面的url和title,便于自动保存
						var url = "http://书签列表域名?title=" + encodeURI(tabs[0].title) + "&url=" + encodeURI(tabs[0].url);
						if (tabActive.length == 0) {
							window.open(url)
						}
						else {
							chrome.tabs.highlight({ windowId: tabActive[0].windowId, tabs: tabActive[0].index });
							chrome.tabs.update(tabActive[0].id, {url: url});
						}
					}
				);
			}
		);

	}
});
bookmark.vue
//判断如果是带着title和url过来的,就直接打开添加书签页面,并赋默认值
this.$nextTick(function() {
    if (params["title"] != null && params["title"] != "") {
        this.isHaveTitle = true;
        this.addExtendBookMark();
    }
});

插件打包

插件打包可以直接借助Chrome浏览器进行打包的。

选择扩展程序

打包扩展程序

打包成功以后,会给你一个crx文件的,你直接拖拽到浏览器就行了,需要在chrome://extensions/界面拖拽,其他界面无效会报错

拖拽进去以后,会提示是否添加,选择添加扩展程序就行了,这样,你的浏览器里这个插件就能使用了。

说明一

第一次用chrome打包的时候只要上传代码文件夹下就行,不用上传私钥。第一次打包成功以后,他会给你一个pem的文件,就是私钥信息。

这个私钥的作用就是第二次你修改代码重新上传打包的时候,记得把这个pem文件也一并上传,这样Chrome认为你是同一个插件升级而已。

说明二

这种方式拖拽上去的组件,你可以正常使用,但是Chrome会提示插件不在应用商店。没有强迫症可以不关心。

说明三

我是有强迫症的,所以我后面想了另外的办法来实现。我是把插件发布到应用商店里去了。

chrome应用商店无法直接访问,你懂得,要翻一下。

先注册开发者账户

需要支付5美元(我有国际账户的,所以这个很方便)

上传扩展程序

编辑插件信息

然后就等待发布就行,发布以后你就能在Chrome应用商店直接搜索到,然后使用了。

当时弄这块的时候,正式发布他还有好些要求,我觉得麻烦,就换了一种方式实现,我发布开发者版本仅供测试使用,然后指定我自己的chrome账号为测试者,这样只要浏览器登录我的chrome账号,这个插件就可以正常使用了(本来就是自己用的)。

后来发现其他浏览器这样操作还是比较麻烦,我就把正版的crx插件从浏览器里扣出来,这样拖拽到其他浏览器平台都能正常使用,还不会出现提示。

扣出来的文件

总结

  这个程序陪伴我好几年了,非常好用,省了很多工夫。