浏览器书签千千万,找起来像大海捞针。不如自己动手,造一个专属的数字小窝。

最近花了些时间折腾了一个书签导航系统,从零到一的过程还挺有意思,分享一下成果和心得。

缘起:为什么要造这个轮子?

说实话,浏览器自带的书签功能已经用了很多年,但痛点一直存在:

  • 查找困难:几百个书签堆在一起,想找个网站比翻抽屉还费劲
  • 颜值堪忧:灰不溜秋的列表界面,毫无打开的欲望
  • 功能单一:除了存链接,啥也干不了

至于市面上的第三方导航站?广告比内容还多,不是我的菜。

既然找不到趁手的,那就自己写一个,顺便把最近学的 Vue 3 和 TypeScript 练练手。

最终效果

先来看看成品长什么样(此处应有截图)。

核心功能一览:

功能 描述
书签管理 分类整理,支持拖拽排序,告别混乱
翻转时钟 复古机械风,每一秒都是仪式感
农历日历 节气、节日、黄历,做个有文化的程序员
Live2D 看板娘 二次元老婆,治愈加成 +100
AI 助手 接入 Gemini,有问题直接问
汇率转换 实时汇率,海淘党必备

整体采用毛玻璃风格设计,简约但不简单,看着挺舒服的。

技术选型

1
2
3
4
5
6
前端框架:Vue 3 + TypeScript
构建工具:Vite
状态管理:Pinia
后端服务:Express
数据存储:SQLite
特效加持:oh-my-live2d

为什么选 SQLite?

一个字:轻。单文件数据库,不用装 MySQL,不用配 Redis,部署的时候往服务器一扔就完事。个人项目用这个完全够了,别过度设计。

核心功能实现

1. 书签拖拽排序

用户体验的关键点之一。采用 vuedraggable,它是 Sortable.js 的 Vue 封装,对 Vue 3 的支持相当友好。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template>
<draggable
v-model="bookmarks"
item-key="id"
@end="onDragEnd"
animation="200"
ghost-class="ghost"
>
<template #item="{ element }">
<BookmarkItem :bookmark="element" />
</template>
</draggable>
</template>

<script setup lang="ts">
const onDragEnd = async () => {
// 把新顺序同步到后端
await updateBookmarkOrder(bookmarks.value.map(b => b.id))
}
</script>

加个 ghost-class 给拖拽中的元素一点视觉反馈,体验会好很多。

2. 翻转时钟

这个是整个项目里最有意思的部分。纯 CSS 实现 3D 翻页效果,核心是 transform-style: preserve-3d 配合 rotateX 旋转。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.flip-card {
position: relative;
transform-style: preserve-3d;
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}

.flip-card.flipping {
transform: rotateX(-180deg);
}

.flip-card-front,
.flip-card-back {
position: absolute;
backface-visibility: hidden;
}

.flip-card-back {
transform: rotateX(180deg);
}

每秒检查数字是否变化,变了就触发翻转动画。那种机械翻页的感觉,复古又优雅。

3. 农历日历

国人嘛,总得有点传统文化。用了 lunar-javascript 这个库,功能非常全面:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { Lunar } from 'lunar-javascript'

const lunar = Lunar.fromDate(new Date())

// 基本信息
console.log(lunar.getYearInChinese()) // 二〇二六
console.log(lunar.getMonthInChinese()) // 腊月
console.log(lunar.getDayInChinese()) // 十七

// 进阶玩法
console.log(lunar.getJieQi()) // 节气(如果有的话)
console.log(lunar.getYearShengXiao()) // 马(生肖)
console.log(lunar.getYearInGanZhi()) // 丙午(干支纪年)

节假日、节气、生肖、干支、宜忌,全都能算。过年的时候在页面上显示”距离除夕还有 X 天”,仪式感拉满。

4. Live2D 看板娘

二次元浓度必须拉满。oh-my-live2d 封装得相当好,几行代码就能集成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { loadOml2d } from 'oh-my-live2d'

loadOml2d({
models: [
{
path: '/assets/live2d/models/mai/mai.model3.json',
position: [-50, 50],
scale: 0.08,
stageStyle: {
height: 300
}
}
],
tips: {
idleTips: {
wordTheDay: true // 每日一言
}
}
})

点击会有互动表情,还能配置定时消息,感觉像多了个小助手。

踩坑实录

开发过程不可能一帆风顺,记录几个印象深刻的坑:

坑一:Vite 打包 SQLite

sql.js 需要加载 wasm 文件,但 Vite 默认不处理这玩意。一开始各种报错,查了半天文档才搞明白。

解决方案:把 sql-wasm.wasm 放到 public 目录,运行时动态加载:

1
2
3
const SQL = await initSqlJs({
locateFile: file => `/wasm/${file}`
})

坑二:Live2D 移动端翻车

模型文件动辄几 MB,移动端加载慢得像蜗牛,还贼占内存。有些低端机直接卡死。

解决方案:响应式判断,移动端直接不加载,省心省力:

1
2
3
4
const isMobile = window.innerWidth < 768
if (!isMobile) {
loadOml2d({ /* ... */ })
}

坑三:拖拽和点击打架

书签卡片既要能点击打开链接,又要能拖拽排序。两个事件老是冲突。

解决方案:计算 mousedown 到 mouseup 的位移距离,小于阈值算点击,大于算拖拽:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let startPos = { x: 0, y: 0 }

onMouseDown(e) {
startPos = { x: e.clientX, y: e.clientY }
}

onMouseUp(e) {
const distance = Math.hypot(
e.clientX - startPos.x,
e.clientY - startPos.y
)
if (distance < 5) {
// 这是点击,打开链接
window.open(bookmark.url)
}
// 否则是拖拽,vuedraggable 会处理
}

部署上线

前后端分离部署,结构清晰:

1
2
3
4
5
# 前端构建
npm run build

# 后端启动
cd server && node index.js

Nginx 配置要点:

1
2
3
4
5
6
7
8
9
10
# 前端静态文件
location / {
root /var/www/bookmark/dist;
try_files $uri $uri/ /index.html;
}

# 后端 API 反代
location /api {
proxy_pass http://127.0.0.1:3000;
}

当然也可以用 Docker 一把梭,看个人喜好。

写在最后

整体开发下来,几点感受:

  1. Vue 3 Composition API 写起来确实舒服,逻辑复用比 Options API 优雅太多
  2. TypeScript 的类型提示在重构时帮了大忙,尤其是改接口的时候
  3. 造轮子是最好的学习方式,看十遍文档不如写一个项目

如果你也受够了浏览器书签的简陋,或者单纯想练手,可以参考这个项目的思路。核心就是:书签 CRUD + 用户认证 + 一些有趣的小组件

剩下的,就是发挥想象力了。


有问题欢迎留言交流,一起学习进步。