feat: 个人轻量看板工具 MVP

从零实现一个 local-first 的个人任务看板,数据只存在浏览器本地,不依赖后端。

功能
- 固定三列看板(待办 / 进行中 / 已完成),支持拖拽改状态与列内排序
- 快速新增、详情编辑、删除撤销、已完成归档
- 搜索与筛选(关键词 / 优先级 / 标签 / 今日到期 / 逾期)
- 今日视图:按紧急度分组(逾期 > 今天到期 > 进行中)+ 统计卡片
- 导出导入 JSON;启动时自动留快照,保留最近 3 份
- 键盘快捷键(N / 「/」/ ? / Esc)
- PWA:可安装、离线可用、更新提示

技术要点
- 持久化采用 TaskRepository 端口 + 整份快照读写,schemaVersion 迁移表
  显式登记升级规则(当前 v2,v1 -> v2 为归档功能补 archivedAt)
- 同列排序用相邻中点插入,间距不足时按列 normalize,避免浮点精度耗尽
- 拖拽只消费 Sortable 的事件,store 保持唯一数据源,不接管数据
- 数据损坏时先备份到 *.corrupt.<时间戳> 再报错,不覆盖用户数据
- 引入 vue-router 拆分看板与今日两个视图(见 docs/adr.md ADR-005)

验证
- 单元测试 437 个(33 个文件),行覆盖率 96.8%
- 端到端测试 23 个(Playwright),含真实拖拽与断网离线场景
- npm run build 通过,产物可静态托管

注:部署时需配置 SPA 回落,否则直接访问 /today 会 404(详见 README)。
This commit is contained in:
chenshoulin
2026-10-18 11:59:49 +08:00
commit 4e064e82ea
98 changed files with 20084 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
"""生成 PWA 图标。
设计:天蓝色底 + 三根白色半透明圆角竖条(看板的三列),
每根竖条内从上往下堆叠若干不透明白色卡片,数量各不相同。
注意:Pillow 的 ImageDraw 不做 alpha 混合(半透明填充会直接覆盖像素),
因此所有半透明元素必须画在独立图层上,再用 alpha_composite 合成。
Maskable 版本把内容缩到安全区(中心 80%)内,避免被系统裁成圆形时切掉。
"""
from __future__ import annotations
import os
from PIL import Image, ImageDraw
OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "public")
BACKGROUND = (2, 132, 199, 255) # sky-600
TRACK = (255, 255, 255, 70) # 半透明的列轨道
CARD = (255, 255, 255, 255)
# 每列的卡片高度(相对内容区高度的比例),从上往下堆叠
COLUMNS = [
[0.22, 0.15],
[0.16, 0.22, 0.13],
[0.14],
]
SUPERSAMPLE = 4
def draw_icon(size: int, content_ratio: float) -> Image.Image:
"""content_ratio: 内容占据画布的比例(1.0 表示铺满)"""
canvas = size * SUPERSAMPLE
background = Image.new("RGBA", (canvas, canvas), BACKGROUND)
layer = Image.new("RGBA", (canvas, canvas), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
content = canvas * content_ratio
offset = (canvas - content) / 2
gap = content * 0.07
column_width = (content - gap * (len(COLUMNS) - 1)) / len(COLUMNS)
track_radius = max(2, int(column_width * 0.24))
for index, cards in enumerate(COLUMNS):
left = offset + index * (column_width + gap)
top = offset
bottom = offset + content
draw.rounded_rectangle(
[left, top, left + column_width, bottom],
radius=track_radius,
fill=TRACK,
)
pad = column_width * 0.17
card_width = column_width - pad * 2
card_gap = content * 0.035
cursor = top + pad
for ratio in cards:
card_height = content * ratio
draw.rounded_rectangle(
[left + pad, cursor, left + pad + card_width, cursor + card_height],
radius=max(2, int(track_radius * 0.5)),
fill=CARD,
)
cursor += card_height + card_gap
image = Image.alpha_composite(background, layer)
return image.resize((size, size), Image.LANCZOS)
def main() -> None:
os.makedirs(OUT_DIR, exist_ok=True)
targets = [
("pwa-192x192.png", 192, 0.88),
("pwa-512x512.png", 512, 0.88),
("maskable-512x512.png", 512, 0.60), # 留出安全区
("apple-touch-icon.png", 180, 0.88),
]
for name, size, ratio in targets:
path = os.path.join(OUT_DIR, name)
draw_icon(size, ratio).convert("RGB").save(path, "PNG", optimize=True)
print(f"generated {name} ({size}x{size})")
if __name__ == "__main__":
main()