Files
Aq-Accounting-Flutter/tools/make_icons.py
T
2026-09-24 14:24:34 +08:00

399 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
生成 App 的双色图标集(SVG)。
为什么要有这个脚本:
图标是「设计系统」的一部分,不是一堆散文件。把形状和配色集中写在这里,
改动(换圆角、调粗细、换某个颜色的深浅)只需要改一个地方再重跑,
不用手工去改 30 个 SVG。
设计规范:
· 画布 24×24,内容控制在 3~21 之间留出视觉边距
· 线条粗细统一 2.0,端点和连接处全部圆角(这是「可爱」的关键)
· 双色:浅色填充 + 深色描边(类似 Phosphor Duotone 的做法)
· 配色以品牌焦糖色 #C8956E 为锚,压低饱和度,保证整体暖调统一
产物:
assets/icons/<name>.svg 提交进仓库,由 flutter_svg 渲染
<后台>/static/admin/icons/<name>.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"),
}
# 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),
]),
# ============================ 界面图标 ============================
"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' <path d="{d}" fill="{fill if filled else "none"}"/>'
for d, filled in paths
)
return (
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" '
f'width="24" height="24" fill="none" stroke="{stroke}" '
f'stroke-width="{STROKE}" stroke-linecap="round" stroke-linejoin="round">\n'
f'{body}\n</svg>\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 = ["""<!DOCTYPE html><html><head><meta charset="utf-8">
<title>图标预览</title>
<style>
body{font-family:-apple-system,"Microsoft YaHei",sans-serif;background:#FAF8F5;color:#2B2B2B;padding:28px}
h2{font-size:15px;margin:26px 0 12px;color:#5D4037}
.row{display:flex;flex-wrap:wrap;gap:14px}
.cell{width:104px;background:#fff;border:1px solid #EAE3DC;border-radius:12px;
padding:14px 8px;text-align:center}
.cell img{width:44px;height:44px;display:block;margin:0 auto 8px}
.cell .n{font-size:11.5px;color:#8D7B70;word-break:break-all}
.big img{width:72px;height:72px}
</style></head><body>
<h1 style="font-size:19px;margin:0">强宝小助手 · 图标集</h1>
<p style="color:#8D7B70;font-size:13px;margin:6px 0 0">上:44px 实际显示尺寸 下:72px 放大看细节</p>"""]
for title, names in groups:
html.append(f"<h2>{title}</h2><div class='row'>")
for n in names:
if n not in ICONS:
continue
html.append(
f"<div class='cell'><img src='../assets/icons/{n}.svg'>"
f"<span class='n'>{n}</span></div>")
html.append("</div>")
html.append("<h2>放大检查(72px)</h2><div class='row big'>")
for n in ICONS:
html.append(f"<div class='cell'><img src='../assets/icons/{n}.svg'>"
f"<span class='n'>{n}</span></div>")
html.append("</div></body></html>")
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()