从零实现一个 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)。
95 lines
2.8 KiB
Python
95 lines
2.8 KiB
Python
"""生成 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()
|