AI 接入 V1.0

UI更新
This commit is contained in:
2026-09-24 14:24:34 +08:00
parent 34bc2e04b0
commit 9a144f159f
65 changed files with 1195 additions and 196 deletions
+398
View File
@@ -0,0 +1,398 @@
# -*- 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()