为什么要自己做书签管理
本人日常涉及的工作大多是 B/S 相关的产品,为了方便测试系统,我本地安装了很多款浏览器,包括 Chrome,Edge,FireFox,360 技术,360 安全,QQ 浏览器等,虽然很多双核浏览器都是基于 Chromium 引擎的,大方向上没什么太大的问题。但是也经常会碰到很多诡异的问题: 比如对元素进行了某种特定排序,在Chrome下和双核浏览器下顺序是倒装的;比如不同浏览器下对颜色的渲染是有差异的等等。此为背景。
浏览器一多,带来的问题就是书签这块怎么管理,每个浏览器都有自己的管理体系,同一个浏览器可以上传到云端然后同步到不同的电脑。但是同一台电脑下,不同的浏览器之间书签怎么同步,如果通过导入导出html的方式那就成本太大了。这也跟我个人习惯有关系,我大部分场景都是随机选择浏览器打开,然后看网页什么的,然后碰到好的网页,就收藏下,这样同步起来就更难受了。
也一直尝试用同一款浏览器去查看网页,但是一直改不过来,有时候用一款浏览器调试代码的时候,自然而然会选择用这个浏览器去查资料。
怎么样的书签符合我要求
基于我自己的工作经验总结,对我来说想要达到的书签效果:
- 书签保存在独立于浏览器以外的云端(互联网可达),不受浏览器影响
- 看到网页,随时用右键就能保存到书签系统
- 浏览器有快捷方式可以直接打开书签(云端有一个书签列表,随时可以打开),不考虑断网的情况,现在可以随时手机发布热点上网。
大概是这么一个架构:
制作书签系统过程
书签管理页面
页面展示
书签管理系统包含书签的增删改查,检索等,数据库采用的是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插件从浏览器里扣出来,这样拖拽到其他浏览器平台都能正常使用,还不会出现提示。
扣出来的文件
总结
这个程序陪伴我好几年了,非常好用,省了很多工夫。