一个自用的摘录 Web App ,基于 GitHub 进行多端数据云同步。
主要技术:
- 前端: Vue3,Vant4 组件库。
- 后端: Spring Boot3(JDK17),MyBatis 。
- 数据库:SQLite3 。
- 云端同步:使用 Github 存储数据。
-
Windows 端使用导出的 exe
功能不稳定。
-
Android 使用导出的 apk
不能直接导出,需要对代码进行平台适配。
- 后端云服务器部署。
- 前端采用网页访问。
- 前端采用 PWA (Progress Web Applicaton)。
- 后端在 PWA 中使用 JS 操作本地存储,并进行云端同步。
- 同时适用电脑和手机浏览器。
-
card 浏览
- 最近 card 浏览历史(向左滑动)
- 可点击按钮返回最新 card(最新浏览位置)。
- 按随机生成方式浏览卡片(向右滑动)
- 最近 card 浏览历史(向左滑动)
-
浏览全部 card
- 第一和最后一次 card 可循环浏览。
-
添加 card
- 输入字段历史词提示
- 输入字段候选词提示
-
修改 card
- 逻辑修改,保留所有历史数据。
-
删除 card
- 逻辑删除,保留所有历史数据。
-
搜索 card
- 所有关键词
- card 列表展示
- 点击可展开,折叠。
- 展开后可修改,删除。
- 自动高亮关键词。
-
云端同步
- 使用 GitHub 备份数据,免费,无需云服务器。
- 基于版本对比算法,支持多端同步。
- 连接检测,网络出现问题时只支持浏览 card 。
-
空格
- 全部使用半角符号。(中文对齐需要全角空格时除外)
- 采用齐头式,禁止段首空两格(格式有特殊意义时除外)
- 对齐只允许使用空格,禁止 Tab 。
-
换行
-
换行
句间不空行(句末一个换行)
-
换段
段间空一行(段末两个换行)
-
注意
- 使用 Windows 记事本编辑时,两个回车需要使用
\n\r
进行换段替换。 - 使用 Vant4 的输入框输入时,两个回车需要使用
\n\n
进行换段替换。
- 使用 Windows 记事本编辑时,两个回车需要使用
-
-
卡片(card)
-
类型(type)
-
内容(content)
-
文学(literature)(0)
有书的。
- 诗歌(poetry)
- 文章(article)
- 小说(fiction)
- 包括轻小说。
-
歌曲(song)(1)
重点在歌词。
-
言语(words)(2)
-
出处不是诗歌、文章,小说的句子。
- ACG:动画、电视剧、电影。
- 剧目:音乐剧,歌剧,舞台剧,话剧,广播剧,朗读剧等。
-
出处、类型不明晰的句子。
-
由原作改编的各类作品,以改编后的作品类型为准。
各类型的描述:(觉得不重要的信息可以留空)
文学(诗歌、文章、小说):
-
标题(title)
-
译题(translation title)
-
原文(original text)
-
译文(translation text)
-
作者(author)
-
译者(translator)
-
书籍(book)
-
版本(edition)
书籍相关的的出版社、版次等信息。
-
备注(note)
歌曲:
-
标题(title)
-
译题(translation title)
-
原文(original text)
-
译文(translation text)
-
作词(lyric)
-
译者(translator)
-
演唱(voice)
-
作曲(compose)
-
编曲(arrangement)
-
专辑(album)
-
出处(source)
-
备注(note)
言语:
-
原文(original text)
-
译文(translation text)
-
语者(sayer)
说话人,可以是角色。
- 注意,言语的 sayer 可为空。
-
译者(translator)
-
出处(source)
描述来源的文字,范围较广。
- 没有必要再具体指定细分类,有一个显著的出处描述即可。
-
备注(note)
原文和译文整体分开展示:
- 原文和译文往往不是逐句对应的。
- 分开展示以保留原文整体的「形」。
副标题、章节标号等,写在「原文」、「译文」中。
语言约定:(UTF-8)
- 中文(华文楷体:STKaiti )
- 日文(华文楷体:STKaiti )
- 英文(Gadugi)
历史记录分类:(type)
- read(0)
- add(1)
- update(2)
- delete(3)
- readInAll(4)(浏览全部 card 功能)
/* 建表 */
drop table if exists card;
create table card (
id integer primary key autoincrement ,
type integer
not null
check( type in (0, 1, 2)) , -- 0:文学 1:歌曲 2:言语
content_id integer
not null ,
create_time timestamp ,
update_time timestamp ,
deleted integer
);
drop table if exists literature;
create table literature (
id integer primary key autoincrement ,
title text ,
translation_title text ,
original_text text ,
translation_text text,
author text ,
translator text ,
book text ,
edition text ,
note text,
create_time timestamp ,
update_time timestamp ,
deleted integer
);
drop table if exists song;
create table song (
id integer primary key autoincrement ,
title text ,
translation_title text ,
original_text text ,
translation_text text ,
lyric text , -- 作词
voice text ,
compose text ,
arrangement text ,
album text ,
source text ,
note text,
create_time timestamp ,
update_time timestamp ,
deleted integer
);
drop table if exists words;
create table words (
id integer primary key autoincrement ,
original_text text ,
translation_text text ,
sayer text ,
translator text ,
source text ,
note text,
create_time timestamp ,
update_time timestamp ,
deleted integer
);
drop table if exists history;
create table history (
id integer primary key autoincrement ,
cardId integer ,
type integer
not null
check ( type in (0, 1, 2, 3, 4)), -- 0 read, 1 add, 2 update, 3 delete, 4 readInAll
create_time timestamp
);
drop table if exists github_info;
create table github_info (
id integer primary key autoincrement,
username text,
repo_name text,
access_token text
);
insert into github_info values (null, null, null, null);
/* 测试数据 */
insert into card values (null, 0, 1, datetime('now', 'localtime'), datetime('now', 'localtime'), 0);
insert into literature
values (null, null, null, null, '我要唱的歌,直到今天还没有唱出。
每天我总在乐器上调理弦索。
时间还没有到来,歌词也未曾填好,只有愿望的痛苦在我心中。
花蕊还未开放,只有风从旁叹息走过。
我没有看见过他的脸,也没有听见过他的声音;我只听见他轻蹑的足音,从我房前路上走过。
悠长的一天消磨在为他在地上铺设座位;但是灯火还未点上,我不能请他进来。
我生活在和他相会的希望中,但这相会的日子还没有来到。', '泰戈尔', '冰心', '飞鸟集', '译林出版社 出版年: 2010/6', '具体出自 吉檀迦利',
datetime('now', 'localtime'), datetime('now', 'localtime'), 0);
insert into history values (null, 1, 1, datetime('now', 'localtime') );
insert into card values (null, 1, 1, datetime('now', 'localtime'), datetime('now', 'localtime'), 0);
insert into song
values (null, '迷星叫', null, '迷星叫的日文歌词', '迷星叫的中文歌词', null, 'MyGO!!!!!', null, null, '迷迹波', 'BanG Dream', null,
datetime('now', 'localtime'), datetime('now', 'localtime'), 0);
insert into history values (null, 2, 1, datetime('now', 'localtime') );
insert into card values (null, 2, 1, datetime('now', 'localtime'), datetime('now', 'localtime'), 0);
insert into words
values (null, '我看见花,绝非美丽;我知道爱,绝非真意', null, '博丽未灯', null, '繁度论', null,
datetime('now', 'localtime'), datetime('now', 'localtime'), 0);
insert into history values (null, 3, 1, datetime('now', 'localtime') );
/* readInAll */
insert into history values (null, 1, 4, datetime('now', 'localtime') );
insert into history values (null, 2, 4, datetime('now', 'localtime') );
insert into history values (null, 3, 4, datetime('now', 'localtime') );
vant 的全局导入和局部导入时,组件元素的名称有不同。
# 全局导入
# use
<van-swipe> </van-swipe>
# 局部导入
<script setup>
import { Swipe } from 'vant';
</script>
# use
<swipe> </swipe>
-
主动换行
人为输入换行符。
-
自动换行
行长度过长自动换行。
需要保证主动换行比自动换行的间距更大:
- 将每行单独划分为一个元素。
- 使用 margin 增大各行的间距。
- 减小自动换行的行的行高。
基于 Vant 的轮播图实现滑动切换浏览页面:
-
上一个 card
浏览记录。
- 历史记录的查看行为不记入历史记录。
-
当前 card
记入历史记录。
-
下一个 card
下一个 card 号码由后端生成。(暂定随机)
没有在 vue 部分做缓存,而是利用浏览器自身对 url 的缓存。
如果自己实现,需要对移动端做滑动事件适配(Web 端是鼠标事件)。
可以左滑回看任意个历史记录。
如果直接创建大量的轮播图页面,资源消耗会非常大,但如果只一次创建部分,组件对下标的支持存在限制。
滑动浏览时,如果初始 index = 1,向 swipe 左侧添加 item 后:
- index 向左翻页变为 0 后就无法再减小,无法再访问新增的 item 。
- 原 index 位置对应的 item 不变。
组件本身的特征:
- 向左翻页 index 变小,最小为 0 。
- 向右翻页 index 变大。
在不修改组件源码的前提下:
- 无法直接修改 swipe 的 initial_index 。
- 无法改变组件内部的当前页 index 。
利用 swipe 的循环轮播功能。
在 loop 中,index 会循环变化。
- 保持 swipe 的数据数组长度一直为 3,作为一个局部 swipe 状态,index 会 0,1,2 循环变换。
- 根据 index 变换,就可以区分向左还是向右。
- 向左翻页,回看历史。
- 向右翻页,随机新的 card 。
- 这样就解决了 index 变化到左端点(0)就无法再变化的情况。
- 进一步的,跟随 index 的变化,调整应当元素在 localSwipeCardIds 中的位置。
以上,根据当前卡片内容,不断更新 swipe 的数据数组的 localSwipeCardIds ,即可实现局部 swipe 状态的移动,即可利用轮播图的滑动效果达到无限滑动。
另外,实际浏览历史记录不会回看太多,实际只提供之前 30 页的回看,以减少无用数据量。
边界情况:
- 左端点时,阻止向左翻页的右滑事件,使得无法再向左翻页。实现参考
- 右端点时,新增 card 。
注意,该警告不用处理:(这种方法使用后会未生效:参考)
[Violation] Added non-passive event listener to a scroll-blocking <some> event.
Consider marking event handler as 'passive' to make the page more responsive. See <URL>
swipe 的轮播项显示内容没有随翻页正常更新。
swipe 对数据列表没有正常响应式更新。(不是该原因,实验验证是正常响应式更新的)
翻页后,父组件给子组件的 CardView 传入新的属性时,由于 CardView 内没有设置侦听属性变更,从而没有被重新渲染。
给 CardView 添加侦听器(watch),监测属性值的变化,变化后请求新的 card 数据。
更新数据导致的组件更新是重新渲染,而不是重新创建。(注意生命周期)
- 采用随机生成,但不能与历史记录中最多 10 条重复。
- 如果无法生成无重复的 cardId,则返回 null (没有更多 card)。
在已有 card 较多时,随机生成非常容易。
在已有 card 非常少甚至小于 10 时,生成时很容易发生与历史记录的碰撞:
- 此时生成无重复的 cardId 比较困难,资源消耗也对。
修改需求:
- 如果 10 次仍然碰撞,则返回和当前正在浏览的 card 不一样的 cardId 即可。
- 边界情况时返回 null 。(没有和正在浏览的 card 不一样的 cardId)
加入历史记录的时机:
- 创建 Swipe 时。(页面刷新会导致重新创建 Swipe)
- 访问最新阅读的 card 时。
- 添加 card 时。
- 在浏览全部 card 功能中,记录当前最新浏览位置。
注意:
在路由切换跳转之后,页面会刷新。
浏览最近历史记录时,使用 Vuex ,从 Swipe 向平级的 TabBar 传递信息。
swipe 传递以下状态信息:
- globalPresentIndex
- globalMostNewReadIndex
直接用计算属性监听 vuex 这两个值的变化,然后 return globalPresentIndex < globalMostNewReadIndex
。
Toast 组件元素存在,但不显示。
Vant4 的 Toast 是函数式组件,需要手动引入样式。详见
是 Vant4 组件自身的问题,解决方法为引入样式文件:
// 引入其中一个即可
import "vant/lib/index.css" // 组件不显示样式问题,针对所有组件
import 'vant/es/toast/style' // 针对 Toast 组件
但这样引入会是全局样式,会污染到其它样式。
尝试自己写一个 MyToast ,将样式作用域限制在 MyToast 内。
失败,没有找到限制的方法。在 style
中使用 @import "vant/lib/index.css";
仍然是全局样式。
如果使用 scoped
参考,Toast 仍会不显示,说明 Toast 必需使用全局样式。
自己写一个简单的提示,直接使用绝对定位的 div 。
直接使用刷新实现。
使用 provide 和 inject 传递方法,使用 nextTick 产生微任务重新创建组件。参考
自行抽象出 CardSwipe 组件后,直接响应式改变 CardSwipe 的数据即可。
将元素 scrollTop 属性值设为 0 即可。
注意,必须是控制滚动条的那个元素的该属性才行,其它元素 scrollTop 可能一直为 0 。
对于(vant4)开启了 autosize 属性的 textarea 。
输入时如果触发了 autosize ,此时需要点击外部才能失去焦点。
如果没有先点击外部失去焦点,而是:
- 直接提交按钮,则页面跳动,失去焦点,没有按钮点击事件。
- 直接其它输入框,则页面跳动,旧框失去焦点,当前输入框获得焦点。
focus 在一个 textarea 上时:
- 点击另一个 textarea 时,会先 blur ,然后跳动,然后 focus 过去。
- 点击提交,只会 blur 。
- click-input 事件发生在 focus 后。
实验可知:
- autosize 使输入框加行时,加大了整体页面的高度,但加行带来的增加的高度没有被修改到元素的属性中。
- blur 时视口会回到元素的 scrollTop 值的状态。
- 当滑动到底部点击提交时,或滑动到下方点击另一个 textarea 时,元素记录 scrollTop 值会比真实的 scrollTop 值小,从而页面会向上跳动。
对于直接点击按钮:(该点已是最终方案)
- blur 事件由 mouseDown 触发,将按钮上的 click 改为 mouseDown 并阻止其默认事件(阻止 blur)即可使得按钮生效。(此时输入框没有 blur)参考
对于直接点击其它输入框(textarea):
- mousedown 事件发生在 blur 前。
- focus 发生在跳动后。
- 在点击输入框时,保持视口在当前最新位置。(跳动后立刻跳动回来,但肉眼看不见)
- 记录 mousedown 时的 scrollTop ,然后再 focus 时跳动该位置。
方案一可以解决:有大量内容的「原文」框跳到「译文」框的情况;(1)
但解决不了:有大量内容的「原文」框跳到有大量内容「译文」框的跳动情况。(2)
进一步分析发现:在(2)时,跳动发生的时机在 focus 后,会在 focus 后更新到较小的组件内部记录的 scrollTop 值。
所以方案二:
-
focus 后设定一个延迟时间再跳到正确的位置。(1 ms)(该点已是最终方案)
跳转距离越长,页面闪动越剧烈,但仍然是轻微闪动。
mousedown 到 focus 后的一小段时间内,禁止滚动。
- blur 启用禁止滚动。
- focus 后一段时间,允许滚动。
失败,通过 vue3 动态控制 css 样式,设置 overflow-y 为 hidden 后,页面仍然会跳动。
对于直接点击按钮:
- blur 事件由 mouseDown 触发,将按钮上的 click 改为 mouseDown 并阻止其默认事件(阻止 blur)即可使得按钮生效。(此时输入框没有 blur)参考
对于直接点击其它输入框(textarea):
- focus 后设定一个延迟时间再跳到正确的位置。(1 ms)(该点已是最终方案)
- 最后的缺点是:跳转距离越长,页面闪动越剧烈,但仍然是轻微闪动。
一般的 button 颜色闪动可用 css 伪类选择器实现:
.button:active {
background-color: gray;
}
问题:(对于 enterPreview 的 button)
- mouseDown 的默认事件被阻止,用于阻止 blur 事件。
- button 的点击激活状态(active )的颜色变换事件发生在 mousedown 之后。
- 这会导致进入 preview 后 button 才变色。
解决方案:
- 设置 timeout ,先等 button 变色后再进入 preview 。
在「添加」路由页时,浏览器 url 为 http://localhost:8081/#/add
。
此时刷新,浏览器 url 保持不变(问题一),但组件会随之刷新,从而页面导航栏变化到默认的 item 「随机」,但页面显示的仍然是「添加」路由页的内容(问题二)。
可以使用路由传参解决,但应用在使用时,不会存在这种使用应用没有提供的方式刷新。(浏览器刷新属于应用以外提供的刷新方式)
直接忽略该问题。
时间戳使用 SQlite 的 timestamp :
- date 默认为一般日期。
- time 默认为 UTC 时间 + 0 时区。
在 SQL 语句中,可以使用 datetime('now', 'localtime')
获得当前系统时区的时间(本地时间)。(包括 Mapper 处的语句)(获取到的系统时间是数据库服务器所在地的系统时间)
在 Java 语句中,可以使用 AOP 对公共字段进行填充,也可以对公共字段进行一些处理,包括指定时区,格式转换等操作。(获取到的系统时间是 java 后端服务器所在地的系统时间)
OperationType 只用于切入方法的注解,具体是什么值不重要,使用 enum 。
CardType 和 HistoryType 的值需要提交到数据库内,需要知道具体值,使用 constant 。
搜索后显示的内容中,将关键词高亮。
整个替换 overviewList 的 innerHTML 中的 keyword 为新的标签内容:
'<span class="highlight">'+ keyword +'</span>'
.highlight {
background-color: yellow;
}
会丢失事件。
遍历 overviewList 下所有元素,找到所有 textNode 。
然后将其 parentNode 的 innerHTML 替换为方式一中的新的标签内容。
注意:
<p>
文本
</p>
以上标签中的“文本”为一个 textNode,它的 parentNode 为 <p>
。
遍历 overviewList 所有元素计算量大,并且会搜索到和 card-content 不相关的内容。
对 card-content 相关内容添加类属性,直接获取含有该类属性的元素,替换其 innerHTML 。
前端使用正则表达式忽略大小写,并用 replace 函数进行替换。
后端使用 lower 函数转换为小写再判断是否存在(instr()
)。
需要开启配置:参考
// vue.config.js
module.exports = {
chainWebpack: (config) => {
config.module
.rule('vue')
.use('vue-loader')
.tap((options) => {
return {
...options,
defineModel: true
}
})
}
}
输入值后,值在半秒内没有变化再查询提示数据。
watch 监听,如果没有定时器,则设置定时器(半秒),否则重新设置定时器的时间为半秒。
- 点击提示项关闭
- 点击其它地方关闭
- 输入框聚焦重新打开
组件内定义一个透明 cover。
缺点:会影响其它位置组件的某些功能。
组件内监听点击事件的传播,判断监听到 click 事件的当前 tips 是否归属于事件的 e.target
元素(即 input 元素)。
组件内控制显示与否。
缺点:麻烦,效率低。
将 input 和提示框抽象成一个组件。
直接利用 input 失焦事件,当前 input 失焦则隐藏提示框,聚焦则显示提示框。
对于点击其它地方关闭功能。点击提示项时,会先 blur 再触发 click 事件,这导致 blur 后提示框消失(blur 事件中会关闭提示框),无法触发 click 完成提示信息到 inputValue 的同步。
blur 后间隔一段时间再关闭提示框。
缺点:会产生一定的延时。
blur 事件由 mouseDown 触发,将 click 改为 mouseDown 并阻止其默认事件(阻止 blur)即可。(此时输入框没有 blur)
逻辑删除时,history 中会添加 type = 删除 的历史记录条目。
通过 history 查询初始 Card 列表时(在 GenerateCardView 中),history 中已被删除的 Card 的历史记录会被查询出来。
插入删除历史记录时,将所有历史记录置为 deleted = 0 。
问题:会破坏 deleted 字段的意义,该条历史记录应当是有效的记录,只是记录了删除而已。
查询历史记录时,使用 type 为 删除的条目,过滤历史记录中已经被删除的 Card 对应的条目。
过滤的方法:
- 原则:记录删除的历史条目,必定是该 Card 相关的最后一条历史条目。
- 方式:数据库查询可以使用 limit ,对数据从后往前扫描,一边记录 type 为 删除的条目的 CardId ,一边过滤即可。(数组删除操作比较占用时间,所以相对地,直接将满足条件的 CardId 放到新的数组中)
如果直接新增 Card ,然后将旧 Card 的 deleted = 0 ,那么 Card 的 id 会变为新的 CardID 。
添加 deleted = 1 的词条进行备份,原来的词条做修改。
动态切换 SQLite 的数据库文件。
利用 Spring 的 AbstractRoutingDataSource 配置动态数据源,只能在配置文件已经写入好的数据源列表中动态切换。
尝试直接替换文件。
可能会出现文件占用,无法操作。
缺点:SQLite 单个数据库文件 100 KB 以上,流量压力大。
每个卡片使用单个 Json 文件存储,每次启动,重建到 SQLite 中。
本地保存 SQLite 文件,每次只需要重建本地没有的或者更新后的卡片 json 。
card json 文件名定义:
defination:
<cardId>.<the update datetime of the card contained by this json file>.<logistic delete flag, deleted = 1>.json
example:
2.2024-06-26T18:47:49.0.json
3.2024-06-26T18:47:49.1.json
后端:
-
增
json 文件名使用 card 的 updatetime 。
-
弃用方案
如果 datetime 使用创建 json 文件的时间,会导致反复同步其它终端上传的新内容。(本地和云端的 card 永远相差大于 10 秒)
-
-
删
逻辑删除,保留旧版本。
-
通过 datetime 区分新旧版本。
-
删除时通过 deleted flag 表示 json 对应的卡片是否已删除。(弃用)
逻辑删除时,不方便修改 GitHub 的文件名(官方文档中没有给出直接的修改文件名的方式)。
-
如果最新 datetime 的 flag 为 1,表示该 card 已经完全删除。(采用)
每个 card 只考虑最新的 json 文件即可。
-
-
查
最新 datetime 的 json 文件才有效。
-
改
直接新增一个新的 datetime 的 json 文件即可。
-
每次进入 app 时统一同步(这样可以通过单一云端支持多终端的同步)
-
本地新的,更新到云端。
-
云端新的,更新到本地。
-
不同步的内容:
-
card 的历史版本
因为两次同步之间的多次变动的概率极小,所以一次同步只同步最新版本数据。
-
浏览历史
-
-
比对方法
-
获取 SQLite 内的(cardId,updatetime, deleted)和 json 文件名映射(cardId,datetime, deleted_flag)。
-
同一个 cardId 下,比对最新 updatetime 和最新 datetime 。
-
如果相差小于 10 秒,视为同一版本,不进行同步。
-
如果相差大于 10 秒,较新的 time 对应的 card 作为最新版本,进行同步。
即使因为一些网络的原因导致同一 card 的本地数据库的 updatetime 和云端的 datetime 相差大于 10 秒了,这样的 card 也不会太多,并且不会破坏数据一致性。
-
需要同步 delete 的 card
deleted_flag = 1 的 card 。
-
需要同步 add 的 card
本地和云端没有相同 cardId 的 card 。
-
需要同步 update 的 card
本地和云端存在相同 cardId 的 card 。
-
-
-
-
方法对比(弃用方案)
比对最新 updatetime 的 card 和最新 datetime 的 card 的内容,如果不一致,以较新的那个 time 为最新版本。(这样可以在 updatetime 和 datetime 不一致时正常同步,但是要大量比对 card 的内容)
-
前端传递到后端:
- 用户自行建立公有仓库
- 上传 用户名,仓库名,Access Token
- 存到 SQLite 里。
Personal access tokens (classic) 需要的 scope 权限:(否则报 404)
- repo
- workflow
GitHubFileManager 的仓库信息需要由前端上传。
不能在 GitHubFileManager 的构造函数中查询仓库信息。
因为在执行 GitHubFileManager 的构造函数时,syncMapper 还没有注入。
@Autowired
SyncMapper syncMapper;
public GitHubFileManager() {
GitHubInfo gitHubInfo = syncMapper.getLatestInfo();
if (gitHubInfo == null) {
System.out.println("GitHub information does not exist!");
return;
}
owner = gitHubInfo.getUsername();
repo = gitHubInfo.getRepoName();
accessToken = gitHubInfo.getAccessToken();
}
Spring 会在完成依赖注入和初始化 Bean 后调用 @PostConstruct 标注的方法,可以在其中进行数据库查询或其他初始化操作。
@Autowired
SyncMapper syncMapper;
@PostConstruct
public void init() { // 在 GitHubFileManager 初始化完成后自动调用,设置其信息。
GitHubInfo gitHubInfo = syncMapper.getLatestInfo();
if (gitHubInfo == null) {
System.out.println("GitHub information does not exist!");
return;
}
owner = gitHubInfo.getUsername();
repo = gitHubInfo.getRepoName();
accessToken = gitHubInfo.getAccessToken();
}
进入 jar 打包的 exe 或 apk 后,需要自动打开默认浏览器进入 localhost:8087
并自动全屏。
使用 JS 在进入网址时自动全屏页面。
Spring 加载完成后,在事件监听器(ApplicationListener)中使用 java.awt.Desktop 类打开浏览器。
问题:
Spirng 中没有桌面的访问权限,不支持。
移动端不支持 Desktop,需要其它方式。
- 平台检测
一种粗糙的方法,判断系统名称里是否含有操作系统的关键字(系统名称可人为更改,但一般情况下都有效)
String os = System.getProperty("os.name").toLowerCase();
if (os.contains("win")) {
System.out.println("Windows 操作系统");
} else if (os.contains("mac")) {
System.out.println("Mac 操作系统");
} else if (os.contains("nix") || os.contains("nux") || os.contains("aix")) {
System.out.println("类Unix操作系统");
} else if (os.contains("android")) {
System.out.println("安卓设备");
} else {
System.out.println("未知操作系统");
}
-
电脑端
使用 Runtime 类执行命令。
String url = "http://localhost:" + port;
Runtime.getRuntime().exec("cmd.exe /c start " + url); // for Windows
-
移动端(Android)
Java web 程序无法直接打开 Android 系统的浏览器,需要其它手动方案替代。
前端使用 js 自动全屏页面,同时在前端添加关闭程序按钮,点击后自动关闭前端和后端。
-
关闭后端
前端请求后端程序关闭。
-
关闭前端页面
使用 JS,但 JS 无法关闭非自己打开的前端页面(浏览器安全策略)。尝试多种替代方案无效。
放弃自动全屏页面,用户通过浏览器关闭当前页面。
当前页面关闭时,请求后端关闭。(页面关闭不会触发 Vue3 的 beforeUnmount 和 unmounted 生命周期,需要监听浏览器的关闭页面事件)
需要区分刷新和关闭标签页。
通过监听 beforeunload
(刷新或关闭前) 和 unload
(刷新或关闭后)事件的时间差来区分标签页的刷新和关闭操作。
刷新与关闭都会先后 onbeforeunload 与 onunload,但刷新在加载新页面前还需要一些准备工作,所以刷新事件在执行到 onunload 事件时,用的时间会比关闭事件时间长。
用一个接近空白的测试页面进行测试:
- 关闭时 onbeforeunload 与 onunload 的时间差一般会在 3 毫秒内。
- 刷新时的时间差一般会在 10 毫秒以上。
本项目用 8 ms 作为边界值,小于 8 毫秒为关闭(取一个较大的值,保证后端关闭),大于为刷新。
如果在 onbeforeunload 与 onunload 内用 axios 发送请求,可能因为页面资源的快速变动导致请求中断。
解决方法如下:
-
可以手动对页面变动延时,让异步请求有足够时间发送,但不优雅。
-
等待 axios 的异步请求完毕后再继续执行,使用 async 和 await 模拟同步请求。(采用)
-
使用 fetch,并设置 keepAlive 参数为 true 。
即使页面处于关闭状态也会保持连接,利用这个特性,可以发送可靠的请求。
-
使用 navigator.sendBeacon() 方法传输少量数据。
该方法可用于通过HTTP将少量数据异步传输到Web服务器,使用户代理在有机会时异步地向服务器发送数据,同时不会延迟页面的卸载或影响下一导航的载入性能。(IE 不兼容)
-
打包为 exe ,提供给 Windows 。
用 exe4j 将 jar 打包成 exe文件。exe4j 使用(建议在官网下载 exe4j), jdk 提取 jre
-
打包为 apk ,提供给 Android 。
需要建立 Android 应用,调整代码以适应 Android 平台,然后导出 apk 。
- 这样才能自动打开关闭 Android 的浏览器。
对于 exe :导出的 exe 某些功能不稳定,比如自动关闭后端这个功能。
对于 apk :修改代码比较麻烦。
在 Java 后端方案已经写好的前端的基础上修改。
-
全部使用最简单的实现方案,不使用逻辑删除。
-
使用 IndexedDB 在本地离线存储数据
IndexedDB
比LocalStorage
和SessionStorage
更适合存储大量数据或者需要进行复杂查询的数据。
- 所有 Card 存储在一个 Json 文件中,文件名为
datetime.json
。 - 存储所有 json 的历史版本。
- 每次进入和退出 app 时进行云端同步,如果有异常发生,等待下一次同步即可。
- 同步如何确定已经被删除和修改过后的 Card ?
- 比较本地和云端数据文件的 datetime 。
- card 的浏览历史只在本地存储。
注意:
- 如果每个 card 单独存储,可以方便逐步同步或获取数据,但是搜索和输入提示功能一次需要所有的 card 数据。
- GitHub API 不允许一次请求获取多个文件,除非使用 Git 。
-
电脑端
以服务方式启动项目,用 Chrome 安装网页。
-
Android 端
电脑当作临时服务器,手机通过局域网访问,用 Chrome 安装网页。