boycot 搜索
avatar

boycot

Howdz起始页开发记录

前言

Howdz是基于Vue3 + Typescript开发的一个完全自定义配置的浏览器导航起始页,支持按需添加物料组件,可自由编辑组件的位置、大小与功能。支持响应式设计,可自定义随机壁纸、动态壁纸等。项目提供网页在线访问、打包出浏览器插件、打包出桌面应用(Electron)等访问方式。

本文记录项目开发中使用的相关技术。

表单封装

项目中运行自由添加各种物料组件,而每一个物料组件都含有自己的配置项表单,而其中又有部分相同的配置项,所以可以实现一个 JS 数据驱动的表单封装。

当前使用了ElementPlus框架,封装了一个 StandardForm 组件,为其传入formDataformConf两个属性即可生成双向绑定的表单,支持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",
      ]),
    };
  },
};

JSX生成的表单

右键菜单

物料组件添加后,在编辑模式下可以右键弹出菜单更改配置或删除等。右键菜单的实现来源与笔者开源的@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 模式

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

Fixed模式

交互弹窗 Popover

系统提供一种配置交互行为的功能,可以配置点击一个组件时弹窗另外一个组件,并配置组件弹出的方向。经过调研后发现Element-plusPopover并不太适合用于这种情况,因为弹出的组件时动态的。于是就自己封装了一个组件,不仅支持配置Popover的各个方向,还另外扩展了一个ScreenCenter的弹出,让组件可以在屏幕中间弹出(类似dialog)。

通过传入点击的元素、目标弹窗的宽高和弹窗方向,返回出目标弹窗的xy。核心代码如下:

/**
 * 获取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

不同方向的Popover

获取任意网站 Favicon

CollectionSearch组件中,都有用到一个功能,就是由用户输入网址后能自动获取到网站的 Favicon。在初版实现是直接使用网址 origin + /favicon.ico 获取,但经过大量尝试后发现,当前很多网站的 icon 并不是以这种标准形式存储的。所以后面就自己实现了一个后端接口来获取。

后端接口原理:

  1. 从用户输入的网站中读取到 origin
  2. 尝试从Redis中读取已缓存的图标路径,读取到则返回
  3. 若缓存中没有,这使用cheerio加载网站,使用$('link[rel*="icon"]').attr('href')读取图标路径
  4. 若上一步没有读取到,则继续尝试使用标准形式读取,即网站 Origin + /favicon.ico
  5. 读取成功则写入Redis缓存,否则返回获取失败

同时接口接收type参数,可由后端直接返回图片流,以解决一些网站的 ICON 资源做了 CORS 限制。因为在Collection组件中,为了减少初次访问请求加载数,前端读取到图标后会将图标转成 BASE64 格式存到本地存储中。这种方式需要使用 Ajax 获取图标,让接口直接返回文件流可以解决跨域问题。

另读取图标时,前端会使用 Canvas 通道法将图标的白色部分扣成透明,代码可参考此处

添加网站自动获取图标

总结

项目仍在持续优化开发中,欢迎各种建议。由于篇幅问题,部分使用到的技术会不定时更新记录。若感谢的可以持续关注、Star,谢谢。

相关链接

乳白
杏仁黄
茉莉黄
麦秆黄
油菜花黄
佛手黄
篾黄
葵扇黄
柠檬黄
金瓜黄
藤黄
酪黄
香水玫瑰黄
淡密黄
大豆黄
素馨黄
向日葵黄
雅梨黄
黄连黄
金盏黄
蛋壳黄
肉色
鹅掌黄
鸡蛋黄
鼬黄
榴萼黄
淡橘橙
枇杷黄
橙皮黄
北瓜黄
杏黄
雄黄
万寿菊黄
菊蕾白
秋葵黄
硫华黄
柚黄
芒果黄
蒿黄
姜黄
香蕉黄
草黄
新禾绿
月灰
淡灰绿
草灰绿
苔绿
碧螺春绿
燕羽灰
蟹壳灰
潭水绿
橄榄绿
蚌肉白
豆汁黄
淡茧黄
乳鸭黄
荔肉白
象牙黄
炒米黄
鹦鹉冠黄
木瓜黄
浅烙黄
莲子白
谷黄
栀子黄
芥黄
银鼠灰
尘灰
枯绿
鲛青
粽叶绿
灰绿
鹤灰
淡松烟
暗海水绿
棕榈绿
米色
淡肉色
麦芽糖黄
琥珀黄
甘草黄
初熟杏黄
浅驼色
沙石黄
虎皮黄
土黄
百灵鸟灰
山鸡黄
龟背黄
苍黄
莱阳梨黄
蜴蜊绿
松鼠灰
橄榄灰
蟹壳绿
古铜绿
焦茶绿
粉白
落英淡粉
瓜瓤粉
蜜黄
金叶黄
金莺黄
鹿角棕
凋叶棕
玳瑁黄
软木黄
风帆黄
桂皮淡棕
猴毛灰
山鸡褐
驼色
茶褐
古铜褐
荷花白
玫瑰粉
橘橙
美人焦橙
润红
淡桃红
海螺橙
桃红
颊红
淡罂粟红
晨曦红
蟹壳红
金莲花橙
草莓红
龙睛鱼红
蜻蜓红
大红
柿红
榴花红
银朱
朱红
鲑鱼红
金黄
鹿皮褐
醉瓜肉
麂棕
淡银灰
淡赭
槟榔综
银灰
海鸥灰
淡咖啡
岩石棕
芒果棕
石板灰
珠母灰
丁香棕
咖啡
筍皮棕
燕颔红
玉粉红
金驼
铁棕
蛛网灰
淡可可棕
中红灰
淡土黄
淡豆沙
椰壳棕
淡铁灰
中灰驼
淡栗棕
可可棕
柞叶棕
野蔷薇红
菠萝红
藕荷
陶瓷红
晓灰
余烬红
火砖红
火泥棕
绀红
橡树棕
海报灰
玫瑰灰
火山棕
豆沙
淡米粉
初桃粉红
介壳淡粉红
淡藏花红
瓜瓤红
芙蓉红
莓酱红
法螺红
落霞红
淡玫瑰灰
蟹蝥红
火岩棕
赭石
暗驼棕
酱棕
栗棕
洋水仙红
谷鞘红
苹果红
铁水红
桂红
极光红
粉红
舌红
曲红
红汞红
淡绯
无花果红
榴子红
胭脂红
合欢红
春梅红
香叶红
珊瑚红
萝卜红
淡茜红
艳红
淡菽红
鱼鳃红
樱桃红
淡蕊香红
石竹红
草茉莉红
茶花红
枸枢红
秋海棠红
丽春红
夕阳红
鹤顶红
鹅血石红
覆盆子红
貂紫
暗玉紫
栗紫
葡萄酱紫
牡丹粉红
山茶红
海棠红
玉红
高粱红
满江红
枣红
葡萄紫
酱紫
淡曙红
唐菖蒲红
鹅冠红
莓红
枫叶红
苋菜红
烟红
暗紫苑红
殷红
猪肝紫
金鱼紫
草珠红
淡绛红
品红
凤仙花红
粉团花红
夹竹桃红
榲桲红
姜红
莲瓣红
水红
报春红
月季红
豇豆红
霞光红
松叶牡丹红
喜蛋红
鼠鼻红
尖晶玉红
山黎豆红
锦葵红
鼠背灰
甘蔗紫
石竹紫
苍蝇灰
卵石紫
李紫
茄皮紫
吊钟花红
兔眼红
紫荆红
菜头紫
鹞冠紫
葡萄酒红
磨石紫
檀紫
火鹅紫
墨紫
晶红
扁豆花红
白芨红
嫩菱红
菠根红
酢酱草红
洋葱紫
海象紫
绀紫
古铜紫
石蕊红
芍药耕红
藏花红
初荷红
马鞭草紫
丁香淡紫
丹紫红
玫瑰红
淡牵牛紫
凤信紫
萝兰紫
玫瑰紫
藤萝紫
槿紫
蕈紫
桔梗紫
魏紫
芝兰紫
菱锰红
龙须红
蓟粉红
电气石红
樱草紫
芦穗灰
隐红灰
苋菜紫
芦灰
暮云灰
斑鸠灰
淡藤萝紫
淡青紫
青蛤壳紫
豆蔻紫
扁豆紫
芥花紫
青莲
芓紫
葛巾紫
牵牛紫
紫灰
龙睛鱼紫
荸荠紫
古鼎灰
乌梅紫
深牵牛紫
银白
芡食白
远山紫
淡蓝紫
山梗紫
螺甸紫
玛瑙灰
野菊紫
满天星紫
锌灰
野葡萄紫
剑锋紫
龙葵紫
暗龙胆紫
晶石紫
暗蓝紫
景泰蓝
尼罗蓝
远天蓝
星蓝
羽扇豆蓝
花青
睛蓝
虹蓝
湖水蓝
秋波蓝
涧石蓝
潮蓝
群青
霁青
碧青
宝石蓝
天蓝
柏林蓝
海青
钴蓝
鸢尾蓝
牵牛花蓝
飞燕草蓝
品蓝
银鱼白
安安蓝
鱼尾灰
鲸鱼灰
海参灰
沙鱼灰
钢蓝
云水蓝
晴山蓝
靛青
大理石灰
海涛蓝
蝶翅蓝
海军蓝
水牛灰
牛角灰
燕颔蓝
云峰白
井天蓝
云山蓝
釉蓝
鸥蓝
搪磁蓝
月影白
星灰
淡蓝灰
鷃蓝
嫩灰
战舰灰
瓦罐灰
青灰
鸽蓝
钢青
暗蓝
月白
海天蓝
清水蓝
瀑布蓝
蔚蓝
孔雀蓝
甸子蓝
石绿
竹篁绿
粉绿
美蝶绿
毛绿
蔻梢绿
麦苗绿
蛙绿
铜绿
竹绿
蓝绿
穹灰
翠蓝
胆矾蓝
樫鸟蓝
闪蓝
冰山蓝
虾壳青
晚波蓝
蜻蜓蓝
玉鈫蓝
垩灰
夏云灰
苍蓝
黄昏灰
灰蓝
深灰蓝
玉簪绿
青矾绿
草原远绿
梧枝绿
浪花绿
海王绿
亚丁绿
镍灰
明灰
淡绿灰
飞泉绿
狼烟灰
绿灰
苍绿
深海绿
长石灰
苷蓝绿
莽丛绿
淡翠绿
明绿
田园绿
翠绿
淡绿
葱绿
孔雀绿
艾绿
蟾绿
宫殿绿
松霜绿
蛋白石绿
薄荷绿
瓦松绿
荷叶绿
田螺绿
白屈菜绿
河豚灰
蒽油绿
槲寄生绿
云杉绿
嫩菊绿
艾背绿
嘉陵水绿
玉髓绿
鲜绿
宝石绿
海沬绿
姚黄
橄榄石绿
水绿
芦苇绿
槐花黄绿
苹果绿
芽绿
蝶黄
橄榄黄绿
鹦鹉绿
油绿
象牙白
汉白玉
雪白
鱼肚白
珍珠灰
浅灰
铅灰
中灰
瓦灰
夜灰
雁灰
深灰