案例使用低代码平台快速复刻iTab,一个好看又好用的自定义卡片式浏览器新标签页

zcimg10 个月前
10

iTab是一个好看又好用的自定义卡片式浏览器新标签页,精致的iOS式小组件卡片设计让信息一目了然,常用网址随心订制。

整个过程在众触低代码平台进行,使用表达式快速复刻逻辑(高度简化版JS)。

声明

本课程于2023年3月仿自iTab,相关素材版权归属于原站,本课程作为仿站习作,仅做教学用,不得用作商业用途,商业合作请访问源站。

本课程目的是如何在众触低代码平台快速开发一个浏览器新标签页的教学示例。课程开发前试用了市面上几乎所有的新标签页,选中iTab作为仿制案例是个人觉得它是当时最优秀的,也喜欢它的UI风格。喜欢此课程的朋友请支持iTab原创。需要注意的是本案例作为教学示例是不更新的,而原创目前正在快速迭代中的,比如正在开发文件夹功能,喜欢尝鲜的朋友应该关注iTab原创。当然了,对于有一定开发能力的朋友可以基于我的这个模板快速二次定制开发,做出完全符合自己喜好而独具一格的起始页,有这个意愿的朋友可以微信我(kenyxy)组个群一起探讨自定义风格的话题。

动手玩一玩

访问https://startpage.zc-app.cn可以看到最终效果,在URL后面加/z就进入开发模式:https://startpage.zc-app.cn/z

详尽的的教学请移步B站视频:https://www.bilibili.com/video/BV1aP411S7Wv

初始化数据

从localStorage读取用户配置信息

$v.config = localStorage("config")
$v.nav = localStorage("nav")
$v.history = localStorage("history") || []
$v.focusMode = localStorage("focusMode")

如果首次使用则从数据库拉取默认配置

$v.config ? "" : $exp.defaultConfig.exc()
$v.nav ? "" : $exp.defaultNav.exc()

$exp.defaultConfig:

$xtk.get("x", "config")
$v.config = $r.x
localStorage("config", $v.config)

$exp.defaultNav:

$xtk.get("x", "nav")
$v.nav = $r.x
localStorage("nav", $v.nav)

数据结构:
config:各种设置项;nav:导航卡片/图标;focusMode:聚焦模式;history:搜索历史;notes:记事本。
提示:在平台编辑模式下F12打开开发者模式console,直接复制变量可以查看变量内容,比如$v.config,知道变量结构以后可以有更主观的感受。

侧边栏分组

分组源自数据源$v.config.nav

滚动

点击某一分组时把对应的主体部分滚入页面视窗:

$v.navIdx = $i
$l.scroll = $$(".grids").slice(0, $i).reduce('$acc + getComputedStyle($x).height.parseInt() + 55', 0)
$v.mainE.scrollTo({ top: $l.scroll, behavior: "smooth" })

$$(".grids")是所有分组页面数组,slice取其前面$i页并reduce计算它们的高度和,这就是主体元素$v.mainE需要向上滚动的距离。
你会发现主体部分每页都用一个交叉观察器包装着,里面的表达式跟上面的一模一样,它是监控页面的滚动情况,一旦当前页面滚入视窗一点点就把整个页面平滑滚入进来,$v.navIdx的改变会改变侧边栏的选中状态,这就是为什么鼠标滚轮轻轻一划就会翻一页的原因。
但侧边栏的点击滚动也会被交叉观察器检测到,触发观察器里的表达式,从而导致干扰。为了规避干扰我们在点击滚动逻辑前设一个标识,表示当前正处于滚动中,滚动逻辑2秒后再取消滚动标识:

$v.scrolling = true
...
timeout(2000, '$v.scrolling = false')

然后在交叉观察器表达式前添加stopIf($v.scrolling),表示如果处于滚动时不执行表达式,这样就避免了干扰。

拖拽排序

侧边栏有个拖拽排序插件用于对$v.config.nav进行拖拽排序,排序后保存回localStorage。

添加/编辑分组

点击+号按钮直接设置$v.modal.key变量弹出分组窗口;右键某个分组先把当前分组信息($x和$i)放入变量$v.pop,弹出右键菜单,点击编辑菜单把$v.pop转存到$v.modal变量后再弹出分组窗口。所以再弹出中判断当前是添加还是编辑的依据是是否有分组信息$x:$x会放入弹窗表单初始数据中从而显示当前分组的图标和名称,根据是否有$x分别执行编辑逻辑和添加逻辑。
编辑时直接替换侧边栏对应的分组设置信息(图标和名称):

$v.config.nav[$v.modal.$i] = $f.m

添加时把表单数据追加到侧边栏分组中并在主体导航中添加空数组以容纳小组件:

$v.config.nav.push($f.m)
$v.nav.push([])
localStorage("nav", $v.nav)

主体导航卡片/图标

源自动态数据$v.nav,是个二维数组,第一维由交叉观察器包装以检测滚动,第二维需要渲染导航卡片或图标,网址图标的数据简单,只需要一个<a/>链接来渲染即可;小组件卡片就复杂了,每种卡片的渲染逻辑都不同,我们统一放到一个div中,里面根据不同的组件名称分别用渲染条件指定渲染,具体的小组件逻辑会在后面详细讲述。
与侧边栏类似,也有拖拽排序插件对$v.nav的每一项($x)进行拖拽排序。
另外值得注意的是卡片/图标是使用grid css网格进行排版,并设置grid-auto-flow: dense属性来填充网格剩余空位使得图标自动排布。

搜索引擎

百度搜索提示/自动补全:

load("https://suggestion.baidu.com/su?wd=" + $f.search.txt)

你直接在浏览器地址栏中输入https://suggestion.baidu.com/su?wd=低代码,你会得到类似于下面的返回:

window.baidu.sug({q:"低代码",p:false,s:["低代码开发平台","低代码平台","低代码开发","低代码平台什么意思","低代码平台排名","低代码开发是什么意思","低代码是什么意思","低代码开发平台优缺点","低代码平台搭建","低代码开发平台前景"]});

可以看到它会执行window下的一个函数,那我们就一个这个名字构造一个回调函数:

$w.baidu.sug = func('$v.suggestion = $arg.s; render()')

得到数组$v.suggestion后就可以把提示列表渲染出来。
注:众触平台中window简写成$w。

按回车键搜索并保存历史:

stopIf(!$f.search.txt || $ev.key != "Enter")
go($v.config.search.url + $f.search.txt)
$v.history.unshift($f.search.txt)
localStorage("history", $v.history.unique().slice(0, 30))

$v.config.search.url是用户设置的默认搜索引擎的搜索地址,这里会弹窗新标签页搜索关键字$f.search.txt。
最新的搜索关键字将插入到搜索历史队首,去重后保留最新的30个历史。

切换搜索引擎:
$v.config.searchEngine里存放的是常用的搜索引擎列表,用户点击当前搜索引擎图标时会弹窗渲染此列表,用户点击选择一个新的搜索引擎覆盖上面的$v.config.search作为新的默认搜索引擎。

添加搜索引擎:
用户也可以点击添加新的搜索引擎,在弹窗的模态框的挂载组件里通过$xtk.get("x", "searchEngine")取得所有搜索引擎列表供用户选择,同时用户也可以通过自定义搜索引擎输入框来添加。

聚焦模式

点击顶部的时钟会在聚焦模式和普通模式间切换

$v.focusMode = $v.focusMode ? "" : 1
localStorage("focusMode", $v.focusMode)

聚焦模式会隐藏侧边栏和主体卡片/导航图标(准确来说是不渲染,这样可以提高性能),并把时钟和搜索栏往中间靠拢一点。

数据导入导出

数据导出:

download({config: $v.config, nav: $v.nav, notes: $v.notes}, "startpage_" + date().format("yyyy-MM-dd") + ".json")

把设置、导航、记事本组成一个json文件下载下来,你也可以试一试把搜索历史添加到json中。

数据导入:

点击触发的是一个隐藏的文件输入表单,上传类似如上下载的文件即可覆盖当前的设置:

$l.file = $ev.target.files[0]
stopIf($l.file.type != "application/json", 'warn("请上传json文件")')
$l.reader = new $w.FileReader()
$l.reader.readAsText($l.file, "UTF-8")
$l.reader.onload = func($exp.load)

$exp.load

$l.O = JSON.parse($arg.target.result)
stopIf(!$l.O.config, 'warn("文件格式不正确")')
$v.config = $l.O.config
$v.nav = $l.O.nav
$v.notes = $l.O.notes
info("已导入")

备份与恢复(需账号)

所有自定义设置,记事本等内容都可以同步进行云端,这样随时更换设备也不会担心丢失数据了。

备份到云端:

stopIf(!$c.me, '$v.modal = { key: "login" }')
$xtk.modify("setting", $c.me._id, {config: $v.config, nav: $v.nav, notes: $v.notes}, 1)
$r ? info("已备份") : warn("备份失败")

如果账号就弹窗登录;把最新数据更新到$xtk表中,最后一个参数为真时表示如果数据库中此条数据不存在就新建一条。

同步到本地:

stopIf(!$c.me, '$v.modal = { key: "login" }')
$xtk.get("setting", $c.me._id)
stopIf(!$r._id, 'info("你从未备份过")')
$v.config = $r(1).x.config
$v.nav = $r(1).x.nav
$v.notes = $r(1).x.notes
localStorage("nav", $v.nav)
localStorage("config", $v.config)
localStorage("notes", $v.notes)
info("已同步")

就是把前面保存到数据库中的设置数据取下来覆盖本地数据。$r(1)表示第1行表达式的返回值(从0开始算),即$xtk.get()返回的数据。

登录

用的是【账号登录管理】插件,可以有多种登录注册方式。其中微信扫码登录要求参考文档配置微信公众号信息。

右键弹窗

可以在多个地方右键弹窗:侧边栏右键、图标右键、桌面其它地方右键(即根节点)

侧边栏右键:

$ev.stopPropagation()
$v.pop = {nav: 1, $i, $x}
render()
$v.popE = $(".pop")
$v.popE.style.left = "50px"
$v.popE.style.top = $ev.pageY + "px"
$v.popE.addClass("show")

首先阻止事件往上传播,避免触发根节点的右键事件;把右键点击的分组信息放入$v.pop后渲染,然后跳转右键弹窗的位置以显示在点击位置的右下方。

图标/小组件右键:

$ev.stopPropagation()
$v.pop = {cpt: 1, $i, $x, $pi: $p.$i}
render()
$v.popE = $(".pop")
$v.popE.style.left = ($w.innerWidth - $ev.pageX < 110 ? $w.innerWidth - 110 : $ev.pageX) + "px"
$v.popE.style.top = ($w.innerHeight - $ev.pageY < 110 ? $w.innerHeight - 110 : $ev.pageY) + "px"
$v.popE.addClass("show")

因为小组件图标是在二维数组中的,我们不仅要定位当前图标所在的位置下标$i,还得知道它所在的分组所在的下标,即父级下标parent’s index:$p.$i。调整弹窗位置时还考虑到如果点击的是最靠右的图标或最靠下边的图标时弹窗会溢出到视窗外而做进一步调整。

调节样式

可以设置图标的尺寸、圆角、间距、文字大小与颜色。每种样式的做法都类似,下面这个是拖到滑条时动态设置图标尺寸的:

$v.body.style.setProperty("--icon_size", $f.m.css.icon_size + "px")

--icon_size是CSS变量,可以从全局设置里的【共用CSS】找到:

body {
  --icon_size: 60px;
  --icon_radius: 16px;
  --icon_gap_x: 30px;
  --icon_gap_y: 30px;
  --icon_opacity: 1;
  --icon_name_size: 12px;
  --icon_name_color: #fff;
  --wall_mask: 0;
  --wall_blur: 2px;
}

图标类名grid的CSS宽度和高度使用到了这个变量:

.grid {
    width: var(--icon_size);
    height: var(--icon_size);
    border-radius: var(--icon_radius);
    opacity: var(--icon_opacity);
}

调节壁纸遮罩度与模糊度也类似。

添加小组件

$v.nav[$f.m.nav].push($x)
localStorage("nav", $v.nav)
info("添加【" + name + "】成功")

$f.m.nav是目标分组,默认是当前分组。$x是小组件数组被点击的数据项。
添加网址导航图标也类似。

自定义图标

图标由URL地址,名称,图片或文字和一些样式组成。
添加自定义图标和编辑图标共用同一个模态窗,上面提到右键单击一个图标时会把当前图标信息$x放入变量,这里也是以此来判断是编辑还是添加的:

$v.modal.$x ? $v.nav[$v.modal.$pi][$v.modal.$i] = $f.diy : $v.nav[$f.m.nav].push($f.diy)
$v.saveNav()

编辑的时候还不显示弹窗侧边栏和目标分组,因为编辑是覆盖原位置上的图标,目标分组不可变。

右键删除

$v.nav[$v.pop.$pi].splice($v.pop.$i, 1)
$v.saveNav()
info("已删除" + $v.nav[$v.pop.$i].name)

批量删除

点击批量删除先添加batchDel类名以显示每个图标/小组件右上角的删除图标:

$ev.stopPropagation()
$v.mainE.addClass("batchDel")
$v.pop = ""

点击删除图标删除并保存到localStorage:

$ev.stopPropagation()
$v.nav[$p.$i].splice($i, 1)
$v.saveNav()
info("已删除" + name)

点击删除图标的任何地方会让隐藏所有删除图标,只是因为在根节点有个点击事件:

$v.showSearchE = 0
$v.popE.classList.contains("show") ? $v.popE.removeClass("show") : ""
$v.mainE.classList.contains("batchDel") ? $v.mainE.removeClass("batchDel") : ""

最后一行就是如果主体页面种包含batchDel类名就删除此类名。同样道理,点击其它地方会关闭切换搜索引擎弹窗或右键弹窗的逻辑也是在这里。你也许能猜到为什么上面有道理$ev.stopPropagation(),这是阻止事件传播到根节点触发此逻辑而关闭刚刚弹出来的弹窗。

调节布局

有些小组件卡片允许调整布局,右键菜单中会展示可调整的布局,选中的布局保存到导航设置中从而改变小组件的CSS样式。

时钟小组件

每隔一秒循环调用自身一次,取得最新时间更新对应元素的文本内容。

$exp.loop.exc()

为了性能,用现在的时间跟上一次的时间比较(时对时、分对分、天对天),不同时才去操作对应的页面元素。

$l.now = date().format("MM/dd/HH/mm/ss").split("/")
$l.now[2] == $l.pass[2] ? "" : $el.firstChild.firstChild.innerText = $l.now[2]
$l.now[3] == $l.pass[3] ? "" : $el.firstChild.children[2].innerText = $l.now[3]
size != "2x2" || $l.now[1] == $l.pass[1] ? "" : $el.lastChild.innerText = $l.now[0] + "/" + $l.now[1] + " 周" + $v.周[date().getDay()]
size != "1x2" ? "" : $el.firstChild.lastChild.innerText = $l.now[4]
$l.pass = $l.now
timeout(1000)
$exp.loop.exc()

点击弹出屏保电子钟:

$v.modal = {key: "clock", arr: [3, 10, ":", 6, 10, ":", 6, 10]}

电子钟需要给每个数字位做动画,要单独渲染,数组[3, 10, ":", 6, 10, ":", 6, 10]渲染时钟的每一位,数字表示当前位有多少种数字可能,比如第一个是十位上的小时,有3种可能:0,1,2,第四位的是十位上的分钟,满60分就得进位为小时。
挂载时就进入循环:

$l.fn = func($exp.fn)
$exp.loop.exc()

绚丽的动画来自于两个类名:用active标识当前显示的数字,用before标识前一次显示的数字。构造一个函数$l.fn用来添加这个两个类名,同时也要移除上一次动画添加的类名。它接受3个参数,第一个参数$args[0](简写成$arg)是元素位置下标,第二个参数$args[1]是即将要显示的数字下标,第三个参数$args[2]是当前数字位数长度。要标识前一次显示的数字,用第二个参数减去1就可以了,但当当前显示的数字在最前排是(即第二个参数是0时),前一次显示的数字应该是最后排(即当前数字位数长度),这就需要第三个参数了。
$exp.fn:

$("#" + id + "_" + $arg + "_0 .active").removeClass("active")
$("#" + id + "_" + $arg + "_0 .before").removeClass("before")
$("#" + id + "_" + $arg + "_0_" + $args[1]).addClass("active")
$("#" + id + "_" + $arg + "_0_" + ($args[1] == 0 ? $args[2] : $args[1] - 1)).addClass("before")

$exp.loop:

$l.d = date()
$l.h = $l.d.getHours()
$l.m = $l.d.getMinutes()
$l.s = $l.d.getSeconds()
$l.now = {h1: parseInt($l.h / 10), h0: $l.h % 10, m1: parseInt($l.m / 10), m0: $l.m % 10, s0: $l.m % 10, s1: parseInt($l.s / 10), s0: $l.s % 10}
$l.last.h1 == $l.now.h1 ? "" : $l.fn(0, $l.now.h1, 2)
$l.last.h0 == $l.now.h0 ? "" : $l.fn(1, $l.now.h0, 9)
$l.last.m1 == $l.now.m1 ? "" : $l.fn(3, $l.now.m1, 5)
$l.last.m0 == $l.now.m0 ? "" : $l.fn(4, $l.now.m0, 9)
$l.last.s1 == $l.now.s1 ? "" : $l.fn(6, $l.now.s1, 5)
$l.fn(7, $l.now.s0, 9)
$l.last = $l.now
timeout(1000)
$v.modal ? $exp.loop.exc() : ""

桌面上的时钟就是简化版的时钟小组件,逻辑类似。

世界时钟小组件

挂载时把配置项放到变量后渲染,渲染完成后选取所有时分秒元素。

$v.worldClock = config
$v.worldClocks = $v.worldClock.keys()
render()
$l.hours = $$("#" + id + " .hour")
$l.minutes = $$("#" + id + " .minute")
$l.seconds = $$("#" + id + " .second")
$exp.loop.exc()

根据各地时差计算出各自的旋转角度,每隔一秒更新一次样式。
$exp.loop:

$l.second = date().getSeconds()
$l.minute = date().getMinutes()
$l.hour = date().getHours()
$l.seconds.forEach('$x.style.transform = "rotate(" + $l.second * 6 + "deg)"')
$l.minutes.forEach('$x.style.transform = "rotate(" + (($l.minute + $l.second / 60) * 6 + $v.worldClock[$v.worldClocks[$i]] % 1 * 360) + "deg)"')
$l.hours.forEach('$x.style.transform = "rotate(" + ($l.hour + $l.minute / 60 + $v.worldClock[$v.worldClocks[$i]]) * 30 + "deg)"')
timeout(1000)
$exp.loop.exc()

点击弹出配置窗口,配置各地名称和相对北京时间的时差。

备忘录小组件

挂载时读取备忘录列表

$v.notes = localStorage("notes")

表单标题输入框失去焦点时保存回localStorage:

stopIf($v.modal.note.title == $f.m.title)
$v.modal.note.ut = date().getTime()
$v.modal.note.title = $f.m.title
localStorage("notes", $v.notes)

标题没变化是不用保存的,每次编辑也更新一下ut(updated time)。

壁纸小组件

挂载时读取最新Bing壁纸,从最后一张开始每隔一段时间往前翻一张。

$xtk.get("x", "必应墙纸")
$v.wallpaper = { arr: $r.x, idx: $r.x.length }
$exp.loop.exc()

$exp.loop:

$exp.next.exc()
timeout(15000)
$v.wallpaper.manul ? "" : $exp.loop.exc()

$exp.next:

$v.wallpaper.idx = $v.wallpaper.idx < 1 ? $v.wallpaper.arr.length - 1 : $v.wallpaper.idx - 1
$el.firstChild.style.backgroundImage = 'url("https://cn.bing.com' + $v.wallpaper.arr[$v.wallpaper.idx].url + '_UHD.jpg&w=480&h=270")'
$el.lastChild.innerText = $v.wallpaper.arr[$v.wallpaper.idx].title

点击时立马换一张:

$v.wallpaper.manul = true
$exp.next.exc()

点击后标识成手动manul,不再自动翻。

下载壁纸:

download('https://cn.bing.com' + $v.wallpaper.arr[$v.wallpaper.idx].url + '_UHD.jpg', $el.nextSibling.innerText)

设为桌面壁纸

$v.config.css.wall_bg = 'https://cn.bing.com' + $v.wallpaper.arr[$v.wallpaper.idx].url + '_UHD.jpg'
localStorage("config", $v.config)

其它小组件

倒计时,热搜榜,汇率,彩虹屁,今日诗词

比较简单,略。

定时任务

热搜、汇率和彩虹屁的数据来自天行数据API,在控制面版的【定时任务】栏可以看到:每小时获取一次热搜,每天获取一次汇率和彩虹屁。由于是免费账号,获得数据的频次受限,所以获取的数据先存入数据库,用户从数据库拿而不直接从第三方API拿。

比如每小时热搜:

$api.request("https://apis.tianapi.com/nethot/index?key=bbc73e6e65b75855ee4a73d453358d62")
$api.request("https://apis.tianapi.com/topnews/index?key=bbc73e6e65b75855ee4a73d453358d62")
$api.request("https://apis.tianapi.com/weibohot/index?key=bbc73e6e65b75855ee4a73d453358d62")
$xtk.modify("x", "热搜", {百度: $r(0).result.list, 头条: $r(1).result.list, 微博: $r(2).result.list}, 1)

另外每天也获取当天的必应墙纸。

$api.request("https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1&mkt=zh-CN")
$xtk.modify("x", "必应墙纸", {$push: {arr: {url: $r.images[0].urlbase, title: $r.images[0].title, date: $r.images[0].startdate}}}, 1)

今日诗词本身是免费的,就直接从它的API拿。在组件挂载时直接加载它的JS文件,它提供一个load函数,执行一次就返回新诗词,我们把它放到变量后渲染出来。

投诉
收藏
点赞 1
评论 0
由众触低代码平台生成和驱动