你平时的开发状态是不是这样的:代码推到远端之后,要开 GitHub 网页看流水线跑没跑;服务器负载高了,要临时登录面板看监控;本地服务一多,端口和进程自己都记不清。浏览器里挂着十几个标签页,终端窗口叠了好几层,信息不是不够,而是散得太碎。所以我想自己攒一个全栈项目,最终产物就是一块跑在桌面上的仪表盘,叫它 Status Deck。
这个项目定位很明确:一个给开发者用的桌面仪表盘,把仓库动态、CI/CD 状态、服务器指标、本地开发环境这些零散信息,统一聚合到一个桌面上随时能看的界面里。方案上我选了 vue + golang + uniapp 这套组合,桌面端用 Tauri 做壳子,后端本地跑一个 Go 聚合服务,移动端用 uniapp 做只读延伸。整条链路全部自己实现,从数据采集、接口设计、界面渲染到打包分发,一站到底,这也是“全栈自造”四个字的真正含义。
这篇是系列第一篇,重点讲整体设计、技术选型、核心数据结构,以及一路做下来我最想让你避开的那些坑。适合刚学完前端想找完整练手项目的同学,也适合已经写过不少业务代码、想给自己做点私有工具的开发者。
1. 为什么自造一块开发者桌面仪表盘(而不是继续买监控工具)
1.1 信息碎片化到忍无可忍,才是第一驱动力
说实话,市面上现成的监控看板并不少。Grafana、Datadog、Uptime Kuma、GitHub官方通知页,随便挑一个都能用。但真放到日常开发的场景里,你会发现这些工具天然有一个共同问题:我是去“配合它们”的,而不是它们来配合我。
举个例子,Grafana 是专业,但想在里面加一个“今天有没有人给我提了新 PR”的卡片,得搭数据源、写查询、配告警,整个过程重得离谱。SaaS 监控面板倒是轻量,但数据都在别人服务器上,卡片长什么样、哪些指标在前台展示、刷新频率多少,你说了不算。桌面小组件生态更尴尬,大部分是天气、日程、TODO,没有哪个是面向开发工作流的。
我对 Status Deck 的核心诉求只有三个:数据是私有的、卡片是我自己定义的、打开就能看见不需要切换几十个页面。“私有”意味着我可以放心把 Git 仓库的 API Token 和本机系统指标都交给它,反正不出我这台设备;“自定义”意味着我今天想看重构产线进度,明天想看磁盘空间,都可以按需加卡片。说白了,它不是一个“数据中心”,而是我的开发状态聚合器。
1.2 全栈自造的路线图:从需求拆解到模块分层
立项的时候,我给项目划了四层结构:
- 采集层:负责从各种数据源拿原始数据。比如从 GitHub API 拉 PR 列表,从系统接口读 CPU 和内存,从本地日志文件读构建结果。
- 聚合层:把采集到的数据统一清洗、归类,存进内存里的状态中心,并提供统一的 HTTP API 给前端调用。
- 展示层:桌面上跑的 UI,负责渲染状态卡片、定时拉取最新数据、展示刷新状态和错误提示。
- 延伸层:手机端只读展示。不在同一个局域网里看不到,这是刻意为之,因为我只需要“扫一眼”的需求,不需要完整的远程访问能力。
模块一拆开,整个项目的思路就清晰了:能最快跑出闭环的是“采集一小部分数据 + 聚合接口 + 一张卡片”。所以我第一版没有贪多,只做了本地系统指标和 GitHub 动态两组数据,先把“一条真实状态从数据源走到桌面卡片”的路打通,再谈扩展。这也是我自己做项目的一个原则:先把最短链路跑通,否则越到后面坑越多,心态也容易崩。
2. 技术方案定型:vue + golang + Tauri 这套组合是怎么定下来的
2.1 桌面壳体对比:Tauri 和 Electron,我为什么选了前者
桌面仪表盘必须有个桌面壳,否则就只是个网页。当时我认真对比了 Electron 和 Tauri。Electron 成熟,打包之后的体积也“成熟”,一个最小应用装上依赖动不动 80MB 往上,内存占用随随便便几百 MB,对一个常驻后台的仪表盘来说太重了。Tauri 用系统自带的 WebView 渲染页面,后端是 Rust,打包出来通常只有几 MB 到十几 MB,空闲内存占用也低很多。
我做了一个简单对比表,当时就是按这张表做的决定:
| 对比项 | Tauri | Electron |
|---|---|---|
| 安装包体积 | 一般 3~15MB | 通常 60~150MB |
| 空闲内存占用 | 实测大约 80~150MB(取决于页面) | 通常 200MB 起 |
| 跨平台支持 | Windows/macOS/Linux 都可以 | 同样可以,生态更久 |
| 前端技术栈 | 任意 Web 技术 | 任意 Web 技术 |
| 后端语言 | Rust | Node.js |
| 生态成熟度 | 相对年轻,插件少 | 非常丰富 |
| 适合场景 | 个人工具、轻量常驻应用 | 大型桌面应用 |
Tauri 唯一让我犹豫的是 Rust。但仔细想想,我的核心逻辑其实是放在 Go 聚合服务里的,Rust 只负责拉起应用、管理窗口、执行系统命令,这些场景用到的 Rust 语法并不复杂。反过来说,Electron 的 Node 后端如果拿来跟 Go 抢资源,倒是我更不想看到的结果。
2.2 为什么是 Golang 做本地聚合服务
桌面壳之外,我单独设计了一个常驻的本地聚合服务,用 Go 写。为什么不直接用 Rust 写?因为我不想把采集逻辑和界面进程耦合死。Go 服务是独立进程,单独测试、单独编译、单独发布,将来不做桌面端了,直接把服务丢到服务器上也照样跑,通用性会更好。
选 Go 还有三个非常实际的原因:
- 编译产物是单二进制文件,不存在运行时依赖。Windows 上丢一个 exe 就能跑,macOS 上 chmod +x 就能执行。
- 并发模型天然适合做采集器。GitHub、服务器指标、日历提醒,每个数据源的刷新周期都不一样,用
time.Ticker加上 goroutine 就能优雅处理,不需要引入复杂的定时框架。 - 交叉编译顺手。
GOOS=darwin go build && GOOS=windows go build几秒钟能出两个平台的文件,对个人开发来说体验极好。
有的朋友可能会说,我用 Node 脚本或者 Python 也能实现定时轮询。确实能,但 Node 没有 Go 的静态编译体验,Python 分发的时候还要处理好解释器和依赖。反正项目都已经走全栈了,后端选一个真正“省心”的语言,比选一个“熟悉”的语言更重要。
2.3 Vue3 + uniapp 的组合:不是炫技,是复用成本最低的方案
前端我选了 Vue3 的组合式 API,原因特别简单:状态卡片的 UI 天然适合组件化,而 Vue3 的setup语法写自定义 hooks 非常顺手,比如useStatusCards()这种拉取逻辑和数据状态绑定,比选项式 API 更容易组织。
移动端我选了 uniapp,但只说清楚一个前提:它在项目里只做“只读延伸”,不做任何业务操作。同一个局域网下,用 uniapp 编译出来的 App 打开后直接请求 Go 聚合服务的 API,看板样式尽量跟桌面端保持一致。至于为什么不写原生 iOS/Android 应用,因为个人开发者维护两套原生代码的时间成本确实不值得,uniapp 的跨端能力对我来说刚好够用。
整套技术栈最终定下来是:Vue3 负责界面,Go 负责聚合,Tauri 负责把两者变成一个桌面应用,uniapp 负责移动端只读入口。没有一个是冷门技术,也没有一个是堆上去炫技的,每个选择背后都对应一个实际需求。
3. 核心设计:状态卡片与数据聚合的底层结构
3.1 一切皆卡片:Status Deck 的核心抽象模型
Status Deck 界面上看到的一切,都是“状态卡片”这种模型。打开仪表盘,你看到的是一组大小不一、排列整齐的卡片,每一张卡片代表一类状态信息,比如github-pr、server-metrics、build-status。我给自己定了一条规则:一切皆卡片,新数据源来了就注册一张新卡片,旧数据源不需要了就把配置项关掉,UI 上自动消失。
卡片的数据结构我设计成了这样:
{ "id": "github-pr", "title": "GitHub PR 动态", "source": "github", "refreshInterval": 300, "display": { "type": "list", "maxItems": 8 }, "actions": [ { "label": "打开 GitHub", "url": "https://github.com/pulls" } ] }source字段指向聚合器里对应的数据源,refreshInterval是刷新周期(秒),display决定这张卡片长什么样。为什么不用硬编码组件、而是用配置驱动卡片?因为配置驱动的收益太明显了:要新增一个数据源时,不需要改前端组件,只要在配置中心加一条卡片描述,前端就能自然渲染出来。这个设计做好之后,整个仪表盘的横向扩展成本变得很低。
3.2 首批卡片清单与采集方式
第一版我做了四张卡片,对应四种最常见的开发状态:
| 卡片 id | 数据来源 | 采集方式 | 刷新策略 |
|---|---|---|---|
| github-pr | GitHub REST API | 应用内 HTTP 请求 | 5 分钟轮询 |
| server-metrics | 本机系统指标 | Go 读取 syscall 接口 | 2 秒轮询 |
| build-status | CI 构建状态 | Webhook 推送到本地端口 | 事件触发 |
| calendar-remind | 本地日历文件 | 解析 ICS 文件 | 10 分钟轮询 |
这里想多说一句选型中的细节。CI 构建状态我没有用轮询,而是让 CI 在构建完成时把结果推送到本地端口,这种“事件驱动”的方式在等待构建的时候体验极好:构建一结束,卡片立刻变绿或者变红,不用等一个轮询周期。本地服务器指标则完全相反,它就是需要高频率展示的实时数据,2 秒一轮很舒服。典型的混合采集策略:能推送的数据源就推送,不能推送的就按重要程度设定合理的轮询周期。
3.3 统一状态模型与刷新策略设计
聚合层输出的数据不能各卡片各写各的,必须统一。我定义了一个StatusEvent结构,任何数据源最终都汇总成这个结构:
type StatusEvent struct { Source string `json:"source"` Type string `json:"type"` Level string `json:"level"` Message string `json:"message"` Detail string `json:"detail,omitempty"` TS int64 `json:"ts"` }Level是重点字段,取值有info、success、warning、error,前端拿到之后能直接决定卡片边框和状态灯的颜色。这套模型的好处是:前端渲染逻辑非常简单,它只认StatusEvent,不关心背后的数据源是 GitHub 还是系统指标。数据源那边想加什么字段,就往Detail塞 JSON 字符串,不影响核心展示链路。
刷新策略我总结成了一个经验表:
| 数据源类型 | 建议刷新周期 | 原因 |
|---|---|---|
| 本地系统指标 | 1~3 秒 | 实时性强,开销小 |
| 代码仓库动态 | 3~5 分钟 | 更新频率低,避免 API 限流 |
| CI/CD 状态 | Webhook 优先 | 事件驱动,无需轮询等待 |
| 日历提醒 | 10~15 分钟 | 对时间不敏感,低频足够 |
这个小表格看上去不起眼,但它真的是我做了多版之后才打磨出来的。最开始我把 GitHub 也设成 10 秒一轮,结果账号差点被 API 限流,教训非常深刻。
4. 实操记录:后端聚合、前端渲染与桌面壳整合的关键实现
4.1 Go 聚合服务的骨架:并发采集与内存状态中心
首先建一个标准的 Go 项目,入口文件是main.go,里面注册路由并启动采集器。采集器是并发模型,每个数据源一个 goroutine,各自拥有独立的time.Ticker,互不干扰:
func main() { state := NewStateCenter() go StartGithubCollector(state) // 5分钟一轮 go StartSystemCollector(state) // 2秒一轮 go StartWebhookListener(state) // 事件驱动 http.HandleFunc("/api/cards", func(w http.ResponseWriter, r *http.Request) { data := state.Snapshot() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(data) }) http.ListenAndServe("127.0.0.1:16888", nil) }StateCenter是全局内存状态中心,底层就是一个sync.RWMutex保护的map[string]StatusEvent。采集器往里面写数据,API 读快照。这里没有用 Redis 或者消息队列,因为本地单机的状态聚合,内存 Map 完全够用,而且没有额外依赖,部署时省心。
写这段代码的时候有一个细节要注意:并发采集时,多个 goroutine 同时写同一个 Map 会直接 panic。所以所有写入必须经过锁保护,读取快照用RLock,写入用Lock。
4.2 前端渲染与状态拉取:Vue3 的组合式 API 写法
前端侧我创建了一个组合式函数useStatusCards,统一管理卡片数据拉取和自动刷新:
import { ref, onMounted, onUnmounted } from 'vue' export function useStatusCards() { const cards = ref([]) const loading = ref(true) const lastUpdated = ref(0) let timer: number | undefined async function fetchCards() { try { const res = await fetch('http://127.0.0.1:16888/api/cards') cards.value = await res.json() lastUpdated.value = Date.now() } finally { loading.value = false } } onMounted(() => { fetchCards() timer = window.setInterval(fetchCards, 15000) }) onUnmounted(() => clearInterval(timer)) return { cards, loading, lastUpdated, refresh: fetchCards } }这里把轮询周期设成 15 秒,是综合考虑了“界面时效性”和“本地服务压力”之后的结果。15 秒刷新一次看板,对人工观察来说足够灵敏,对 Go 服务来说也完全没有压力。卡片组件本身用DeckCard.vue封装,接收一个StatusEvent类型的 prop,内部根据level决定颜色和图标样式,实现逻辑非常直白。
4.3 Tauri 桌面壳整合:生命周期与本地端口访问
Tauri 这边的整合,最关键的是生命周期管理:应用启动时要拉起 Go 服务进程,应用退出时必须保证 Go 进程也一起退出,否则端口残留会越来越混乱。我在 Tauri 的 Rust 侧用了tauri::Manager,在setup钩子里通过std::process::Command启动 Go 编译出的二进制文件,并保存子进程句柄;在退出钩子里child.kill()收尾。
tauri.conf.json里有两个关键项:
{ "build": { "beforeDevCommand": "npm run dev", "beforeBuildCommand": "npm run build" }, "mainBinaryName": "status-deck" }前端请求地址统一写http://127.0.0.1:16888/api/cards,不经过 Tauri 的 IPC,直接走 HTTP 请求,这样前端代码可以完全脱离 Tauri 单独在浏览器里调试,开发体验很关键。实测下来,Tauri 打包出来的安装包只有 8.6MB,安装后空闲内存占用大约 110MB,在桌面仪表盘这个定位上可以说是非常轻了。
4.4 uniapp 只读端延伸:同一套 API 的简单复用
移动端思路非常直白:uniapp 的uni.request直接请求局域网内的 Go 服务地址。因为 Go 服务默认只监听127.0.0.1,手机访问的时候需要改绑到0.0.0.0并开放防火墙端口。这个“局域网内只读展示”的定位,让我不用考虑鉴权、HTTPS、多用户这些复杂问题,只需做好只读看板。
页面布局我几乎照搬了桌面端卡片的排列方式,uniapp 的view组件栈很快就能铺出来。真正有用的经验是:H5 端调试会碰到跨域问题,需要给 Go 服务加 CORS 头;但打包成 App 之后uni.request没有跨域限制,反而是最省事的方式。
5. 踩坑实录:定位问题、排查思路与避坑清单
5.1 让人原地崩溃的几个真实问题
做这个项目的时间,大半花在这些破事上。我把它们整理成一张速查表,你大概率也会碰到:
| 问题现象 | 表面原因 | 真实根因 | 我的解决办法 |
|---|---|---|---|
| 卡片突然停止刷新 | 前端定时器看起来还在跑 | Go 服务崩溃或者端口被占用 | 给 Go 进程加recover,前端加错误提示和自动重连 |
| GitHub Token 失效 | 代码没改,数据就是拉不到 | Token 过期,无提示地返回 401 | 加 Token 的过期检查,在任何接口返回 401 时写日志 |
| Tauri 环境变量读不到 | Go 服务能跑,但配置为空 | Tauri 启动子进程时没有继承环境变量 | 改为把配置写进本地配置文件,启动时显式读取 |
| 杀不干净的残留进程 | 重启应用后端口被占用 | 退出时没有真正 kill 子进程 | 在 Tauri 的退出钩子里强制child.kill() |
| uniapp 手机连不上桌面服务 | 手机和电脑同一 WiFi 却请求失败 | 端口没有监听局域网地址 | 将监听地址改成0.0.0.0并放行防火墙 |
这里面最坑的是 Tauri 环境变量。我一开始把 GitHub Token 写在.env文件里,在终端里跑 Go 服务好好的,但被 Tauri 拉起之后就读不到环境变量。最后统一改成配置文件方案才彻底解决。现在我的原则是:桌面壳进程和业务服务之间尽量少传递环境变量,路径配置都走本地文件。
5.2 轮询频率、API 限流与数据隐私边界
开发者工具最容易忽略的边界就是别人的 API 限流。GitHub API 未认证的请求一小时只有 60 次,就算认证了,一小时也是 5000 次,看着多,你要是每 10 秒拉一次,半天就没了。我的实测经验是:仓库动态类数据 5 分钟一轮,配合条件判断只在有变化时更新卡片,完全够用。
数据隐私方面我的原则很简单:所有采集的数据只停留在本机内存和本地文件里,不上传任何云服务,不接入任何第三方分析平台。因为 Status Deck 本身就是给个人开发者用的私有工具,没必要也不应该把数据送到外面。后续如果要加多设备同步,我会优先考虑自建的、可控的服务节点,而不是直接依赖现成云服务。
5.3 编译链与跨平台打包的实战经验
Tauri 的编译链依赖 Rust,首次编译时cargo build会拉很多依赖包,时间长短取决于网络环境。这里给我的经验不是怎么加速,而是“先把依赖下好再写代码”:直接cargo build一次,让依赖在后台慢慢拉,这期间去写 Go 服务和前端页面,两边并行效率更高。
跨平台打包我踩过最痛的坑是:Tauri 不支持跨平台交叉编译出完整安装包,Windows 的安装包必须在 Windows 上构建,macOS 的也必须在 macOS 上构建。所以我的做法是准备两台构建机,或者用 GitHub Actions 的矩阵构建来同时产出多平台产物。macOS 上首次运行还会遇到“无法验证开发者”之类的提示,这是因为没有做签名,属于个人分发工具的正常现象,右键打开即可,不属于功能问题。
6. 下一步玩什么:AI 总结、迭代方向与项目体会
6.1 把 AI 引进来:本地模型做构建失败原因聚类
桌面仪表盘如果能在一堆状态里直接给出“今天最值得关注的一句话”,会比罗列几十条原始状态更有价值。我计划在下一版引入本地 AI 模型:把当天的构建日志、测试失败信息、错误堆栈做摘要聚类,然后生成一条总览性结论,比如“今天有 3 次构建失败,主要都是前端依赖版本不匹配引起的”。
为什么优先考虑本地模型而不是在线 API?因为 Status Deck 的定位就是私有数据不出设备,项目的核心原则是所有数据都留在本机。本地模型在个人电脑上的运行速度足够做离线摘要分析,更重要的是保持整个工具链的自我掌控。我会把这块设计成可插拔模块,核心聚合流程不依赖 AI,AI 只是附加增强能力。
6.2 后续迭代计划
目前的版本跑通之后,我给自己列了一个迭代清单:
- 增加多设备状态汇聚:把服务器采集改成 agent 模式,每台机器上报给聚合服务,看板上一屏管所有机器。
- 统一 Webhook 接入:Jenkins、GitLab CI、自建脚本的构建结果都能推到本地端口,不再依赖各家平台差异。
- 让卡片支持自定义布局:拖动排序、修改尺寸、调刷新频率,这些配置通过设置面板直接操作。
- 细化通知规则:对 error 级状态做桌面通知和声音提醒,不用一直盯着看板。
优先级上我会先做“多设备状态汇聚”,因为这是把个人工具变成团队小工具的关键一步。卡片布局倒是可以慢慢来,毕竟自己用的东西,现在的固定布局也能接受。
6.3 尝试过的才敢说的体会
这个小项目做到现在,我最大的感受不是技术栈有多新,而是“掌控感”这个东西真的很重要。用别人的看板工具,你永远在等别人给你想要的效果;自己写一个,哪怕功能简陋,但是每一行代码都是按你的习惯长的,用起来就是顺手。
如果你也想做一个自己的全栈项目,我给的建议是:别一开始就把什么功能都想全,先做一条最小闭环,比如“本机 CPU 状态能显示在桌面卡片上”,然后一步一步加新卡片、加数据源、加移动端。项目做到后面你会发现,它最大的价值不是省了那点订阅费,而是让你把数据流、界面层、打包发布、跨端适配这些环节真真切切地过了一遍手。第二期我准备具体写 Go 聚合服务的代码结构和采集器设计,到时候会把接口定义、并发安全的实现细节,以及 Tauri 生命周期管理的完整代码都放出来。