# -*- coding: utf-8 -*- """ 生成 App 的双色图标集(SVG)。 为什么要有这个脚本: 图标是「设计系统」的一部分,不是一堆散文件。把形状和配色集中写在这里, 改动(换圆角、调粗细、换某个颜色的深浅)只需要改一个地方再重跑, 不用手工去改 30 个 SVG。 设计规范: · 画布 24×24,内容控制在 3~21 之间留出视觉边距 · 线条粗细统一 2.0,端点和连接处全部圆角(这是「可爱」的关键) · 双色:浅色填充 + 深色描边(类似 Phosphor Duotone 的做法) · 配色以品牌焦糖色 #C8956E 为锚,压低饱和度,保证整体暖调统一 产物: assets/icons/.svg 提交进仓库,由 flutter_svg 渲染 <后台>/static/admin/icons/.svg 同一套图标给网页后台用 <后台>/static/admin/icons.js 图标名清单 + emoji 兼容映射 tools/_icons_preview.html 预览页(浏览器打开检查,不提交) 用法: python tools/make_icons.py # 只生成 App 的图标 python tools/make_icons.py --preview # 额外生成预览页 python tools/make_icons.py --sync-web # 同时同步给网页后台(改了图标就带上) """ from __future__ import annotations import argparse import pathlib ROOT = pathlib.Path(__file__).resolve().parent.parent OUT_DIR = ROOT / "assets" / "icons" PREVIEW = ROOT / "tools" / "_icons_preview.html" # 网页后台也要用同一套图标(否则分类图标从 emoji 换成图标名之后, # 后台会把 "food" 当文字显示出来)。这里是后台的静态资源目录。 WEB_DIR = ROOT.parent / "AI-accounting-soft" / "src" / "main" / "resources" / "static" / "admin" STROKE = 2.0 # ---------------------------------------------------------------- 形状原语 # 所有图标都由这几个原语拼出来 —— 这是「风格统一」的机制保证: # 圆角半径、线宽、端点全是同一套参数,不会出现某个图标特别突兀。 def rr(x: float, y: float, w: float, h: float, r: float) -> str: """圆角矩形""" return ( f"M{x + r} {y}h{w - 2 * r}a{r} {r} 0 0 1 {r} {r}v{h - 2 * r}" f"a{r} {r} 0 0 1 -{r} {r}h-{w - 2 * r}a{r} {r} 0 0 1 -{r} -{r}" f"v-{h - 2 * r}a{r} {r} 0 0 1 {r} -{r}z" ) def circle(cx: float, cy: float, r: float) -> str: return f"M{cx - r} {cy}a{r} {r} 0 1 0 {2 * r} 0a{r} {r} 0 1 0 -{2 * r} 0z" def bowl() -> str: """半圆碗(上沿平、下半圆)""" return "M4 11h16a8 8 0 0 1-16 0z" def sparkle() -> str: """四角星光 —— 用曲线收腰,直线版会像菱形""" return ( "M12 2.5C12.6 7.8 16.2 11.4 21.5 12 16.2 12.6 12.6 16.2 12 21.5" "5.8 16.2 9.4 12.6 4.2 12 9.4 11.4 12.6 7.8 12 2.5z" ) # ---------------------------------------------------------------- 配色 # (描边色, 填充色)。以焦糖 #C8956E 为锚,整体暖调、低饱和。 C_BRAND = "#C8956E" C_BRAND_L = "#F6E8DB" C_TEXT = "#8D6E63" # UI 图标默认色(次级文字色) C_TEXT_L = "#F1E7E0" PALETTE = { "food": ("#E2914B", "#FBE7D0"), # 橙 "transport": ("#5B8DB8", "#DCE8F2"), # 灰蓝 "shopping": ("#D97C93", "#F7E2E8"), # 玫红 "entertainment": ("#9B7BC4", "#EBE3F5"), # 紫 "medical": ("#E0725C", "#FAE2DD"), # 珊瑚红 "education": ("#6B8E4E", "#E4EDDA"), # 橄榄绿 "housing": ("#B5794F", "#F0E2D6"), # 棕 "utilities": ("#D9A93C", "#F8ECD2"), # 琥珀 "phone": ("#4FA0B0", "#DBEDF1"), # 青 "other": ("#9A8C80", "#EBE5E0"), # 灰棕 "salary": (C_BRAND, C_BRAND_L), # 品牌焦糖 "bonus": ("#D99C3C", "#F9EDD5"), # 金 "invest": ("#4FA98A", "#DBEFE8"), # 青绿 "parttime": ("#8A7BB8", "#E8E3F4"), # 蓝紫 "bill": (C_BRAND, C_BRAND_L), "note": (C_BRAND, C_BRAND_L), "assistant": ("#D9A93C", "#F8ECD2"), "health": ("#4FA98A", "#DBEFE8"), # 青绿(和 invest 同色系,属"正向"语义) } # name -> (palette_key, [(path, 是否填充), ...]) ICONS: dict[str, tuple[str, list[tuple[str, bool]]]] = { # ============================ 记账分类 ============================ "food": ("food", [ (bowl(), True), ("M9.2 8.2c1-1.4-.9-2.6.1-4", False), ("M13.6 8.2c1-1.4-.9-2.6.1-4", False), ]), "transport": ("transport", [ (rr(7, 4.8, 10, 5.4, 2.6), True), # 车厢 (rr(3.2, 9.4, 17.6, 7.6, 3), True), # 车身 (circle(8, 17.2, 1.9), True), # 轮 (circle(16, 17.2, 1.9), True), ]), "shopping": ("shopping", [ (rr(4.8, 8.6, 14.4, 11.8, 3), True), # 袋身 ("M9 8.6V7a3 3 0 0 1 6 0v1.6", False), # 提手 ]), "entertainment": ("entertainment", [ (rr(3.2, 9.6, 17.6, 10.4, 2.5), True), # 板身 ("M3.2 9.6h17.6", False), ("M7 6.2l-1.6 3.4M12 6.2l-1.6 3.4M17 6.2l-1.6 3.4", False), # 斜纹 ]), "medical": ("medical", [ (rr(3.6, 3.6, 16.8, 16.8, 5), True), ("M12 8.4v7.2M8.4 12h7.2", False), # 十字 ]), "education": ("education", [ ("M12 4.4l9 4.6-9 4.6-9-4.6 9-4.6z", True), # 帽檐 ("M20.6 9.2v5.4", False), # 流苏 (circle(20.6, 16.2, 1.3), True), ]), "housing": ("housing", [ ("M3.8 11.4L12 4.6l8.2 6.8V19a2.2 2.2 0 0 1-2.2 2.2H6a2.2 2.2 0 0 1-2.2-2.2z", True), (rr(9.8, 14, 4.4, 7.2, 1.6), False), # 门 ]), "utilities": ("utilities", [ ("M12 2.8a6.4 6.4 0 0 0-3.7 11.6c.8.6 1.3 1.4 1.3 2.3v.6h4.8v-.6" "c0-.9.5-1.7 1.3-2.3A6.4 6.4 0 0 0 12 2.8z", True), ("M9.8 20.2h4.4M10.8 22.2h2.4", False), # 灯座 ]), "phone": ("phone", [ (rr(6.4, 2.8, 11.2, 18.4, 3), True), ("M10.6 5.6h2.8", False), ("M11 18.4h2", False), ]), "other": ("other", [ (rr(3.4, 6.8, 17.2, 13.4, 2.6), True), ("M3.4 11.4h17.2", False), ("M12 6.8v4.6", False), ]), "salary": ("salary", [ (rr(2.4, 6.4, 19.2, 11.2, 2.6), True), (circle(12, 12, 3.1), False), ]), "bonus": ("bonus", [ (rr(3.4, 10, 17.2, 10.2, 2.2), True), # 盒身 (rr(2.4, 6.2, 19.2, 4, 1.6), True), # 盒盖 ("M12 6.2v14", False), # 丝带 ("M12 6.2C10 3.4 6.6 4.2 7.6 6.6", False), # 蝴蝶结 ("M12 6.2c2-2.8 5.4-2 4.4.4", False), ]), "invest": ("invest", [ (rr(3.4, 14.4, 3.2, 6, 1.4), True), # 柱状 (rr(10.4, 11.4, 3.2, 9, 1.4), True), (rr(17.4, 8.4, 3.2, 12, 1.4), True), ("M4 11.6l5-4 3.4 2.8L19.6 5", False), # 趋势线 ("M16 5h3.6v3.4", False), # 箭头 ]), "parttime": ("parttime", [ (rr(3, 8.4, 18, 12.2, 2.6), True), ("M9 8.4V7a1.6 1.6 0 0 1 1.6-1.6h2.8A1.6 1.6 0 0 1 15 7v1.4", False), ("M3 13.2h18", False), ]), # ============================ 模块图标 ============================ "bill": ("bill", [ (rr(2.8, 6.6, 18.4, 12.4, 3), True), # 钱包 ("M2.8 11.4h18.4", False), (circle(16.4, 15, 1.5), True), # 扣子 ]), "note": ("note", [ (rr(4.4, 3.4, 15.2, 17.2, 2.6), True), # 本子 ("M8.6 3.4v17.2", False), # 书脊 ("M11.8 9h5M11.8 12.6h5", False), # 文字线 ]), "assistant": ("assistant", [ (sparkle(), True), ("M18.4 15.6l.9 2.3 2.3.9-2.3.9-.9 2.3-.9-2.3-2.3-.9 2.3-.9.9-2.3z", True), ]), "health": ("health", [ (rr(2.8, 9.2, 4.2, 5.6, 1.4), True), # 左外配重 (rr(6.4, 7.4, 3.4, 9.2, 1.4), True), # 左内配重 ("M9.8 12h4.4", False), # 横杆 (rr(14.2, 7.4, 3.4, 9.2, 1.4), True), # 右内配重 (rr(17.0, 9.2, 4.2, 5.6, 1.4), True), # 右外配重 ]), # ============================ 界面图标 ============================ "expense": (None, [ # 支出:托盘 + 向上箭头 ("M4 13.6v3.4a2.6 2.6 0 0 0 2.6 2.6h10.8a2.6 2.6 0 0 0 2.6-2.6v-3.4" "H15.4a3.4 3.4 0 0 1-6.8 0H4z", True), ("M12 14.6V4.4M7.8 8.6L12 4.4l4.2 4.2", False), ]), "income": (None, [ # 收入:托盘 + 向下箭头 ("M4 13.6v3.4a2.6 2.6 0 0 0 2.6 2.6h10.8a2.6 2.6 0 0 0 2.6-2.6v-3.4" "H15.4a3.4 3.4 0 0 1-6.8 0H4z", True), ("M12 4.4v10.2M7.8 10.4L12 14.6l4.2-4.2", False), ]), "camera": (None, [ (rr(3, 6.8, 18, 13.4, 3), True), (circle(12, 13.4, 3.8), False), ("M9 6.8l1.3-2.4h3.4L15 6.8", False), ]), "search": (None, [ (circle(10.4, 10.4, 6.4), True), ("M15.2 15.2L20.6 20.6", False), ]), "user": (None, [ (circle(12, 8.4, 3.9), True), ("M4.8 20.4c0-4 3.2-6.6 7.2-6.6s7.2 2.6 7.2 6.6z", True), ]), "lock": (None, [ (rr(4.4, 10.4, 15.2, 10.2, 2.6), True), ("M8.4 10.4V7.8a3.6 3.6 0 0 1 7.2 0v2.6", False), (circle(12, 15.4, 1.2), True), ]), "eye": (None, [ ("M2.6 12C5.2 7.6 8.6 5.6 12 5.6s6.8 2 9.4 6.4C18.8 16.4 15.4 18.4 12 18.4" "S5.2 16.4 2.6 12z", True), (circle(12, 12, 2.9), False), ]), "image": (None, [ (rr(3, 4.8, 18, 14.4, 3), True), ("M4.4 16.6l4.6-5 3.4 3.8 3.2-3.4 4.6 4.8", False), (circle(8.6, 9.4, 1.5), True), ]), "file": (None, [ ("M6 3h7.4L19 8.6V19a2.2 2.2 0 0 1-2.2 2.2H6A2.2 2.2 0 0 1 3.8 19V5.2" "A2.2 2.2 0 0 1 6 3z", True), ("M13.4 3v5.6H19", False), ("M7.6 13h8.8M7.6 16.6h5.4", False), ]), "folder": (None, [ ("M3.4 7.4a2.2 2.2 0 0 1 2.2-2.2h3.6a2.2 2.2 0 0 1 1.8.9l.9 1.3h8.5" "a2.2 2.2 0 0 1 2.2 2.2v8.6a2.2 2.2 0 0 1-2.2 2.2H5.6a2.2 2.2 0 0 1-2.2-2.2z", True), ]), "folder-open": (None, [ ("M3.4 17.6V7.4a2.2 2.2 0 0 1 2.2-2.2h3.6a2.2 2.2 0 0 1 1.8.9l.9 1.3h6.5" "a2.2 2.2 0 0 1 2.2 2.2v1.2", False), ("M3.4 18.4l2.3-7h15.1l-2.3 7a2.2 2.2 0 0 1-2.1 1.6H5.5a2.2 2.2 0 0 1-2.1-1.6z", True), ]), "check": (None, [ (circle(12, 12, 8.6), True), ("M8 12.3l2.9 2.9 5.2-6", False), ]), "pin": (None, [ ("M8.8 3.2h6.4l-1.1 5.4 3 2.9v1.6H6.9v-1.6l3-2.9z", True), ("M12 13.1v7.6", False), ]), "target": (None, [ (circle(12, 12, 8.6), True), (circle(12, 12, 4.6), False), (circle(12, 12, 1.5), True), ]), "inbox": (None, [ ("M5.6 4.6h12.8l2.6 8.2v4.4a2.2 2.2 0 0 1-2.2 2.2H5.2A2.2 2.2 0 0 1 3 17.2v-4.4z", True), ("M3 12.8h4.6l1.4 2.6h6l1.4-2.6H21", False), ]), "error": (None, [ (circle(12, 12, 8.6), True), ("M12 7.6v5.2", False), (circle(12, 16.4, 1.15), True), ]), } FALLBACK = "other" # 和 cute_icon.dart 里的 cuteEmojiAliases 保持一致 —— # 这份只用于「迁移还没跑」的过渡期,以及用户自己填 emoji 的情况 EMOJI_ALIASES = { "🍔": "food", "🍜": "food", "🍚": "food", "🍕": "food", "☕": "food", "🚗": "transport", "🚌": "transport", "🚇": "transport", "✈": "transport", "🛍": "shopping", "🛒": "shopping", "👕": "shopping", "🎬": "entertainment", "🎮": "entertainment", "🎵": "entertainment", "🏥": "medical", "💊": "medical", "💉": "medical", "📚": "education", "🎓": "education", "📖": "education", "✏": "education", "🏠": "housing", "🏡": "housing", "🛏": "housing", "💡": "utilities", "💧": "utilities", "⚡": "utilities", "🔌": "utilities", "📱": "phone", "☎": "phone", "💻": "phone", "💰": "salary", "💵": "salary", "🏦": "salary", "💴": "salary", "🎁": "bonus", "🧧": "bonus", "📈": "invest", "📊": "invest", "💹": "invest", "🪙": "invest", "💼": "parttime", "🔧": "parttime", "📦": "other", "📌": "other", "🏷": "other", "❓": "other", } def render(name: str, key: str | None, paths: list[tuple[str, bool]]) -> str: """把形状列表拼成 SVG。key=None 表示界面图标,用默认的次级文字色""" if key is None: stroke, fill = C_TEXT, C_TEXT_L else: stroke, fill = PALETTE[key] body = "\n".join( f' ' for d, filled in paths ) return ( f'\n' f'{body}\n\n' ) def build() -> None: OUT_DIR.mkdir(parents=True, exist_ok=True) for name, (key, paths) in ICONS.items(): (OUT_DIR / f"{name}.svg").write_text(render(name, key, paths), encoding="utf-8") print(f"-> {OUT_DIR} ({len(ICONS)} 个图标)") def preview() -> None: """生成预览页 —— SVG 好不好看必须用眼睛验,光看代码看不出来""" groups = [("记账分类", list(PALETTE.keys())[:14]), ("模块 / 界面", [n for n in ICONS if n not in list(PALETTE.keys())[:14]])] html = [""" 图标预览

强宝小助手 · 图标集

上:44px 实际显示尺寸 下:72px 放大看细节

"""] for title, names in groups: html.append(f"

{title}

") for n in names: if n not in ICONS: continue html.append( f"
" f"{n}
") html.append("
") html.append("

放大检查(72px)

") for n in ICONS: html.append(f"
" f"{n}
") html.append("
") PREVIEW.write_text("\n".join(html), encoding="utf-8") print("->", PREVIEW) def sync_web() -> None: """把图标同步给网页后台。 后台也要显示分类图标,而分类图标已经从 emoji 换成了图标名 —— 不同步的话后台会把 "food" 当文字显示出来。 顺手生成一份 icons.js,里面有图标名清单和 emoji 兼容映射, 免得在两个项目里各维护一份(那种重复迟早会不同步)。 """ if not WEB_DIR.parent.exists(): print(f" (跳过) 找不到网页后台目录: {WEB_DIR}") return dst = WEB_DIR / "icons" dst.mkdir(parents=True, exist_ok=True) for f in dst.glob("*.svg"): f.unlink() for name in ICONS: (dst / f"{name}.svg").write_text( render(name, *ICONS[name]), encoding="utf-8") names = ",\n ".join(f"'{n}'" for n in ICONS) aliases = ",\n ".join(f"'{k}': '{v}'" for k, v in sorted(EMOJI_ALIASES.items())) (WEB_DIR / "icons.js").write_text( "/* 由 tools/make_icons.py 生成,不要手工编辑 —— 改图标请改脚本再重跑 */\n" "window.CUTE_ICONS = {\n" f" fallback: '{FALLBACK}',\n names: [\n {names},\n ],\n" f" aliases: {{\n {aliases},\n }},\n" "};\n", encoding="utf-8") print(f"-> {dst} ({len(ICONS)} 个图标) + icons.js") def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--preview", action="store_true", help="额外生成预览页") ap.add_argument("--sync-web", action="store_true", help="同时同步给网页后台") args = ap.parse_args() build() if args.preview: preview() if args.sync_web: sync_web() if __name__ == "__main__": main()