Howdz是基于Vue3
+ Typescript
开发的一个完全自定义配置的浏览器导航起始页,支持按需添加物料组件,可自由编辑组件的位置、大小与功能。支持响应式设计,可自定义随机壁纸、动态壁纸等。项目提供网页在线访问、打包出浏览器插件、打包出桌面应用(Electron)等访问方式。
本文记录项目开发中使用的相关技术。
项目中运行自由添加各种物料组件,而每一个物料组件都含有自己的配置项表单,而其中又有部分相同的配置项,所以可以实现一个 JS 数据驱动的表单封装。
当前使用了ElementPlus
框架,封装了一个 StandardForm 组件,为其传入formData
与formConf
两个属性即可生成双向绑定的表单,支持JSX
插入其他自定义组件。因篇幅问题,组件封装代码可参考此处: standard-form.vue
然后可以使用类似 JSON 的格式,实现各个物料组件的配置表单,例如Weather
组件的setting.tsx
如下:
// @/materials/Weather/setting.tsx
import pick from "../base"; // pick可以自由选取公用的配置
export default {
formData: {
weatherMode: 1,
cityName: "",
animationIcon: true,
duration: 15,
position: 5,
baseFontSize: 16,
textColor: "#262626",
textShadow: "0 0 1px #464646",
iconShadow: "0 0 1px #464646",
fontFamily: "",
padding: 10,
},
formConf(formData: Record<string, any>) {
// 传入formData以实现双向绑定
return {
weatherMode: {
label: "天气城市",
type: "radio-group",
radio: {
list: [
{ name: "自动获取(IP)", value: 1 },
{ name: "手动输入", value: 2 },
],
label: "name",
value: "value",
},
},
cityName: {
when: (formData: Record<string, any>) => formData.weatherMode === 2, // 类似v-if
type: "input",
attrs: {
placeholder: "请输入城市名(目前仅支持中国城市名)",
clearable: true,
},
rules: [
{
required: true,
validator: (
rule: unknown,
value: string,
callback: (e?: Error) => void
) => {
formData.weatherMode === 2 && !value
? callback(new Error("请输入城市名"))
: callback();
},
},
], // 支持el-form原生rule
},
animationIcon: {
label: "动画图标",
type: "switch",
tips: "默认使用含动画的ICON,若想提高性能可关闭使用静态ICON",
},
duration: {
label: "自动刷新频率",
type: "input-number",
attrs: { "controls-position": "right", min: 5, max: 12 * 60 },
tips: "刷新频率,单位为分钟",
},
...pick(formData, [
// 选取公用的配置
"position",
"baseFontSize",
"textColor",
"textShadow",
"iconShadow",
"fontFamily",
"padding",
]),
};
},
};
物料组件添加后,在编辑模式下可以右键弹出菜单更改配置或删除等。右键菜单的实现来源与笔者开源的@howdjs/mouse-menu
。同时在本项目中,为了兼容移动端,对插件进行了二次封装,为其添加了长按弹出菜单的功能。二次封装代码参考此处。
项目中采用的是vue指令
的方式使用,菜单插件可以接收任意参数进行回调,所以可以把点击的物料组件数据传到回调中进行各种操作。
<template>
<div v-for="element in affix" :key="affix.id">
<div v-mouse-menu="{ disabled: () => isLock, params: element, menuList }">
<!--Material code-->
</div>
</div>
</template>
<script>
setup () {
const isLock = computed(() => store.state.isLock)
const menuList = ref([
{ label: '基础配置', tips: 'Edit Base', fn: (params: ComponentOptions) => emit('edit', params.i) },
{ label: '删除', tips: 'Delete', fn: (params: ComponentOptions) => store.commit('deleteComponent', params) }
])
// fn中的params为组件数据
}
</script>
当前提供 2 中布局方式,一种是基于类文件流的栅格布局,这种布局会让组件一个接一个排列,另外一个是 Fixed 布局,可以让组件固定与页面任意位置。
栅格模式使用vue-grid-layout实现,该插件 vue3 版本处于 Beta 中。
<template>
<grid-layout
v-model:layout="list"
:col-num="12"
:row-height="rowHeight"
:is-draggable="!isLock"
:is-resizable="!isLock"
>
<grid-item
v-for="item in list"
:x="item.x"
:y="item.y"
:w="item.w"
:h="item.h"
:i="item.i"
>
<!--Material code-->
</grid-item>
</grid-layout>
</template>
<script>
setup () {
const isLock = computed(() => store.state.isLock)
const list = computed({
get: () => store.state.list,
set: (val) => { store.commit('updateList', val) }
})
}
</script>
使用v-model:layout
双向绑定栅格模式物料组件列表数据,因为物料数组存在 vuex 中,这里用computed
的 setter 进行更新。isLock
是用于判断当前是否处于编辑模式,在锁定状态下禁用拖拽与大小更改。当前使用的栅格数为 12,即将屏幕宽度分割为 12 份。
Fixed 模式使用笔者自己开源的@howdjs/to-control插件完成,可以让物料组件固定在页面的任何位置中,也支持拖拽右下角更改大小。
<template>
<div
v-for="element in affix"
v-to-control="{
positionMode: element.affixInfo.mode,
moveCursor: false,
disabled: () => isLock,
arrowOptions: { lineColor: '#9a98c3', size: 12, padding: 8 }
}"
:key="element.id"
@todragend="handleAffixDragend($event, element)"
@tocontrolend="handleAffixDragend($event, element)"
>
<!--Material code-->
</div>
</template>
<script>
setup () {
const isLock = computed(() => store.state.isLock)
const affix = computed(() => store.state.affix)
const handleAffixDragend = ($event: any, element: ComponentOptions) => {
const mode = element.affixInfo?.mode || 1
const { left, top, bottom, right, width, height } = $event
const _element = JSON.parse(JSON.stringify(element))
_element.affixInfo.x = [1, 3].includes(mode) ? left : right
_element.affixInfo.y = [1, 2].includes(mode) ? top : bottom
if (width && height) {
_element.w = width
_element.h = height
}
store.commit('editComponent', _element)
}
}
</script>
与栅格模式不同,这里是使用事件回调函数对组件的 Vuex 数据进行更新。也是使用isLock
判断组件是否锁定。插件支持更改定位方向,记录在右上角、右下角等,这样对响应式布局很有效。更多用法可参考: @howdjs/to-control
系统提供一种配置交互行为的功能,可以配置点击一个组件时弹窗另外一个组件,并配置组件弹出的方向。经过调研后发现Element-plus
的Popover
并不太适合用于这种情况,因为弹出的组件时动态的。于是就自己封装了一个组件,不仅支持配置Popover
的各个方向,还另外扩展了一个ScreenCenter
的弹出,让组件可以在屏幕中间弹出(类似dialog
)。
通过传入点击的元素、目标弹窗的宽高和弹窗方向,返回出目标弹窗的x
和y
。核心代码如下:
/**
* 获取Popover目标信息
* @param element 来源DOM
* @param popoverRect popover信息
* @param direction popover方向
* @returns [endX, endY, fromX, fromY]
*/
export function getPopoverActivePointByDirection(
element: HTMLElement,
popoverRect: PopoverOption,
direction = DirectionEnum.BOTTOM_CENTER
) {
const { width, height, top, left } = element.getBoundingClientRect();
const {
width: popoverWidth,
height: popoverHeight,
offset = 10,
} = popoverRect;
const activePointMap = {
[DirectionEnum.SCREEN_CENTER]: [
window.innerWidth / 2 - popoverWidth / 2,
window.innerHeight / 2 - popoverHeight / 2,
],
[DirectionEnum.TOP_START]: [left, top - popoverHeight - offset],
[DirectionEnum.TOP_CENTER]: [
left + width / 2 - popoverWidth / 2,
top - popoverHeight - offset,
],
[DirectionEnum.TOP_END]: [
left + width - popoverWidth,
top - popoverHeight - offset,
],
[DirectionEnum.RIGHT_START]: [left + width + offset, top],
[DirectionEnum.RIGHT_CENTER]: [
left + width + offset,
top + height / 2 - popoverHeight / 2,
],
[DirectionEnum.RIGHT_END]: [
left + width + offset,
top + height - popoverHeight,
],
[DirectionEnum.BOTTOM_END]: [
left + width - popoverWidth,
top + height + offset,
],
[DirectionEnum.BOTTOM_CENTER]: [
left + width / 2 - popoverWidth / 2,
top + height + offset,
],
[DirectionEnum.BOTTOM_START]: [left, top + height + offset],
[DirectionEnum.LEFT_END]: [
left - popoverWidth - offset,
top + height - popoverHeight,
],
[DirectionEnum.LEFT_CENTER]: [
left - popoverWidth - offset,
top + height / 2 - popoverHeight / 2,
],
[DirectionEnum.LEFT_START]: [left - popoverWidth - offset, top],
};
const fromPoint = [left + width / 2, top + height / 2];
return [...activePointMap[direction], ...fromPoint] || [0, 0, ...fromPoint];
}
另外,使用transform-origin
这个属性可以实现弹窗从点击元素过渡展开的动画。最后配置弹窗的方向与弹出的组件类型即可。代码参考:ActionPopover.vue
在Collection
与Search
组件中,都有用到一个功能,就是由用户输入网址后能自动获取到网站的 Favicon。在初版实现是直接使用网址 origin + /favicon.ico 获取,但经过大量尝试后发现,当前很多网站的 icon 并不是以这种标准形式存储的。所以后面就自己实现了一个后端接口来获取。
后端接口原理:
Redis
中读取已缓存的图标路径,读取到则返回cheerio
加载网站,使用$('link[rel*="icon"]').attr('href')
读取图标路径Redis
缓存,否则返回获取失败同时接口接收type
参数,可由后端直接返回图片流,以解决一些网站的 ICON 资源做了 CORS 限制。因为在Collection
组件中,为了减少初次访问请求加载数,前端读取到图标后会将图标转成 BASE64 格式存到本地存储中。这种方式需要使用 Ajax 获取图标,让接口直接返回文件流可以解决跨域问题。
另读取图标时,前端会使用 Canvas 通道法将图标的白色部分扣成透明,代码可参考此处
项目仍在持续优化开发中,欢迎各种建议。由于篇幅问题,部分使用到的技术会不定时更新记录。若感谢的可以持续关注、Star,谢谢。