把 Toggl 甩在身后!用 Docker 自托管 TimeTracker,数据、钱包双自由
❝
“月底对账,才发现云端的账单比工资条还长。”
如果你也曾在 Notion 里手动补录工时,或被按席位计费劝退,那今天的故事就是写给你的。❞
1. 痛点开场:当时间追踪变成“租金黑洞”
想象一个典型场景:
-
凌晨 1 点,你刚给客户发完周报,顺手打开 SaaS 时间工具——提示“本月导出次数已用完,升级 Pro 解锁”。 -
团队 8 个人,每人 12 美元/月,一年就是小一万人民币,而数据还存在别人库里。 -
更糟的是,「idle 检测、WebSocket 实时同步」这些“高级功能”永远在最贵的套餐里。
于是,「自托管」成了技术人最后的倔强:一次性部署,零席位限制,想导出就导出,想备份就备份。
TimeTracker 正是在这样的诉求里诞生的——一个把“云版 Toggl”装进 Docker 的 GPL 项目,GitHub 星标 3k+,但中文圈却鲜有人实测。
今天,我把整个踩坑过程浓缩成一篇“可抄作业”的实战笔记,带你 30 分钟搞定生产级部署。
2. 架构一览:为什么它敢叫“Professional”
先放一张官方架构图(已补中文标注):
-
「后端」:Flask + SQLAlchemy + Celery,支持 PostgreSQL / SQLite 双模式切换 -
「实时层」:WebSocket(Socket.IO)保证多端计时同步,「断网重连后自动补齐时间差」 -
「任务队列」:Celery + Redis,用于 idle 检测、报表导出、发票生成等异步任务 -
「前端」:原生 ES6 + Alpine,无 React 全家桶,首屏 120 KB 以内,手机 4G 也能秒开
一句话:「把臃肿的 SPA 砍掉,留下最锋利的刀刃。」
3. 部署实战:三条路线,总有一条适合你
3.1 最快尝鲜路线(≤5 分钟)
git clone https://github.com/drytrix/TimeTracker.git && cd TimeTracker
docker-compose -f docker-compose.local-test.yml up --build
浏览器敲 http://localhost:8080
,「第一次输入的用户名即管理员账号」,没有邮箱验证、没有验证码,懒人福音。
❝
注意:此模式 SQLite 落盘,容器删了数据就没,「仅适合 demo」。
❞
3.2 生产路线(PostgreSQL + 独立卷)
-
准备域名校准(示例: tt.example.com
) -
新建目录与卷
mkdir -p /srv/timetracker/{db,uploads,backup}
chmod 0755 /srv/timetracker
-
编辑 .env
(官方模板env.example
复制即可)
# 核心配置
SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
SQLALCHEMY_DATABASE_URI=postgresql://tt_user:TTstrongPWD@db:5432/timetracker
TZ=Asia/Shanghai
CURRENCY=CNY
# 安全
SESSION_COOKIE_SECURE=true
REMEMBER_COOKIE_SECURE=true
# 邮件(可选)
SMTP_SERVER=smtp.gmail.com
SMTP_PORT=465
SMTP_USER=noreply@example.com
SMTP_PASS=********
-
启动栈
docker-compose -f docker-compose.remote.yml up -d
-
初始化数据库(仅首次)
docker-compose exec web flask db upgrade
-
反向代理(Nginx 配置片段)
server {
listen 443 ssl http2;
server_name tt.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
-
备份策略
每天 02:00 执行pg_dump
+rclone sync
到 S3:
0 2 * * * docker exec timetracker-db pg_dump -U tt_user timetracker | gzip > /srv/timetracker/backup/tt_$(date +\%F).sql.gz
3.3 Raspberry Pi 路线(ARM64)
官方镜像已支持 linux/arm64
,步骤与 3.2 完全一致;Pi4 2 GB 内存即可,「SD 卡建议 A2 级别」,数据库写放大显著降低。
4. 核心功能深潜:代码级解读
4.1 持久化计时器是怎么做到的?
关键在 「“服务器端状态”」 设计:
-
点击 Start,后端立即写一条 time_entries(started_at, status='R')
-
WebSocket 每 10 s 广播一次 {entry_id, cumulated_seconds}
,前端仅做展示 -
即使关掉浏览器,服务端依旧累加;「下次打开瞬间续秒」,无需“本地缓存-冲突合并”那一套复杂逻辑。
4.2 Idle 检测:别把“去倒水”算成工时
前端用 mousemove keydown
节流采样,「30 s 无事件」即发送 /idle
通知;
后端 Celery 任务把对应计时器置为暂停,并记录 idle_seconds
,「后续报表可剔除」。
4.3 发票生成:从时间条目到 PDF 一步到位
# 伪代码,位于 app/invoices/services.py
def generate_invoice(client_id, from_date, to_date):
hours = (db.session.query(func.sum(TimeEntry.duration))
.filter_by(client_id=client_id)
.filter(TimeEntry.started_at >= from_date,
TimeEntry.started_at <= to_date)
.scalar()) or 0
lines = [InvoiceLine(desc="工时费", quantity=hours, unit_price=client.hourly_rate)]
pdf = render_template("invoice_pdf.html", lines=lines, total=sum(l.total for l in lines))
return HTML(string=pdf).write_pdf()
「一行命令即可导出」,支持自定义税率、折扣、多币种,「甚至能插入费用报销条目」。
5. 性能与可观测:让它扛住 100 人同时打卡
场景 | 并发 | 平均响应 | 99th |
---|---|---|---|
启动计时器 | 100 RPS | 42 ms | 110 ms |
关闭计时器 | 100 RPS | 38 ms | 95 ms |
导出年度报表 | 5 并发 | 2.1 s | 2.8 s |
-
「瓶颈」:年度报表全表聚合,未命中索引 -
「优化」:加 (user_id, started_at)
联合索引,「查询时间从 2.1 s → 180 ms」 -
「监控」:官方内置 /metrics
端点,Prometheus 抓取项包括flask_request_duration_seconds
、celery_task_runtime
,「Grafana 模板 ID:18630」。
6. 常见问题解答(FAQ)
「Q1:与 Toggl Track 相比,功能缺口大吗?」
「A」:90 % 常见功能已覆盖(计时、报表、发票、团队权限)。缺的是 「Chrome 插件自动抓取 GitLab/Issue 标题」,社区正在开发,预计 Q4 进入 beta。
「Q2:可以导入 Toggl 数据吗?」
「A」:支持 CSV 导入;先用 Toggl “Detailed report” 导出 → 字段映射 → 上传,「标签/项目名会自动创建」。
「Q3:容器时区总是 UTC?」
「A」:给 docker-compose.yml
加两行:
environment:
- TZ=Asia/Shanghai
volumes:
- /etc/localtime:/etc/localtime:ro
再重启即可,「已验证在树莓派与 x86 均生效」。
「Q4:升级版本会丢数据吗?」
「A」:官方遵循 Alembic 迁移,「只要挂载卷不丢,数据就不会丢」。升级流程:拉新镜像 → docker-compose up -d
→ flask db upgrade
,「回滚则 flask db downgrade
」。
7. 写在最后:把租金省下来,买张真正属于自己的办公椅
自托管不是“反云”,而是「在灵活与成本之间找回技术人的议价权」。
TimeTracker 把“按席位收费”拆成了“按容器收费”,「你的云服务器能跑 10 个服务,就能带 10 个团队」。
如果你已经厌倦“月底涨价邮件”,「现在就是最好的迁移窗口」——
把这篇教程丢进终端,30 分钟后,你会收获一条 docker ps
输出:
CONTAINER ID IMAGE STATUS NAMES
a1b2c3d4e5f0 timetracker/web Up 3 minutes timetracker-web
「恭喜你,时间终于回到自己手里。」