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:
@@ -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()
|
||||
Reference in New Issue
Block a user