Status Deck:用Tauri+Vue3+Go打造桌面工作状态聚合仪表盘
1. 项目概述1.1 为什么要自造一个Status Deck先说我自己的处境。我手头长期维护着三个业务线的小程序、两个公司内部中台、一个开源组件库以及一堆散落的个人实验项目。每天早上一坐到工位第一件事就是打开一堆标签页GitLab看两个仓库的MR和Pipeline状态Jira看今天的迭代任务Grafana看生产环境的报错趋势再顺手翻一翻微信里测试同学给我留的bug反馈。等到把这些全部看一遍半小时已经没了而且信息还是割裂的——没有一个地方告诉我今天最该处理的是什么。这就是我做Status Deck的动机简单说就一句话把散落在各个平台的关键信息聚合到一个常驻桌面的仪表盘上。它不是一个办公OA看板也不是一个数据中台的可视化项目更不是去重新造一个Jira轮子而是面向开发者个人视角的工作状态总览。它能告诉我哪些CI流水线还挂着、是因为什么挂的今天真正需要我出手的代码评审有几条线上日志里有没有新出现的ERROR级别关键报错我负责的模块在最近一轮测试里暴露了什么问题今天还有哪些节点的缺陷单没动过。如果说项目是产品的话那Status Deck就是我的驾驶舱。对读者来说这个项目的最大价值在于它不是传统意义上的教学demo而是一个可以真正塞进日常开发流程里的全栈作品既有前端界面打磨又有后端采集调度还牵扯到桌面端容器选型、本地存储、WebSocket长连接、甚至后面接入AI总结。无论你是在前端转全栈的路上想找项目练手还是已经带团队想做个内部效率工具它都非常有参考意义。1.2 这个项目解决的核心痛点先聊痛点不然一切都是自嗨。开发者日常的信息来源是高度碎片化的。公司用Jira代码托管在GitLab发布看Jenkins日志找Kibana用户反馈在钉钉群需求文档在语雀或者Confluence……每一个平台都有自己的待办状态但这些状态之间没有联动。我经常在一种体验中来回拉扯Jira上把任务置为进行中但对应的代码分支还没提交CI挂了半天没人管因为大家默认应该有别的同学在看一个P0线上报障从钉群里刷过去了看一眼就忘。另一个痛点更隐蔽这些平台的推送机制并不适合开发者。Jira的一天十封邮件提醒我看都不看IM群里刷屏式的告警机器人重要信息早被冲走了。开发者需要的是当我有空看一眼时一眼就能看到重点而不是不断的被打断。这个逻辑决定了Status Deck的核心形态它是拉取聚合重排不是推送打扰。还有一层原因和团队协作没关系纯粹从个人技术成长角度。我想做一个全栈自造的项目不是为了秀技术而是为了把所有环节都亲手摸一遍前端是Vue 3 TypeScript主要为了把桌面端交互细节做透桌面容器我在Tauri和Electron之间做了对比后端采集用Go出于性能、交叉编译和部署体积的考虑通信层用WebSocket做实时刷新本地文件存储和SQLite结合保证断网时也能看到历史快照。做完一期的体会是这种自造工具的方式比看任何教程都更能逼你把全链条跑通。接下来我把具体的思路、选型、踩坑过程完整拆出来希望给正在规划全栈项目或者想做个效率工具的同学一个可参考的样本。2. 设计拆解与技术选型逻辑2.1 为什么是Tauri而不是Electron先说桌面容器的选择。很多人一看到桌面仪表盘第一反应就是Electron毕竟生态成熟、资料多。但我在这个项目里最终用的是Tauri理由不只是新一代更酷而是几个实际诉求叠加在一起的结果。第一是内存占用。Status Deck的定位是常驻桌面要开机自启、挂在系统托盘里、平时以很小的窗口悬浮在屏幕角落。Electron动不动几百MB内存对一台还要同时跑IDE、浏览器、本地服务、容器环境的开发机来说太奢侈了。Tauri用的是系统WebView内核不打包浏览器安装包只有几MB运行时内存占用可以控制在几十MB级别。实测下来体感非常明显开一整天也不会有风扇猛转的负担。第二是后端进程的归属问题。Status Deck的核心是数据采集而采集逻辑我放在Go服务里。Tauri的优势在于它可以很干净地和本地的Go进程做进程通信主进程用Rust写轻量、可靠适合做窗口管理和系统托盘。而Electron的Node.js事件循环本身就很繁忙再塞采集逻辑代码层级容易乱。第三是跨平台编译体验。我主力开发机是Windows但偶尔需要在macOS上看效果。Tauri配合GitHub Actions做三平台构建很顺Rust的交叉编译虽然要提前准备target但整体链路清晰。Electron做多平台打包也不是不行但产物体积和CI耗时始终是个绕不开的短板。当然Tauri也不是没有代价。它的WebView内核在各操作系统上表现有细微差异比如Windows上WebView2对CSS backdrop-filter的渲染就和macOS的WKWebView不完全一致Rust部分的学习曲线也摆在那里好在我只需要做窗口、托盘、自定义协议转发这些基础能力没有一个星期就上手了。我最终敲定的架构是这样的桌面壳Tauri 2.x负责窗口、系统托盘、开机自启、自定义协议前端Vue 3 TypeScript Pinia Vite负责仪表盘界面渲染和交互后端采集服务Go监听本地端口轮询各个数据源接口解析、整合、推送同时服务一个SQLite数据库做本地存储通信前端通过HTTP获取一次全量快照然后通过WebSocket订阅增量更新数据源第一版接入了GitLab、Jira、Jenkins、本地的日志文件扫描器。2.2 Go采集端的作用边界一开始我纠结过一个问题采集逻辑能不能直接放在前端或者Tauri的Rust进程里后来实践下来发现还是得有一个独立的Go进程。原因主要有三个。首先是隔离性。数据源的Token、密钥如果存在前端的localStorage或者Tauri的配置里风险比较大独立Go服务可以把密钥放在系统级配置目录中并做权限收窄前端完全接触不到。其次是调度稳定性。Go的goroutine和定时常驻非常适合做轮询采集器比如每30秒拉一次GitLab流水线状态、每1分钟拉一次Jira活跃迭代、每5分钟扫一次日志目录这些定时任务放在前端里会被标签页休眠机制打断放在Rust进程里开发成本又偏高。第三是数据格式处理效率。从JSON API取回来的原始数据非常杂乱Jira的字段结构、GitLab事件结构都带着大量无关信息Go里做字段清洗、归一化、去重、落库性能好代码也更直观。那Go服务具体做什么我给它定的范围是采集、归一化、存储、推流。采集负责按照配置的时间间隔主动请求外部API归一化把不同来源的数据变成统一的卡片结构存储负责写SQLite以及按时间窗口做历史归档推流则是把更新后的卡片状态通过WebSocket发给前端。它还负责启动时扫描本地日志目录把错误日志抽成事件流——这个能力最实用因为有些本地启动的服务日志非常长靠肉眼盯根本看不过来。我不想把Go进程做得太复杂。它不负责做事后统计不做鉴权不暴露多余端口只监听127.0.0.1上的一个随机端口并通过Tauri启动时注入的密钥做简单握手。前端UI和后端服务之间是纯粹的订阅/推送关系保证每一层职责单一。2.3 数据模型与卡片结构设计写Status Deck最核心的一个设计决策是定义一套统一的状态卡片模型。数据源千差万别但落到仪表盘上其实都可以抽象成一张卡片包含以下字段字段类型说明idstring全局唯一ID由数据源类型原始ID哈希生成sourceenumgitlab / jira / jenkins / local_log / manualtypeenumpipeline / merge_request / issue / alert / task / notetitlestring卡片主标题如商户后台登录接口超时statusenumpending / running / failed / success / waring / resolvedpriorityint0-3由规则引擎计算得出contentobject原始数据的精简映射如MR的源分支、目标分支updated_atdatetime最后一次状态变化时间tagsarray用于视图分组如核心链路/支付模块/高优有了这层抽象前端渲染就变得相当统一所有卡片都走同一个卡片组件只是根据source和type来决定辅助展示。比如GitLab的水管卡显示流水线阶段和耗时Jira的卡显示经办人和优先级标识Jenkins卡显示构建按钮的状态。后续如果想新增一个数据源只需要在Go里写一个adapter把数据源API返回的原始结构映射成上面的卡片结构前端一行都不用改。这里有一个容易被忽略的坑同一个事物在不同数据源里可能被当成不同的卡片。比如一个MR关联的CI流水线在GitLab那边是一条流水线事件在Jenkins那边是一条构建任务在Jira里还有一个对应的代码审查任务。如果不做关联归并仪表盘上就会出现三张割裂的卡。我一开始也踩了这个坑后来在卡片模型里增加了一个attributetrace_id用于把同一件事的多个状态源绑定到一起。归并逻辑用的还是最朴素的时间窗口标题关键词匹配先以规则为主二期再考虑引入AI语义归并。2.4 前端视图层为什么要做抽屉式布局Status Deck的使用场景是常驻桌面、随时扫一眼所以界面布局我做成三栏抽屉式左侧是数据源导航中间是待处理队列视图右侧是详情抽屉点击卡片后滑出。这样的布局最贴近开发者的工作流常态下只看中间待处理队列有卡片需要展开时右侧抽屉同步展示上下文信息。前端状态管理用Pinia因为卡片的状态流转非常频繁从running到failed、从pending到resolved状态机是明确的Pinia的store结构很方便做这种约束。为此我定义了状态机转换表待处理状态可以进入运行中、已解决、失败运行中状态可以进入失败、成功、已取消失败状态可以进入重新运行进而回到运行中已解决状态不可逆除非新建卡片。这些约束直接放在store的action里不允许组件直接改state避免界面逻辑失控。后端推送来的每条增量事件都带上卡片完整状态前端按id合并保证一致性。3. 实操走查从零开始搭建3.1 初始化整体工程结构这个项目我用了pnpm workspace管理因为同时涉及Tauri主工程、Vue前端和Go服务前端部分拆成多个包会更清晰。目录结构长这样status-deck/ ├─ desktop/ # Tauri壳工程 │ ├─ src/ # Rust侧代码 │ └─ tauri.conf.json ├─ frontend/ # Vue 3前端 │ ├─ src/ │ └─ package.json ├─ collector/ # Go采集服务 │ ├─ cmd/ │ ├─ internal/ │ ├─ go.mod │ └─ main.go └─ scripts/ # 一键启动脚本这里有个工程上的建议虽然叫monorepo但前端和Go服务不要强行共用一个包管理。Go有自己独立的模块体系最合理的做法是让pnpm workspace只管desktop和frontendcollector作为独立Go module通过根目录的Makefile或者npm scripts做统一调度。强行混合只会让CI和本地开发都不舒服。3.2 Go采集服务关键实现Go服务端的核心是调度器和数据源适配器。调度器我用了一个很轻量的方式不引入cron库直接用time.Ticker。package collector import time type SourceAdapter interface { Name() string Fetch() ([]RawItem, error) Normalize(raw RawItem) (*Card, error) } type Scheduler struct { adapters map[string]SourceAdapter freq map[string]time.Duration incoming chan RawEvent } func (s *Scheduler) Run() { for name, ad : range s.adapters { go func(n string, a SourceAdapter) { ticker : time.NewTicker(s.freq[n]) for range ticker.C { items, err : a.Fetch() if err ! nil { s.incoming - RawEvent{Source: n, Err: err} continue } for _, it : range items { s.incoming - RawEvent{Source: n, Item: it} } } }(name, ad) } }Ticker的好处是简单粗暴缺点是如果某个数据源请求耗时很长下一次Tick可能堆积。我实际用的时候给每个adapter加了超过阈值自动截断的context.WithTimeout保证采集器整体节奏稳定。数据源适配器里最有代表性的是GitLab的MR巡检。GitLab API返回的Merge Request列表过滤条件比较多需要排除Draft、排除自己主动关闭的、只保留和我相关的、按更新时间排序。这些过滤规则我放在Normalize阶段因为如果直接在API请求阶段用Query参数过滤每个项目的自定义字段又会导致规则复杂化不如全量拉回来后统一清洗。归一化后的卡片写入SQLite时用upsert保证幂等。SQLite在本地频率下完全够用不需要单独的数据库引擎。注意启动时创建表索引尤其是trace_id和updated_at这两个字段否则数据量上来后查询会明显变慢。3.3 WebSocket推送与心跳策略数据推流我用了gorilla/websocket因为实现简单、文档多。设计上不是每条采集到的数据都立刻推给前端这样会产生大量小消息、触发前端无意义的频繁渲染。我更倾向于状态快照增量事件混合推送首次连接后端将当前SQLite中的全量卡片按视图维度打包成一次snapshot发给前端后续连接以增量事件为主每次推送包含本轮归并后发生变化的卡片列表如果某轮采集没有变化后端不推送任何消息但会发送一个轻量的ping事件用于前端确认链路存活。WebSocket最麻烦的不是握手而是异常断开。开发者电脑睡眠、网络切换、Tauri窗口被系统挂起都会导致连接断开。断开后前端如果无脑重连会搞出重连风暴。我用的策略是指数退避最大重试间隔封顶。let retry 1 const maxRetry 20 const baseDelay 1000 const capDelay 30000 function connect() { const ws new WebSocket(ws://127.0.0.1:${port}/ws?token${token}) ws.onclose () { if (retry maxRetry) return const delay Math.min(baseDelay * 2 ** (retry - 1), capDelay) setTimeout(() { retry 1 connect() }, delay) } }同时前端的Vue组件只在收到snapshot和卡片变更event时才做渲染所有连接状态都收敛到一个Pinia store里避免多个组件各自维护ws实例导致消息重复处理。3.4 前端界面渲染细节前端界面最花时间的地方不是数据绑定的逻辑而是状态可视化。同样一张卡片从pending变成running再变成failed在视觉上要能被用户下意识感知。我的做法是给每个状态定义一组语义色和动效失败用深红色脉冲光晕成功用绿色打勾跳动运行中用青色扫光动画。这些动效看起来不难但真做起来要考虑浏览器性能尤其不能每秒钟都触发整个列表的动画否则CPU占用会翻好几倍。仪表盘的网格布局我用了CSS Grid让卡片在固定列数下自动换行。每个卡片高度不固定但依据内容大致分为紧凑型和展开型紧凑型只显示标题、状态和priority标识展开型则在右侧抽屉展示详情。这个是纯前端的交互优化对数据模型没有影响。还有一个细节Tauri环境下Vue前端访问本地Go服务的WebSocket会存在跨域的cherry需要在Tauri的tauri.conf.json里配置允许的URL或者后端在HTTP头里放行http://tauri.localhost。这个坑第一次跑通时很容易卡住后面单独拉一节说。3.5 一键启动与调试体验开发调试阶段最痛苦的环节是每次改动前端或者Go服务都要手动三步操作编译Go、启动Go、再启动Vite。我在根目录写了个npm script用concurrently并行跑两个进程再让Tauri的开发模式监听前端端口。npm run dev这个命令会启动Go采集服务监听随机端口并写入run/collector.port文件启动Vite dev server等待Vite ready后再启动Tauri dev窗口Tauri的main进程从端口文件读出Go服务地址注入到前端环境变量中。Debug时我习惯单独开一个终端跑Go进程因为Go日志和前端日志混在一起很容易乱。Go的采集日志和推送日志分开打采集日志用INFO级别、推送日志用DEBUG级别平时只开INFO排查问题时再切DEBUG。4. 踩坑记录与常见问题排查4.1 本地服务端口管理与权限这个项目里Go服务监听的是127.0.0.1随机端口理论上不存在端口冲突问题。但我在一期开发时图省事把端口硬编码成了5170然后就撞上了崽剧某天电脑上不知哪个进程占用了5170导致Go服务启动失败前端WebSocket连不上面板一片空白。排查了十分钟才发现是端口被占。后来我学乖了Go服务启动时先向系统申请一个空闲端口而不是自己猜一个端口。方法是直接监听端口0内核会自动分配空闲端口然后把实际端口号和随机生成的握手密钥写入一个临时文件里供Tauri主进程和前端读取。Tauri窗口加载完成后通过自定义协议读取这个文件完成初始连接信息的注入。这个方案的连带收益是每次启动都用不同端口降低了被恶意探测的可能性因为只监听本地回环外部根本发现不了这个服务的存在。4.2 跨域、令牌注入与安全边界Tauri前后端通信和平时浏览器前后端联调有一个明显的不同前端的Origin是http://tauri.localhost而后端Go服务跑在127.0.0.1上两者之间天然跨域。WebSocket尽管是ws协议同样受到Origin检查限制。我的解决办法是Go服务启动时给所有请求响应标上Access-Control-Allow-Origin: http://tauri.localhost并且只接受来自这个Origin的WebSocket握手请求其余的拒绝。令牌方面我用的是最简单的Bearer token方案但这个token不是写死在前端代码里的。启动时Go服务生成一个随机token写入临时文件并由Tauri主进程读取再通过Tauri的window.__STATUS_DECK_TOKEN__注入给前端。这样一来前端代码仓库里没有敏感数据token只在内存和本地临时文件中流转泄露面大幅缩小。顺带一提Tauri 2.x里可以通过withGlobalTauri把后端能力暴露成全局对象但我没有启用因为前端只需要读token和调用系统托盘相关能力没必要把整个Tauri API都裸露在全局。4.3 WebSocket重连与前端状态一致性这个问题是典型的前后端协作深水区。当电脑休眠恢复后TCP连接其实已经断了但前端WebSocket对象的状态可能还停留在OPEN要等底层超时才能触发onclose。这期间如果有数据变动前端就会漏更新而且界面还显示已连接非常误导人。我的解决方式分两层。第一层是WebSocket应用层心跳后端每30秒发一个ping的应用消息前端收到后更新本地心跳时间戳前端如果超过75秒没收到任何消息包括ping就主动断开并走指数退避重连。这个时间窗口比操作系统级的TCP超时要快得多把断线无感知的时间压缩到一分钟以内。第二层是重连成功后的快照对齐前端每次重连拿到snapshot后用整个快照覆盖本地store保证即使断线期间漏了几条增量事件也不会出现状态不一致。4.4 常见问题速查表现象可能原因排查办法仪表盘空白桌面窗口加载不出内容Go服务未启动或端口注入失败查看run/collector.port文件是否存在在终端手动跑Go服务看日志卡片一直停在加载中WebSocket握手失败多半是Origin不允许检查Go服务的跨域配置在DevTools Network面板看WS帧重启电脑后托盘图标不显示Tauri开机自启配置不对检查tauri.conf.json中autostart配置Windows上用msconfig确认启动项CI卡片状态不更新采集频率设置太长或数据源API限流临时调短Ticker间隔看Go日志里是否有对应的HTTP状态码卡片内容出现重复数据源返回的数据没有做幂等映射检查Normalize阶段是否对原始ID做了哈希确认SQLite表upsert是否生效这个表是我开发过程中真实踩过的问题不是网上抄来的。4.5 从能跑到稳定跑的两个关键细节第一本地SQLite文件的写入频率很容易失控。Go采集端如果每30秒拉一次GitLab每次拉完都更新SQLite磁盘写入也不算频繁。但一旦接入日志文件扫描器这种高频数据源每秒可能产生几十条日志事件直接全量写入SQLite会导致磁盘IO飙升。我的做法是引入一个简单的内存缓冲池每收集到100条日志事件或者每3秒强制flush一次再批量写入SQLite。日志类数据允许一定的延迟但绝不能拖垮主进程的采集调度。第二前端渲染时用v-memo或者分片渲染来避免大列表卡顿。仪表盘卡片数量如果超过200张一次性渲染所有DOM节点会有明显卡顿。我采用分页渲染默认只渲染前100张卡片滚动时动态加载后续卡片。这不是一个炫技方案但对于桌面应用、尤其是开机自启的常驻应用来说任何一点不必要的性能损耗都会累积成糟糕的体感。5. 一期复盘与下一步规划5.1 一期验收的四个标准与其说这是一期总结不如说我在开发前就给自己定了四个验收标准做完后逐项对照信息聚合效率以前每天早晨开工前要花30分钟翻转各个平台现在打开Status Deck两分钟能看完全部可处理事项。这个达到了。常驻稳定性连续开一周不崩、不占大内存、不出现白屏死状态。这个经过两周实测达到了。最稳的一次是连续跑了6天Windows休眠唤醒没出问题。新增数据源成本接一个全新数据源是否在2小时内能完成。GitLab和Jenkins都达标Jira的适配器因为字段太庞杂花了将近半天。代码可维护性后端Go代码和前端Vue代码是否做到了一两周不看还能快速上手。前端因为状态机约束比较强还行后端有一些adapter的重复代码二期需要重构。按这四个标准来评判Status Deck的第一期算是及格但有明显不足。最大的不足不是功能不够丰富而是整个项目的业务闭环还不够完整——仪表盘告诉了我有什么要处理但还没有做到处理完以后状态的联动反馈。比如我在GitLab上把一个MR合入后Status Deck是通过下一轮轮询被动感知到的并不是主动从GitLab webhook接回来的。被动轮询的延迟对于MR这种低频事件无所谓但对于CI失败、线上告警这类时效性强的场景最好依赖Webhook推送而不是主动拉取。目前是混合方案CI状态30秒短轮询日志文件靠本地监听。5.2 二期想做的扩展方向接下来有几个方向按优先级排第一AI摘要层。不是简单调用大模型把所有卡片描述再抄一遍而是做跨卡片的语义归并。比如商户登录接口这个模块可能同时出现了Jira缺陷、CI失败、日志报错三张卡AI需要识别出它们是同一件事并在仪表盘顶部生成一段事件摘要告诉用户这件事涉及范围、可能影响、建议下一步。这个方向需要先积累足够多的卡片历史数据做成离线分析模块再接入大模型能力避免每次界面刷新都要调用一次接口成本控制不住。第二手机端远程查看。目前Tauri桌面的价值在于开发时随处可看但它毕竟不能跟出差场景友好配合。二期考虑做一个简单的手机H5版只读不写通过安全通道连接回家里或公司的服务端在手机上看到同样的关键卡片。要在后端补充按用户维度的权限控制不能像现在这样只绑定本机使用。第三多项目配置管理。目前的配置是写死在Go服务里的要改数据源、改Token都得改配置文件重启服务。二期打算把数据源配置做成可视化维护直接在仪表盘右上角打开设置抽屉通过前端提交新的配置内容写入配置中心再触发采集器热加载。第四小程序的联动也考虑过但是小程序平台够完整、审核成本高我这个工具主要给自己用手机端H5已经够用小程序可能不会优先做。5.3 给也想自造工具的开发者几条建议这个项目做下来让我对自造工具这件事有几个很实在的体会想分享给同样打算做个全栈项目的同学。第一自造工具的第一用户是自己不要一开始就想太多通用性。我一开始写了特别多配置文件想着未来要支持不同团队用、支持多种数据源、支持自定义展示规则结果做完发现全是过度设计真正用到的配置只有GitLab的Token、Jira的查询语句、日志目录的路径。先满足自己的80%需求跑起来再去考虑别的使用场景这个节奏才舒服。第二后端和前端的学习曲线是不同的。我是前端出身写Vue很顺手但Go服务的并发模型和错误处理方式一开始坑了我不少时间。尤其是goroutine里的错误如果不被捕获整个采集器会静默挂掉没有Panic信息前端看到的只是卡片不更新。后面我养成了一个习惯每个采集goroutine都用recover包一层把panic信息打到日志文件里。对于前端转全栈的同学来说这种生产事故教育比任何语法教程都更有用。第三桌面端小细节对体验的影响被严重低估。窗口拖拽、开机自启、托盘菜单、右下角通知、窗口失焦自动隐藏这些功能单个拿出来都很简单但拼在一起才让Status Deck有了桌面应用的感觉而不是一个穿在浏览器外套里的网页。如果只做Web版你永远不会去考虑系统休眠后唤起时UI没刷新这种问题。第四命名很重要Status Deck这个名字本身就是产品定位。我见过很多开发者工具类项目技术上很好但产品定义上模糊不清。一个工具需要一句话说清它是干嘛的否则自己写着写着就跑偏了。Status、Deck两个词合起来既描述了形态状态面板又暗示了使用方式一览无余的卡片阵列整个项目的边界感一下子就出来了。我可以坦白说Status Deck到现在也只算完成了第一期的可用状态离好用还差着一段距离。但它已经切切实实地改善了我每天开工后前30分钟的信息焦虑也让我在Vue、Tauri、Go、SQLite、WebSocket这一整条技术链上有了更深的掌控感。对正在寻找全栈练手项目的朋友我建议直接照抄这个思路挑一个你最痛的实际场景用最顺手的技术栈做出来把它用起来而不是做完截图就丢到仓库吃灰。这个项目后面我还打算持续迭代下去下一期应该会写AI摘要层的具体实践。