AI 接入 V1.0
UI更新
@@ -36,6 +36,9 @@
|
|||||||
- 历史会话存后端(`chat_session` / `chat_message` 两张表),App 只发 `sessionId + content`。**工具调用的中间过程不入库**,所以 App 不用理解 tool 消息格式。
|
- 历史会话存后端(`chat_session` / `chat_message` 两张表),App 只发 `sessionId + content`。**工具调用的中间过程不入库**,所以 App 不用理解 tool 消息格式。
|
||||||
- 助手气泡用 `MarkdownBody` 渲染(回答里常有列表/代码块),用户气泡用 `SelectableText`。
|
- 助手气泡用 `MarkdownBody` 渲染(回答里常有列表/代码块),用户气泡用 `SelectableText`。
|
||||||
- 后端未配 key 时不报错:`/api/ai/status` 返回 `enabled:false`,`/chat` 推一个 error 事件,页面顶部显示红色提示条。
|
- 后端未配 key 时不报错:`/api/ai/status` 返回 `enabled:false`,`/chat` 推一个 error 事件,页面顶部显示红色提示条。
|
||||||
|
- **AI 写库后必须让 App 侧的数据失效**(2026-09-24 修的真实 bug)。`AssistantNotifier.send` 里跟踪 SSE 的 `tool` 事件,命中 `_mutatingTools`(`create_bill` / `create_note`)就在对话结束后调 `invalidateBillData(ref)` 或失效那四个笔记 provider。
|
||||||
|
**为什么必须显式做**:`IndexedStack` 让首页仪表盘和「我的」页常驻,它们 watch 的 `monthlyStatisticsProvider` / `budgetProvider` / `accountProvider` **永远不会被 autoDispose 回收**,只靠「写操作后显式失效」更新。AI 是在服务端直接写库的,App 不知情 → 这三个一直是旧数。而 `billsProvider` 只被 push 出来的记账首页 watch,会被回收重取 —— 于是现象是**「账单列表对了、汇总和预算环没对」**,很有迷惑性。
|
||||||
|
回归测试在 `test/ai_refresh_test.dart`(用假 AiApi 吐 SSE 事件,断言 provider 的重建次数)。**加新的写工具时记得同步 `_mutatingTools`**,否则会静默失效。
|
||||||
|
|
||||||
## 设计规范(配色)
|
## 设计规范(配色)
|
||||||
- 品牌主色:`#C8956E`(驼色/焦糖色),在 `app.dart` 中作为 `ColorScheme.fromSeed` 的 seedColor。
|
- 品牌主色:`#C8956E`(驼色/焦糖色),在 `app.dart` 中作为 `ColorScheme.fromSeed` 的 seedColor。
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#D9A93C" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 2.5C12.6 7.8 16.2 11.4 21.5 12 16.2 12.6 12.6 16.2 12 21.55.8 16.2 9.4 12.6 4.2 12 9.4 11.4 12.6 7.8 12 2.5z" fill="#F8ECD2"/>
|
||||||
|
<path d="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" fill="#F8ECD2"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 426 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#C8956E" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M5.8 6.6h12.399999999999999a3 3 0 0 1 3 3v6.4a3 3 0 0 1 -3 3h-12.399999999999999a3 3 0 0 1 -3 -3v-6.4a3 3 0 0 1 3 -3z" fill="#F6E8DB"/>
|
||||||
|
<path d="M2.8 11.4h18.4" fill="none"/>
|
||||||
|
<path d="M14.899999999999999 15a1.5 1.5 0 1 0 3.0 0a1.5 1.5 0 1 0 -3.0 0z" fill="#F6E8DB"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 474 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#D99C3C" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M5.6 10h12.799999999999999a2.2 2.2 0 0 1 2.2 2.2v5.799999999999999a2.2 2.2 0 0 1 -2.2 2.2h-12.799999999999999a2.2 2.2 0 0 1 -2.2 -2.2v-5.799999999999999a2.2 2.2 0 0 1 2.2 -2.2z" fill="#F9EDD5"/>
|
||||||
|
<path d="M4.0 6.2h16.0a1.6 1.6 0 0 1 1.6 1.6v0.7999999999999998a1.6 1.6 0 0 1 -1.6 1.6h-16.0a1.6 1.6 0 0 1 -1.6 -1.6v-0.7999999999999998a1.6 1.6 0 0 1 1.6 -1.6z" fill="#F9EDD5"/>
|
||||||
|
<path d="M12 6.2v14" fill="none"/>
|
||||||
|
<path d="M12 6.2C10 3.4 6.6 4.2 7.6 6.6" fill="none"/>
|
||||||
|
<path d="M12 6.2c2-2.8 5.4-2 4.4.4" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 729 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M6 6.8h12a3 3 0 0 1 3 3v7.4a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-7.4a3 3 0 0 1 3 -3z" fill="#F1E7E0"/>
|
||||||
|
<path d="M8.2 13.4a3.8 3.8 0 1 0 7.6 0a3.8 3.8 0 1 0 -7.6 0z" fill="none"/>
|
||||||
|
<path d="M9 6.8l1.3-2.4h3.4L15 6.8" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 435 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3.4000000000000004 12a8.6 8.6 0 1 0 17.2 0a8.6 8.6 0 1 0 -17.2 0z" fill="#F1E7E0"/>
|
||||||
|
<path d="M8 12.3l2.9 2.9 5.2-6" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 334 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6B8E4E" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 4.4l9 4.6-9 4.6-9-4.6 9-4.6z" fill="#E4EDDA"/>
|
||||||
|
<path d="M20.6 9.2v5.4" fill="none"/>
|
||||||
|
<path d="M19.3 16.2a1.3 1.3 0 1 0 2.6 0a1.3 1.3 0 1 0 -2.6 0z" fill="#E4EDDA"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 376 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#9B7BC4" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M5.7 9.6h12.600000000000001a2.5 2.5 0 0 1 2.5 2.5v5.4a2.5 2.5 0 0 1 -2.5 2.5h-12.600000000000001a2.5 2.5 0 0 1 -2.5 -2.5v-5.4a2.5 2.5 0 0 1 2.5 -2.5z" fill="#EBE3F5"/>
|
||||||
|
<path d="M3.2 9.6h17.6" fill="none"/>
|
||||||
|
<path d="M7 6.2l-1.6 3.4M12 6.2l-1.6 3.4M17 6.2l-1.6 3.4" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 485 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3.4000000000000004 12a8.6 8.6 0 1 0 17.2 0a8.6 8.6 0 1 0 -17.2 0z" fill="#F1E7E0"/>
|
||||||
|
<path d="M12 7.6v5.2" fill="none"/>
|
||||||
|
<path d="M10.85 16.4a1.15 1.15 0 1 0 2.3 0a1.15 1.15 0 1 0 -2.3 0z" fill="#F1E7E0"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 413 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="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.4H15.4a3.4 3.4 0 0 1-6.8 0H4z" fill="#F1E7E0"/>
|
||||||
|
<path d="M12 14.6V4.4M7.8 8.6L12 4.4l4.2 4.2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 375 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="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.4S5.2 16.4 2.6 12z" fill="#F1E7E0"/>
|
||||||
|
<path d="M9.1 12a2.9 2.9 0 1 0 5.8 0a2.9 2.9 0 1 0 -5.8 0z" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M6 3h7.4L19 8.6V19a2.2 2.2 0 0 1-2.2 2.2H6A2.2 2.2 0 0 1 3.8 19V5.2A2.2 2.2 0 0 1 6 3z" fill="#F1E7E0"/>
|
||||||
|
<path d="M13.4 3v5.6H19" fill="none"/>
|
||||||
|
<path d="M7.6 13h8.8M7.6 16.6h5.4" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 400 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="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.5a2.2 2.2 0 0 1 2.2 2.2v1.2" fill="none"/>
|
||||||
|
<path d="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" fill="#F1E7E0"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 419 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="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.5a2.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" fill="#F1E7E0"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 358 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#E2914B" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M4 11h16a8 8 0 0 1-16 0z" fill="#FBE7D0"/>
|
||||||
|
<path d="M9.2 8.2c1-1.4-.9-2.6.1-4" fill="none"/>
|
||||||
|
<path d="M13.6 8.2c1-1.4-.9-2.6.1-4" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 351 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#B5794F" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="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" fill="#F0E2D6"/>
|
||||||
|
<path d="M11.4 14h1.2000000000000002a1.6 1.6 0 0 1 1.6 1.6v4.0a1.6 1.6 0 0 1 -1.6 1.6h-1.2000000000000002a1.6 1.6 0 0 1 -1.6 -1.6v-4.0a1.6 1.6 0 0 1 1.6 -1.6z" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 470 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M6 4.8h12a3 3 0 0 1 3 3v8.4a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-8.4a3 3 0 0 1 3 -3z" fill="#F1E7E0"/>
|
||||||
|
<path d="M4.4 16.6l4.6-5 3.4 3.8 3.2-3.4 4.6 4.8" fill="none"/>
|
||||||
|
<path d="M7.1 9.4a1.5 1.5 0 1 0 3.0 0a1.5 1.5 0 1 0 -3.0 0z" fill="#F1E7E0"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 451 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="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" fill="#F1E7E0"/>
|
||||||
|
<path d="M3 12.8h4.6l1.4 2.6h6l1.4-2.6H21" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 357 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="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.4H15.4a3.4 3.4 0 0 1-6.8 0H4z" fill="#F1E7E0"/>
|
||||||
|
<path d="M12 4.4v10.2M7.8 10.4L12 14.6l4.2-4.2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 377 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#4FA98A" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M4.8 14.4h0.40000000000000036a1.4 1.4 0 0 1 1.4 1.4v3.2a1.4 1.4 0 0 1 -1.4 1.4h-0.40000000000000036a1.4 1.4 0 0 1 -1.4 -1.4v-3.2a1.4 1.4 0 0 1 1.4 -1.4z" fill="#DBEFE8"/>
|
||||||
|
<path d="M11.8 11.4h0.40000000000000036a1.4 1.4 0 0 1 1.4 1.4v6.2a1.4 1.4 0 0 1 -1.4 1.4h-0.40000000000000036a1.4 1.4 0 0 1 -1.4 -1.4v-6.2a1.4 1.4 0 0 1 1.4 -1.4z" fill="#DBEFE8"/>
|
||||||
|
<path d="M18.799999999999997 8.4h0.40000000000000036a1.4 1.4 0 0 1 1.4 1.4v9.2a1.4 1.4 0 0 1 -1.4 1.4h-0.40000000000000036a1.4 1.4 0 0 1 -1.4 -1.4v-9.2a1.4 1.4 0 0 1 1.4 -1.4z" fill="#DBEFE8"/>
|
||||||
|
<path d="M4 11.6l5-4 3.4 2.8L19.6 5" fill="none"/>
|
||||||
|
<path d="M16 5h3.6v3.4" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 850 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M7.0 10.4h10.0a2.6 2.6 0 0 1 2.6 2.6v4.999999999999999a2.6 2.6 0 0 1 -2.6 2.6h-10.0a2.6 2.6 0 0 1 -2.6 -2.6v-4.999999999999999a2.6 2.6 0 0 1 2.6 -2.6z" fill="#F1E7E0"/>
|
||||||
|
<path d="M8.4 10.4V7.8a3.6 3.6 0 0 1 7.2 0v2.6" fill="none"/>
|
||||||
|
<path d="M10.8 15.4a1.2 1.2 0 1 0 2.4 0a1.2 1.2 0 1 0 -2.4 0z" fill="#F1E7E0"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 518 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#E0725C" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M8.6 3.6h6.800000000000001a5 5 0 0 1 5 5v6.800000000000001a5 5 0 0 1 -5 5h-6.800000000000001a5 5 0 0 1 -5 -5v-6.800000000000001a5 5 0 0 1 5 -5z" fill="#FAE2DD"/>
|
||||||
|
<path d="M12 8.4v7.2M8.4 12h7.2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 412 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#C8956E" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M7.0 3.4h10.0a2.6 2.6 0 0 1 2.6 2.6v12.0a2.6 2.6 0 0 1 -2.6 2.6h-10.0a2.6 2.6 0 0 1 -2.6 -2.6v-12.0a2.6 2.6 0 0 1 2.6 -2.6z" fill="#F6E8DB"/>
|
||||||
|
<path d="M8.6 3.4v17.2" fill="none"/>
|
||||||
|
<path d="M11.8 9h5M11.8 12.6h5" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 433 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#9A8C80" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M6.0 6.8h12.0a2.6 2.6 0 0 1 2.6 2.6v8.2a2.6 2.6 0 0 1 -2.6 2.6h-12.0a2.6 2.6 0 0 1 -2.6 -2.6v-8.2a2.6 2.6 0 0 1 2.6 -2.6z" fill="#EBE5E0"/>
|
||||||
|
<path d="M3.4 11.4h17.2" fill="none"/>
|
||||||
|
<path d="M12 6.8v4.6" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 422 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8A7BB8" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M5.6 8.4h12.8a2.6 2.6 0 0 1 2.6 2.6v6.999999999999999a2.6 2.6 0 0 1 -2.6 2.6h-12.8a2.6 2.6 0 0 1 -2.6 -2.6v-6.999999999999999a2.6 2.6 0 0 1 2.6 -2.6z" fill="#E8E3F4"/>
|
||||||
|
<path d="M9 8.4V7a1.6 1.6 0 0 1 1.6-1.6h2.8A1.6 1.6 0 0 1 15 7v1.4" fill="none"/>
|
||||||
|
<path d="M3 13.2h18" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 492 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#4FA0B0" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M9.4 2.8h5.199999999999999a3 3 0 0 1 3 3v12.399999999999999a3 3 0 0 1 -3 3h-5.199999999999999a3 3 0 0 1 -3 -3v-12.399999999999999a3 3 0 0 1 3 -3z" fill="#DBEDF1"/>
|
||||||
|
<path d="M10.6 5.6h2.8" fill="none"/>
|
||||||
|
<path d="M11 18.4h2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 444 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M8.8 3.2h6.4l-1.1 5.4 3 2.9v1.6H6.9v-1.6l3-2.9z" fill="#F1E7E0"/>
|
||||||
|
<path d="M12 13.1v7.6" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 306 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#C8956E" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M5.0 6.4h14.0a2.6 2.6 0 0 1 2.6 2.6v5.999999999999999a2.6 2.6 0 0 1 -2.6 2.6h-14.0a2.6 2.6 0 0 1 -2.6 -2.6v-5.999999999999999a2.6 2.6 0 0 1 2.6 -2.6z" fill="#F6E8DB"/>
|
||||||
|
<path d="M8.9 12a3.1 3.1 0 1 0 6.2 0a3.1 3.1 0 1 0 -6.2 0z" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 445 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M4.0 10.4a6.4 6.4 0 1 0 12.8 0a6.4 6.4 0 1 0 -12.8 0z" fill="#F1E7E0"/>
|
||||||
|
<path d="M15.2 15.2L20.6 20.6" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 320 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#D97C93" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M7.8 8.6h8.4a3 3 0 0 1 3 3v5.800000000000001a3 3 0 0 1 -3 3h-8.4a3 3 0 0 1 -3 -3v-5.800000000000001a3 3 0 0 1 3 -3z" fill="#F7E2E8"/>
|
||||||
|
<path d="M9 8.6V7a3 3 0 0 1 6 0v1.6" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 388 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3.4000000000000004 12a8.6 8.6 0 1 0 17.2 0a8.6 8.6 0 1 0 -17.2 0z" fill="#F1E7E0"/>
|
||||||
|
<path d="M7.4 12a4.6 4.6 0 1 0 9.2 0a4.6 4.6 0 1 0 -9.2 0z" fill="none"/>
|
||||||
|
<path d="M10.5 12a1.5 1.5 0 1 0 3.0 0a1.5 1.5 0 1 0 -3.0 0z" fill="#F1E7E0"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 444 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#5B8DB8" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M9.6 4.8h4.8a2.6 2.6 0 0 1 2.6 2.6v0.20000000000000018a2.6 2.6 0 0 1 -2.6 2.6h-4.8a2.6 2.6 0 0 1 -2.6 -2.6v-0.20000000000000018a2.6 2.6 0 0 1 2.6 -2.6z" fill="#DCE8F2"/>
|
||||||
|
<path d="M6.2 9.4h11.600000000000001a3 3 0 0 1 3 3v1.5999999999999996a3 3 0 0 1 -3 3h-11.600000000000001a3 3 0 0 1 -3 -3v-1.5999999999999996a3 3 0 0 1 3 -3z" fill="#DCE8F2"/>
|
||||||
|
<path d="M6.1 17.2a1.9 1.9 0 1 0 3.8 0a1.9 1.9 0 1 0 -3.8 0z" fill="#DCE8F2"/>
|
||||||
|
<path d="M14.1 17.2a1.9 1.9 0 1 0 3.8 0a1.9 1.9 0 1 0 -3.8 0z" fill="#DCE8F2"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 715 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M8.1 8.4a3.9 3.9 0 1 0 7.8 0a3.9 3.9 0 1 0 -7.8 0z" fill="#F1E7E0"/>
|
||||||
|
<path d="M4.8 20.4c0-4 3.2-6.6 7.2-6.6s7.2 2.6 7.2 6.6z" fill="#F1E7E0"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 346 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#D9A93C" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="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-.6c0-.9.5-1.7 1.3-2.3A6.4 6.4 0 0 0 12 2.8z" fill="#F8ECD2"/>
|
||||||
|
<path d="M9.8 20.2h4.4M10.8 22.2h2.4" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 377 B |
@@ -30,7 +30,7 @@ class AppModule {
|
|||||||
/// 一句话说明,让用户在首页就能看懂这个模块是干什么的
|
/// 一句话说明,让用户在首页就能看懂这个模块是干什么的
|
||||||
final String blurb;
|
final String blurb;
|
||||||
|
|
||||||
/// 用 emoji 当图标 —— 和项目现有的卡片风格一致(笔记入口、预算提示都是 emoji)
|
/// 图标名,对应 `assets/icons/<name>.svg`(见 [cuteIconNames])
|
||||||
final String icon;
|
final String icon;
|
||||||
|
|
||||||
/// 打开模块入口页
|
/// 打开模块入口页
|
||||||
@@ -53,7 +53,7 @@ final List<AppModule> appModules = [
|
|||||||
id: 'bill',
|
id: 'bill',
|
||||||
name: '记账',
|
name: '记账',
|
||||||
blurb: '收支记录、统计与预算',
|
blurb: '收支记录、统计与预算',
|
||||||
icon: '💰',
|
icon: 'bill',
|
||||||
open: (_) => const BillHomePage(),
|
open: (_) => const BillHomePage(),
|
||||||
summary: (_) => const BillModuleSummary(),
|
summary: (_) => const BillModuleSummary(),
|
||||||
),
|
),
|
||||||
@@ -61,7 +61,7 @@ final List<AppModule> appModules = [
|
|||||||
id: 'note',
|
id: 'note',
|
||||||
name: '笔记',
|
name: '笔记',
|
||||||
blurb: 'Markdown 知识库,随时查看和记录',
|
blurb: 'Markdown 知识库,随时查看和记录',
|
||||||
icon: '📝',
|
icon: 'note',
|
||||||
open: (_) => const NoteListPage(),
|
open: (_) => const NoteListPage(),
|
||||||
summary: (_) => const NoteModuleSummary(),
|
summary: (_) => const NoteModuleSummary(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class Bill {
|
|||||||
id: asInt(json['id']),
|
id: asInt(json['id']),
|
||||||
categoryId: asInt(json['categoryId']),
|
categoryId: asInt(json['categoryId']),
|
||||||
categoryName: asNonEmpty(json['categoryName'], '未分类'),
|
categoryName: asNonEmpty(json['categoryName'], '未分类'),
|
||||||
categoryIcon: asNonEmpty(json['categoryIcon'], '📦'),
|
categoryIcon: asNonEmpty(json['categoryIcon'], 'other'),
|
||||||
amount: asDouble(json['amount']),
|
amount: asDouble(json['amount']),
|
||||||
description: asString(json['description']),
|
description: asString(json['description']),
|
||||||
billDate: parseBillDate(asString(json['billDate'])),
|
billDate: parseBillDate(asString(json['billDate'])),
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class Category {
|
|||||||
factory Category.fromJson(Map<String, dynamic> json) => Category(
|
factory Category.fromJson(Map<String, dynamic> json) => Category(
|
||||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||||
name: json['name']?.toString() ?? '',
|
name: json['name']?.toString() ?? '',
|
||||||
icon: json['icon']?.toString() ?? '📦',
|
icon: json['icon']?.toString() ?? 'other',
|
||||||
type: (json['type'] as num?)?.toInt() ?? 1,
|
type: (json['type'] as num?)?.toInt() ?? 1,
|
||||||
sortOrder: (json['sortOrder'] as num?)?.toInt(),
|
sortOrder: (json['sortOrder'] as num?)?.toInt(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class CategoryStat {
|
|||||||
factory CategoryStat.fromJson(Map<String, dynamic> json) => CategoryStat(
|
factory CategoryStat.fromJson(Map<String, dynamic> json) => CategoryStat(
|
||||||
categoryId: asInt(json['categoryId']),
|
categoryId: asInt(json['categoryId']),
|
||||||
categoryName: asNonEmpty(json['categoryName'], '未分类'),
|
categoryName: asNonEmpty(json['categoryName'], '未分类'),
|
||||||
categoryIcon: asNonEmpty(json['categoryIcon'], '📦'),
|
categoryIcon: asNonEmpty(json['categoryIcon'], 'other'),
|
||||||
amount: asDouble(json['amount']),
|
amount: asDouble(json['amount']),
|
||||||
type: asInt(json['type'], fallback: 1) == 2 ? 2 : 1,
|
type: asInt(json['type'], fallback: 1) == 2 ? 2 : 1,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../../../utils/date_util.dart';
|
|||||||
import '../../../utils/toast.dart';
|
import '../../../utils/toast.dart';
|
||||||
import '../../../widgets/category_picker_grid.dart';
|
import '../../../widgets/category_picker_grid.dart';
|
||||||
import '../../../widgets/state_views.dart';
|
import '../../../widgets/state_views.dart';
|
||||||
|
import '../../../widgets/cute_icon.dart';
|
||||||
|
|
||||||
/// 手动记账表单(复刻 uniApp add.vue 手动 Tab)
|
/// 手动记账表单(复刻 uniApp add.vue 手动 Tab)
|
||||||
class ManualEntryTab extends ConsumerStatefulWidget {
|
class ManualEntryTab extends ConsumerStatefulWidget {
|
||||||
@@ -78,9 +79,9 @@ class _ManualEntryTabState extends ConsumerState<ManualEntryTab> {
|
|||||||
Widget _typeSelector(AddBillFormState form, AddBillFormNotifier notifier) {
|
Widget _typeSelector(AddBillFormState form, AddBillFormNotifier notifier) {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
_typeCard('📤', '支出', 1, form, notifier),
|
_typeCard('expense', '支出', 1, form, notifier),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
_typeCard('📥', '收入', 2, form, notifier),
|
_typeCard('income', '收入', 2, form, notifier),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -107,7 +108,7 @@ class _ManualEntryTabState extends ConsumerState<ManualEntryTab> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(icon, style: const TextStyle(fontSize: 20)),
|
CuteIcon(icon, size: 22),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
@@ -228,7 +229,7 @@ class _ManualEntryTabState extends ConsumerState<ManualEntryTab> {
|
|||||||
await notifier.submit();
|
await notifier.submit();
|
||||||
_amountController.clear();
|
_amountController.clear();
|
||||||
_descriptionController.clear();
|
_descriptionController.clear();
|
||||||
if (context.mounted) showToast(context, '✅ 记账成功');
|
if (context.mounted) showToast(context, '记账成功', icon: 'check');
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
if (context.mounted) showToast(context, e.message, error: true);
|
if (context.mounted) showToast(context, e.message, error: true);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '../../../core/network/api_exception.dart';
|
|||||||
import '../../../providers/ocr_provider.dart';
|
import '../../../providers/ocr_provider.dart';
|
||||||
import '../../../utils/toast.dart';
|
import '../../../utils/toast.dart';
|
||||||
import 'ocr_result_card.dart';
|
import 'ocr_result_card.dart';
|
||||||
|
import '../../../widgets/cute_icon.dart';
|
||||||
|
|
||||||
/// OCR 识别 Tab:选图 → 识别 → 结果列表(复刻 uniApp add.vue OCR Tab)
|
/// OCR 识别 Tab:选图 → 识别 → 结果列表(复刻 uniApp add.vue OCR Tab)
|
||||||
class OcrTab extends ConsumerWidget {
|
class OcrTab extends ConsumerWidget {
|
||||||
@@ -38,7 +39,7 @@ class OcrTab extends ConsumerWidget {
|
|||||||
Future<void> _saveDirectly(BuildContext context, WidgetRef ref) async {
|
Future<void> _saveDirectly(BuildContext context, WidgetRef ref) async {
|
||||||
try {
|
try {
|
||||||
await ref.read(ocrProvider.notifier).saveDirectly();
|
await ref.read(ocrProvider.notifier).saveDirectly();
|
||||||
if (context.mounted) showToast(context, '✅ 已保存识别的账单');
|
if (context.mounted) showToast(context, '已保存识别的账单', icon: 'check');
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
if (context.mounted) showToast(context, e.message, error: true);
|
if (context.mounted) showToast(context, e.message, error: true);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -86,7 +87,7 @@ class OcrTab extends ConsumerWidget {
|
|||||||
child: const Column(
|
child: const Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text('📷', style: TextStyle(fontSize: 36)),
|
CuteIcon('camera', size: 40),
|
||||||
SizedBox(height: 6),
|
SizedBox(height: 6),
|
||||||
Text('点击选择图片', style: TextStyle(color: Color(0xFF8D6E63))),
|
Text('点击选择图片', style: TextStyle(color: Color(0xFF8D6E63))),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../../models/ai.dart';
|
|||||||
import '../../providers/ai_provider.dart';
|
import '../../providers/ai_provider.dart';
|
||||||
import '../../utils/toast.dart';
|
import '../../utils/toast.dart';
|
||||||
import '../../widgets/state_views.dart';
|
import '../../widgets/state_views.dart';
|
||||||
|
import '../../widgets/cute_icon.dart';
|
||||||
|
|
||||||
/// 助手:和 AI 对话,问账单、问笔记。
|
/// 助手:和 AI 对话,问账单、问笔记。
|
||||||
///
|
///
|
||||||
@@ -32,6 +33,17 @@ class _AssistantPageState extends ConsumerState<AssistantPage> {
|
|||||||
'我的笔记里有哪些关于 MySQL 的?',
|
'我的笔记里有哪些关于 MySQL 的?',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// 打开就把上一次的对话展开,而不是每次都从空白开始。
|
||||||
|
// 放进 postFrameCallback:initState 里改 provider 状态会撞上「构建期间改状态」。
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ref.read(assistantProvider.notifier).restoreLastSessionIfNeeded();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_input.dispose();
|
_input.dispose();
|
||||||
@@ -156,7 +168,7 @@ class _AssistantPageState extends ConsumerState<AssistantPage> {
|
|||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 40, 24, 0),
|
padding: const EdgeInsets.fromLTRB(24, 40, 24, 0),
|
||||||
children: [
|
children: [
|
||||||
const Center(child: Text('✨', style: TextStyle(fontSize: 44))),
|
const Center(child: CuteIcon('assistant', size: 48)),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
const Center(
|
const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../../utils/date_util.dart';
|
|||||||
import '../../utils/toast.dart';
|
import '../../utils/toast.dart';
|
||||||
import '../../widgets/category_picker_grid.dart';
|
import '../../widgets/category_picker_grid.dart';
|
||||||
import '../../widgets/state_views.dart';
|
import '../../widgets/state_views.dart';
|
||||||
|
import '../../widgets/cute_icon.dart';
|
||||||
|
|
||||||
/// 账单编辑页(复刻 uniApp pages/bill/detail.vue 的编辑能力,并补上删除)
|
/// 账单编辑页(复刻 uniApp pages/bill/detail.vue 的编辑能力,并补上删除)
|
||||||
class BillEditPage extends ConsumerStatefulWidget {
|
class BillEditPage extends ConsumerStatefulWidget {
|
||||||
@@ -117,9 +118,9 @@ class _BillEditPageState extends ConsumerState<BillEditPage> {
|
|||||||
Widget _typeSelector(BillEditState state, BillEditNotifier notifier) {
|
Widget _typeSelector(BillEditState state, BillEditNotifier notifier) {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
_typeCard('📤', '支出', 1, state, notifier),
|
_typeCard('expense', '支出', 1, state, notifier),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
_typeCard('📥', '收入', 2, state, notifier),
|
_typeCard('income', '收入', 2, state, notifier),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -149,7 +150,7 @@ class _BillEditPageState extends ConsumerState<BillEditPage> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(icon, style: const TextStyle(fontSize: 20)),
|
CuteIcon(icon, size: 22),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
@@ -314,7 +315,7 @@ class _BillEditPageState extends ConsumerState<BillEditPage> {
|
|||||||
try {
|
try {
|
||||||
await notifier.save();
|
await notifier.save();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
showToast(context, '✅ 已保存');
|
showToast(context, '已保存', icon: 'check');
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
if (mounted) showToast(context, e.message, error: true);
|
if (mounted) showToast(context, e.message, error: true);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import 'bill_statistics_page.dart';
|
|||||||
import 'widgets/bill_group_card.dart';
|
import 'widgets/bill_group_card.dart';
|
||||||
import 'widgets/budget_card.dart';
|
import 'widgets/budget_card.dart';
|
||||||
import 'widgets/month_switcher.dart';
|
import 'widgets/month_switcher.dart';
|
||||||
|
import '../../widgets/cute_icon.dart';
|
||||||
|
|
||||||
/// 记账模块的首页:月份切换 + 本月汇总 + 预算环 + 按日期分组的账单列表。
|
/// 记账模块的首页:月份切换 + 本月汇总 + 预算环 + 按日期分组的账单列表。
|
||||||
///
|
///
|
||||||
@@ -184,7 +185,7 @@ class BillHomePage extends ConsumerWidget {
|
|||||||
child: const Row(
|
child: const Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text('🎯 ', style: TextStyle(fontSize: 14)),
|
CuteIcon('target', size: 15),
|
||||||
Text(
|
Text(
|
||||||
'仅可查看本月预算',
|
'仅可查看本月预算',
|
||||||
style: TextStyle(fontSize: 13, color: Color(0xFF8D6E63)),
|
style: TextStyle(fontSize: 13, color: Color(0xFF8D6E63)),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import '../../../models/bill.dart';
|
|||||||
import '../../../utils/amount_util.dart';
|
import '../../../utils/amount_util.dart';
|
||||||
import '../../../utils/bill_grouping.dart';
|
import '../../../utils/bill_grouping.dart';
|
||||||
import '../../../utils/date_util.dart';
|
import '../../../utils/date_util.dart';
|
||||||
|
import '../../../widgets/cute_icon.dart';
|
||||||
|
|
||||||
/// 单日分组卡:组头(日期 + 分开的支出/收入小计)+ 账单条目
|
/// 单日分组卡:组头(日期 + 分开的支出/收入小计)+ 账单条目
|
||||||
class BillGroupCard extends StatelessWidget {
|
class BillGroupCard extends StatelessWidget {
|
||||||
@@ -91,7 +92,7 @@ class _BillTile extends StatelessWidget {
|
|||||||
color: Color(0xFFFBEEDD),
|
color: Color(0xFFFBEEDD),
|
||||||
),
|
),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Text(bill.categoryIcon, style: const TextStyle(fontSize: 19)),
|
child: CuteIcon(bill.categoryIcon, size: 21),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../../../models/budget.dart';
|
import '../../../models/budget.dart';
|
||||||
import '../../../utils/amount_util.dart';
|
import '../../../utils/amount_util.dart';
|
||||||
|
import '../../../widgets/cute_icon.dart';
|
||||||
import '../../../widgets/donut_chart.dart';
|
import '../../../widgets/donut_chart.dart';
|
||||||
import '../../../widgets/state_views.dart';
|
import '../../../widgets/state_views.dart';
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ class BudgetCard extends StatelessWidget {
|
|||||||
|
|
||||||
Widget _noBudget() {
|
Widget _noBudget() {
|
||||||
return const EmptyView(
|
return const EmptyView(
|
||||||
icon: '🎯',
|
icon: 'target',
|
||||||
title: '本月未设置预算',
|
title: '本月未设置预算',
|
||||||
tip: '可在「我的 → 预算设置」中设置',
|
tip: '可在「我的 → 预算设置」中设置',
|
||||||
padding: EdgeInsets.symmetric(vertical: 20),
|
padding: EdgeInsets.symmetric(vertical: 20),
|
||||||
@@ -110,7 +111,9 @@ class BudgetCard extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Text('💰 日均可用 ', style: TextStyle(fontSize: 13, color: Color(0xFF6D4C41))),
|
const CuteIcon('target', size: 15),
|
||||||
|
const SizedBox(width: 5),
|
||||||
|
const Text('日均可用 ', style: TextStyle(fontSize: 13, color: Color(0xFF6D4C41))),
|
||||||
Text(
|
Text(
|
||||||
'¥${formatAmount(info.remainingDaily)}',
|
'¥${formatAmount(info.remainingDaily)}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../../../models/statistics_response.dart';
|
import '../../../models/statistics_response.dart';
|
||||||
import '../../../utils/amount_util.dart';
|
import '../../../utils/amount_util.dart';
|
||||||
|
import '../../../widgets/cute_icon.dart';
|
||||||
|
|
||||||
/// 分类排行:emoji + 名称 + 进度条 + 金额 + 占比
|
/// 分类排行:emoji + 名称 + 进度条 + 金额 + 占比
|
||||||
class CategoryRankList extends StatelessWidget {
|
class CategoryRankList extends StatelessWidget {
|
||||||
@@ -50,7 +51,7 @@ class CategoryRankList extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(item.categoryIcon, style: const TextStyle(fontSize: 16)),
|
CuteIcon(item.categoryIcon, size: 18),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../core/module/app_module.dart';
|
import '../../core/module/app_module.dart';
|
||||||
|
import '../../widgets/cute_icon.dart';
|
||||||
import '../assistant/assistant_page.dart';
|
import '../assistant/assistant_page.dart';
|
||||||
|
|
||||||
/// 首页仪表盘:AI 输入入口 + 模块卡片网格。
|
/// 首页仪表盘:AI 输入入口 + 模块卡片网格。
|
||||||
@@ -106,7 +107,7 @@ class DashboardPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Text('✨', style: TextStyle(fontSize: 18)),
|
const CuteIcon('assistant', size: 19),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
const Expanded(
|
const Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -175,7 +176,7 @@ class DashboardPage extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(module.icon, style: const TextStyle(fontSize: 24)),
|
CuteIcon(module.icon, size: 26),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
module.name,
|
module.name,
|
||||||
|
|||||||
@@ -10,8 +10,13 @@ import '../../providers/auth_provider.dart';
|
|||||||
import '../../utils/crypto.dart';
|
import '../../utils/crypto.dart';
|
||||||
import '../../utils/toast.dart';
|
import '../../utils/toast.dart';
|
||||||
import '../shell/main_shell.dart';
|
import '../shell/main_shell.dart';
|
||||||
|
import '../../widgets/cute_icon.dart';
|
||||||
|
|
||||||
/// 登录/注册页(复刻 uniApp pages/login/login.vue)
|
/// 登录页(复刻 uniApp pages/login/login.vue)
|
||||||
|
///
|
||||||
|
/// **注册功能已下线**(2026-09-24):这是单人应用,开放注册等于任何人都能建号。
|
||||||
|
/// App 端的入口和界面都去掉了;后端 `/api/auth/register` 按用户要求保留着,
|
||||||
|
/// 真要加人时把入口加回来即可。
|
||||||
class LoginPage extends ConsumerStatefulWidget {
|
class LoginPage extends ConsumerStatefulWidget {
|
||||||
const LoginPage({super.key});
|
const LoginPage({super.key});
|
||||||
|
|
||||||
@@ -21,15 +26,12 @@ class LoginPage extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _LoginPageState extends ConsumerState<LoginPage>
|
class _LoginPageState extends ConsumerState<LoginPage>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
bool _isRegister = false;
|
|
||||||
bool _showPassword = false;
|
bool _showPassword = false;
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
bool _agreeProtocol = false;
|
|
||||||
bool _rememberMe = false;
|
bool _rememberMe = false;
|
||||||
|
|
||||||
final _usernameController = TextEditingController();
|
final _usernameController = TextEditingController();
|
||||||
final _passwordController = TextEditingController();
|
final _passwordController = TextEditingController();
|
||||||
final _nicknameController = TextEditingController();
|
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
late final AnimationController _floatController = AnimationController(
|
late final AnimationController _floatController = AnimationController(
|
||||||
@@ -53,7 +55,6 @@ class _LoginPageState extends ConsumerState<LoginPage>
|
|||||||
_floatController.dispose();
|
_floatController.dispose();
|
||||||
_usernameController.dispose();
|
_usernameController.dispose();
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
_nicknameController.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,43 +67,24 @@ class _LoginPageState extends ConsumerState<LoginPage>
|
|||||||
showToast(context, '请输入用户名和密码', error: true);
|
showToast(context, '请输入用户名和密码', error: true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_isRegister) {
|
|
||||||
if (password.length < 6) {
|
|
||||||
showToast(context, '密码至少6位', error: true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!_agreeProtocol) {
|
|
||||||
showToast(context, '请先阅读并同意用户协议');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
try {
|
||||||
final api = ref.read(authApiProvider);
|
final response = await ref.read(authApiProvider).login(username, password);
|
||||||
final response = _isRegister
|
|
||||||
? await api.register(
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
_nicknameController.text.trim().isEmpty
|
|
||||||
? username
|
|
||||||
: _nicknameController.text.trim(),
|
|
||||||
)
|
|
||||||
: await api.login(username, password);
|
|
||||||
|
|
||||||
// 记住密码
|
// 记住密码
|
||||||
if (!_isRegister && _rememberMe) {
|
if (_rememberMe) {
|
||||||
await LocalStorage.instance.saveRememberedPassword(
|
await LocalStorage.instance.saveRememberedPassword(
|
||||||
username,
|
username,
|
||||||
encryptPassword(password),
|
encryptPassword(password),
|
||||||
);
|
);
|
||||||
} else if (!_isRegister && !_rememberMe) {
|
} else {
|
||||||
await LocalStorage.instance.clearRememberedPassword();
|
await LocalStorage.instance.clearRememberedPassword();
|
||||||
}
|
}
|
||||||
|
|
||||||
await ref.read(authProvider.notifier).loginSuccess(response);
|
await ref.read(authProvider.notifier).loginSuccess(response);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
showToast(context, _isRegister ? '🎉 注册成功' : '🚀 登录成功');
|
showToast(context, '登录成功', icon: 'check');
|
||||||
Navigator.of(context).pushAndRemoveUntil(
|
Navigator.of(context).pushAndRemoveUntil(
|
||||||
MaterialPageRoute(builder: (_) => const MainShell()),
|
MaterialPageRoute(builder: (_) => const MainShell()),
|
||||||
(route) => false,
|
(route) => false,
|
||||||
@@ -308,90 +290,55 @@ class _LoginPageState extends ConsumerState<LoginPage>
|
|||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_buildSegmentTabs(),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
_inputField(
|
_inputField(
|
||||||
icon: '👤',
|
icon: 'user',
|
||||||
controller: _usernameController,
|
controller: _usernameController,
|
||||||
hint: '请输入用户名',
|
hint: '请输入用户名',
|
||||||
maxLength: 20,
|
maxLength: 20,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_inputField(
|
_inputField(
|
||||||
icon: '🔒',
|
icon: 'lock',
|
||||||
controller: _passwordController,
|
controller: _passwordController,
|
||||||
hint: '请输入密码',
|
hint: '请输入密码',
|
||||||
maxLength: 20,
|
maxLength: 20,
|
||||||
obscure: !_showPassword,
|
obscure: !_showPassword,
|
||||||
suffix: IconButton(
|
suffix: IconButton(
|
||||||
icon: Text(
|
// 一个图标用颜色表示状态:显示中=品牌色,隐藏中=浅灰
|
||||||
_showPassword ? '👁️' : '👁️🗨️',
|
icon: CuteIcon(
|
||||||
style: const TextStyle(fontSize: 16),
|
'eye',
|
||||||
|
size: 18,
|
||||||
|
tint: _showPassword
|
||||||
|
? const Color(0xFFC8956E)
|
||||||
|
: const Color(0xFFBCAAA4),
|
||||||
),
|
),
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
setState(() => _showPassword = !_showPassword),
|
setState(() => _showPassword = !_showPassword),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_isRegister) ...[
|
const SizedBox(height: 8),
|
||||||
const SizedBox(height: 14),
|
Row(
|
||||||
_inputField(
|
children: [
|
||||||
icon: '✨',
|
SizedBox(
|
||||||
controller: _nicknameController,
|
width: 22,
|
||||||
hint: '请输入昵称(可选)',
|
height: 22,
|
||||||
maxLength: 20,
|
child: Checkbox(
|
||||||
),
|
value: _rememberMe,
|
||||||
],
|
activeColor: const Color(0xFFC8956E),
|
||||||
if (!_isRegister) ...[
|
onChanged: (v) =>
|
||||||
const SizedBox(height: 8),
|
setState(() => _rememberMe = v ?? false),
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 22,
|
|
||||||
height: 22,
|
|
||||||
child: Checkbox(
|
|
||||||
value: _rememberMe,
|
|
||||||
activeColor: const Color(0xFFC8956E),
|
|
||||||
onChanged: (v) =>
|
|
||||||
setState(() => _rememberMe = v ?? false),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
),
|
||||||
const Text(
|
const SizedBox(width: 6),
|
||||||
'记住密码',
|
const Text(
|
||||||
style: TextStyle(
|
'记住密码',
|
||||||
fontSize: 13,
|
style: TextStyle(
|
||||||
color: Color(0xFF8D6E63),
|
fontSize: 13,
|
||||||
),
|
color: Color(0xFF8D6E63),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
if (_isRegister) ...[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 22,
|
|
||||||
height: 22,
|
|
||||||
child: Checkbox(
|
|
||||||
value: _agreeProtocol,
|
|
||||||
activeColor: const Color(0xFFC8956E),
|
|
||||||
onChanged: (v) =>
|
|
||||||
setState(() => _agreeProtocol = v ?? false),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Expanded(
|
|
||||||
child: Text(
|
|
||||||
'我已阅读并同意用户协议',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: Color(0xFF8D6E63),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -414,9 +361,9 @@ class _LoginPageState extends ConsumerState<LoginPage>
|
|||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Text(
|
: const Text(
|
||||||
_isRegister ? '注 册' : '登 录',
|
'登 录',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
letterSpacing: 4,
|
letterSpacing: 4,
|
||||||
@@ -437,52 +384,6 @@ class _LoginPageState extends ConsumerState<LoginPage>
|
|||||||
sigmaY: 10,
|
sigmaY: 10,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---- 登录/注册切换 ----
|
|
||||||
Widget _buildSegmentTabs() {
|
|
||||||
return Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0x33C8956E),
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(4),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
_segment(
|
|
||||||
'登录',
|
|
||||||
!_isRegister,
|
|
||||||
() => setState(() => _isRegister = false),
|
|
||||||
),
|
|
||||||
_segment('注册', _isRegister, () => setState(() => _isRegister = true)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _segment(String text, bool active, VoidCallback onTap) {
|
|
||||||
return Expanded(
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: onTap,
|
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
color: active ? const Color(0xFFC8956E) : Colors.transparent,
|
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
text,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: active ? Colors.white : const Color(0xFF8D6E63),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _inputField({
|
Widget _inputField({
|
||||||
required String icon,
|
required String icon,
|
||||||
@@ -501,7 +402,7 @@ class _LoginPageState extends ConsumerState<LoginPage>
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(icon, style: const TextStyle(fontSize: 18)),
|
CuteIcon(icon, size: 20),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import '../../providers/budget_provider.dart';
|
|||||||
import '../../utils/amount_input_formatter.dart';
|
import '../../utils/amount_input_formatter.dart';
|
||||||
import '../../utils/amount_util.dart';
|
import '../../utils/amount_util.dart';
|
||||||
import '../../utils/toast.dart';
|
import '../../utils/toast.dart';
|
||||||
|
import '../../widgets/cute_icon.dart';
|
||||||
import '../../widgets/state_views.dart';
|
import '../../widgets/state_views.dart';
|
||||||
|
|
||||||
/// 「我的」页面
|
/// 「我的」页面
|
||||||
@@ -101,7 +102,7 @@ class MinePage extends ConsumerWidget {
|
|||||||
height: 60,
|
height: 60,
|
||||||
decoration: const BoxDecoration(shape: BoxShape.circle, color: Colors.white),
|
decoration: const BoxDecoration(shape: BoxShape.circle, color: Colors.white),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: const Text('👤', style: TextStyle(fontSize: 30)),
|
child: const CuteIcon('user', size: 32),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -338,7 +339,9 @@ class MinePage extends ConsumerWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Text('💰 日均可用 ', style: TextStyle(fontSize: 13, color: Color(0xFF6D4C41))),
|
const CuteIcon('target', size: 15),
|
||||||
|
const SizedBox(width: 5),
|
||||||
|
const Text('日均可用 ', style: TextStyle(fontSize: 13, color: Color(0xFF6D4C41))),
|
||||||
Text(
|
Text(
|
||||||
'¥${formatAmount(budget.remainingDaily)}',
|
'¥${formatAmount(budget.remainingDaily)}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@@ -408,7 +411,7 @@ class MinePage extends ConsumerWidget {
|
|||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(module.icon, style: const TextStyle(fontSize: 20)),
|
CuteIcon(module.icon, size: 21),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -456,7 +459,7 @@ class MinePage extends ConsumerWidget {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await ref.read(accountProvider.notifier).setBalance(input);
|
await ref.read(accountProvider.notifier).setBalance(input);
|
||||||
if (context.mounted) showToast(context, '✅ 余额已更新');
|
if (context.mounted) showToast(context, '余额已更新', icon: 'check');
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
if (context.mounted) showToast(context, e.message, error: true);
|
if (context.mounted) showToast(context, e.message, error: true);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -477,7 +480,7 @@ class MinePage extends ConsumerWidget {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await ref.read(budgetProvider.notifier).setMonthBudget(input);
|
await ref.read(budgetProvider.notifier).setMonthBudget(input);
|
||||||
if (context.mounted) showToast(context, '✅ 预算已保存');
|
if (context.mounted) showToast(context, '预算已保存', icon: 'check');
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
if (context.mounted) showToast(context, e.message, error: true);
|
if (context.mounted) showToast(context, e.message, error: true);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../models/note.dart';
|
import '../../models/note.dart';
|
||||||
|
import '../../widgets/cute_icon.dart';
|
||||||
|
|
||||||
/// 目录选择面板
|
/// 目录选择面板
|
||||||
///
|
///
|
||||||
@@ -114,7 +115,7 @@ class _FolderPickerSheetState extends State<FolderPickerSheet> {
|
|||||||
children: [
|
children: [
|
||||||
if (widget.showAll)
|
if (widget.showAll)
|
||||||
_row(
|
_row(
|
||||||
icon: '📚',
|
icon: 'education',
|
||||||
name: '全部',
|
name: '全部',
|
||||||
count: widget.tree.totalCount,
|
count: widget.tree.totalCount,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
@@ -122,7 +123,7 @@ class _FolderPickerSheetState extends State<FolderPickerSheet> {
|
|||||||
onTap: () => Navigator.pop(context, NoteLocation.all),
|
onTap: () => Navigator.pop(context, NoteLocation.all),
|
||||||
),
|
),
|
||||||
_row(
|
_row(
|
||||||
icon: '📥',
|
icon: 'income',
|
||||||
name: '未分类',
|
name: '未分类',
|
||||||
count: widget.tree.uncategorizedCount,
|
count: widget.tree.uncategorizedCount,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
@@ -153,7 +154,7 @@ class _FolderPickerSheetState extends State<FolderPickerSheet> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_row(
|
_row(
|
||||||
icon: (open && hasChildren) ? '📂' : '📁',
|
icon: (open && hasChildren) ? 'folder-open' : 'folder',
|
||||||
name: node.name,
|
name: node.name,
|
||||||
count: node.noteCount,
|
count: node.noteCount,
|
||||||
depth: depth,
|
depth: depth,
|
||||||
@@ -221,7 +222,7 @@ class _FolderPickerSheetState extends State<FolderPickerSheet> {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(icon, style: const TextStyle(fontSize: 15)),
|
CuteIcon(icon, size: 17),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ class NoteDetailPage extends ConsumerWidget {
|
|||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'🖼 图片加载失败',
|
'图片加载失败',
|
||||||
style: TextStyle(fontSize: 13, color: Color(0xFFBCAAA4)),
|
style: TextStyle(fontSize: 13, color: Color(0xFFBCAAA4)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -277,7 +277,7 @@ class NoteDetailPage extends ConsumerWidget {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
Clipboard.setData(ClipboardData(text: resolveAssetUrl(href)));
|
Clipboard.setData(ClipboardData(text: resolveAssetUrl(href)));
|
||||||
Navigator.pop(sheetContext);
|
Navigator.pop(sheetContext);
|
||||||
showToast(context, '✅ 已复制链接');
|
showToast(context, '已复制链接', icon: 'check');
|
||||||
},
|
},
|
||||||
style: FilledButton.styleFrom(backgroundColor: const Color(0xFFC8956E)),
|
style: FilledButton.styleFrom(backgroundColor: const Color(0xFFC8956E)),
|
||||||
child: const Text('复制'),
|
child: const Text('复制'),
|
||||||
@@ -358,7 +358,7 @@ class NoteDetailPage extends ConsumerWidget {
|
|||||||
await ref.read(noteApiProvider).update(note.id, pinned: !note.pinned);
|
await ref.read(noteApiProvider).update(note.id, pinned: !note.pinned);
|
||||||
ref.invalidate(noteDetailProvider(noteId));
|
ref.invalidate(noteDetailProvider(noteId));
|
||||||
await ref.read(noteListProvider.notifier).refresh();
|
await ref.read(noteListProvider.notifier).refresh();
|
||||||
if (context.mounted) showToast(context, note.pinned ? '已取消置顶' : '📌 已置顶');
|
if (context.mounted) showToast(context, note.pinned ? '已取消置顶' : '已置顶', icon: 'pin');
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
if (context.mounted) showToast(context, e.message, error: true);
|
if (context.mounted) showToast(context, e.message, error: true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ class _NoteEditPageState extends ConsumerState<NoteEditPage> {
|
|||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_saved = true;
|
_saved = true;
|
||||||
showToast(context, '✅ 已保存');
|
showToast(context, '已保存', icon: 'check');
|
||||||
Navigator.of(context).pop(true);
|
Navigator.of(context).pop(true);
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
if (mounted) showToast(context, e.message, error: true);
|
if (mounted) showToast(context, e.message, error: true);
|
||||||
@@ -246,7 +246,7 @@ class _NoteEditPageState extends ConsumerState<NoteEditPage> {
|
|||||||
final content = _contentController.text;
|
final content = _contentController.text;
|
||||||
if (content.trim().isEmpty) {
|
if (content.trim().isEmpty) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: EmptyView(icon: '📄', title: '还没有内容', tip: '切回编辑模式写点什么吧'),
|
child: EmptyView(icon: 'file', title: '还没有内容', tip: '切回编辑模式写点什么吧'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../../utils/toast.dart';
|
|||||||
import '../../widgets/state_views.dart';
|
import '../../widgets/state_views.dart';
|
||||||
import 'folder_picker_sheet.dart';
|
import 'folder_picker_sheet.dart';
|
||||||
import 'note_detail_page.dart';
|
import 'note_detail_page.dart';
|
||||||
|
import '../../widgets/cute_icon.dart';
|
||||||
|
|
||||||
/// 笔记列表页
|
/// 笔记列表页
|
||||||
///
|
///
|
||||||
@@ -308,7 +309,7 @@ class _NoteListPageState extends ConsumerState<NoteListPage> {
|
|||||||
children: [
|
children: [
|
||||||
SizedBox(height: MediaQuery.of(context).size.height * 0.18),
|
SizedBox(height: MediaQuery.of(context).size.height * 0.18),
|
||||||
EmptyView(
|
EmptyView(
|
||||||
icon: state.hasFilter ? '🔍' : '📝',
|
icon: state.hasFilter ? 'search' : 'note',
|
||||||
title: state.hasFilter ? '没有匹配的笔记' : '还没有笔记',
|
title: state.hasFilter ? '没有匹配的笔记' : '还没有笔记',
|
||||||
tip: state.hasFilter
|
tip: state.hasFilter
|
||||||
? '换个关键词或标签试试'
|
? '换个关键词或标签试试'
|
||||||
@@ -383,7 +384,7 @@ class _NoteListPageState extends ConsumerState<NoteListPage> {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
if (note.pinned) ...[
|
if (note.pinned) ...[
|
||||||
const Text('📌', style: TextStyle(fontSize: 13)),
|
const CuteIcon('pin', size: 14),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
],
|
],
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -471,7 +472,7 @@ class _NoteListPageState extends ConsumerState<NoteListPage> {
|
|||||||
await ref.read(noteListProvider.notifier).refresh();
|
await ref.read(noteListProvider.notifier).refresh();
|
||||||
ref.invalidate(noteTagsProvider);
|
ref.invalidate(noteTagsProvider);
|
||||||
ref.invalidate(noteFolderTreeProvider); // 目录上的笔记数变了
|
ref.invalidate(noteFolderTreeProvider); // 目录上的笔记数变了
|
||||||
if (mounted) showToast(context, '✅ 已记录');
|
if (mounted) showToast(context, '已记录', icon: 'check');
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
if (mounted) showToast(context, e.message, error: true);
|
if (mounted) showToast(context, e.message, error: true);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../api/ai_api.dart';
|
import '../api/ai_api.dart';
|
||||||
import '../core/network/api_exception.dart';
|
import '../core/network/api_exception.dart';
|
||||||
import '../models/ai.dart';
|
import '../models/ai.dart';
|
||||||
|
import 'bill_refresh.dart';
|
||||||
|
import 'note_provider.dart';
|
||||||
|
|
||||||
/// 后端 AI 是否配好了(没配 key 时 App 显示提示,而不是让用户白问一句)
|
/// 后端 AI 是否配好了(没配 key 时 App 显示提示,而不是让用户白问一句)
|
||||||
final aiStatusProvider = FutureProvider.autoDispose<bool>((ref) {
|
final aiStatusProvider = FutureProvider.autoDispose<bool>((ref) {
|
||||||
@@ -65,12 +67,45 @@ class AssistantState {
|
|||||||
static const _sentinel = Object();
|
static const _sentinel = Object();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 会让服务端数据发生变化的工具。**名字要和后端 AiToolService 保持一致** ——
|
||||||
|
/// 改名了这里就会静默失效(不报错,只是数据不刷新)。
|
||||||
|
const _mutatingTools = {
|
||||||
|
'create_bill': _Mutated.bill,
|
||||||
|
'create_note': _Mutated.note,
|
||||||
|
};
|
||||||
|
|
||||||
|
enum _Mutated { bill, note }
|
||||||
|
|
||||||
class AssistantNotifier extends Notifier<AssistantState> {
|
class AssistantNotifier extends Notifier<AssistantState> {
|
||||||
|
/// 本次 App 运行期间是否已经自动恢复过。
|
||||||
|
/// 用它拦住「用户主动开了新对话,切个 Tab 回来旧对话又被拉回来」。
|
||||||
|
bool _autoRestored = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
AssistantState build() => const AssistantState();
|
AssistantState build() => const AssistantState();
|
||||||
|
|
||||||
/// 新对话:清空界面,但**不清 sessionId 为 null 之前的会话**(后端那边还在,能从历史里翻出来)
|
/// 打开助手页时调用:界面还是空的就把最近一次对话展开。
|
||||||
|
///
|
||||||
|
/// 为什么放在页面里而不是 provider 的 build():build() 里不能同步改 state,
|
||||||
|
/// 而这个恢复动作是异步的、而且只该在这个页面被打开时发生。
|
||||||
|
Future<void> restoreLastSessionIfNeeded() async {
|
||||||
|
if (_autoRestored) return;
|
||||||
|
_autoRestored = true;
|
||||||
|
if (state.sessionId != null || state.bubbles.isNotEmpty) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 会话列表本来就是按最近活跃倒序,第一条就是「上一次的对话」
|
||||||
|
final sessions = await ref.read(aiApiProvider).sessions();
|
||||||
|
if (!ref.mounted || sessions.isEmpty) return;
|
||||||
|
await openSession(sessions.first.id);
|
||||||
|
} catch (_) {
|
||||||
|
// 拉不到就算了,用户还能从「历史对话」里自己找
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 新对话:清空界面。后端那条旧会话还在,能从历史里翻出来。
|
||||||
void newChat() {
|
void newChat() {
|
||||||
|
_autoRestored = true;
|
||||||
state = const AssistantState();
|
state = const AssistantState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +154,8 @@ class AssistantNotifier extends Notifier<AssistantState> {
|
|||||||
// SSE 的收尾阶段出错(服务端异步派发、代理截断)很常见,
|
// SSE 的收尾阶段出错(服务端异步派发、代理截断)很常见,
|
||||||
// 弹个红条出来只会让人以为回答没拿到。
|
// 弹个红条出来只会让人以为回答没拿到。
|
||||||
var sawDone = false;
|
var sawDone = false;
|
||||||
|
// 这次对话动过哪些数据
|
||||||
|
final mutated = <_Mutated>{};
|
||||||
try {
|
try {
|
||||||
await for (final event in ref
|
await for (final event in ref
|
||||||
.read(aiApiProvider)
|
.read(aiApiProvider)
|
||||||
@@ -129,6 +166,8 @@ class AssistantNotifier extends Notifier<AssistantState> {
|
|||||||
case AiEventType.delta:
|
case AiEventType.delta:
|
||||||
_appendDelta(event.content ?? '');
|
_appendDelta(event.content ?? '');
|
||||||
case AiEventType.tool:
|
case AiEventType.tool:
|
||||||
|
final what = _mutatingTools[event.toolName];
|
||||||
|
if (what != null) mutated.add(what);
|
||||||
state = state.copyWith(activeTool: event.toolName);
|
state = state.copyWith(activeTool: event.toolName);
|
||||||
case AiEventType.done:
|
case AiEventType.done:
|
||||||
// 新会话时后端在这一刻才把 ID 告诉我们,必须记下来,
|
// 新会话时后端在这一刻才把 ID 告诉我们,必须记下来,
|
||||||
@@ -150,6 +189,28 @@ class AssistantNotifier extends Notifier<AssistantState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_finish(failed: failed);
|
_finish(failed: failed);
|
||||||
|
_refreshMutatedData(mutated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 让被 AI 改过的数据在 App 里失效重取。
|
||||||
|
///
|
||||||
|
/// 为什么必须显式做:AI 是在**服务端直接写库**的,App 毫不知情。
|
||||||
|
/// 而这些数据的 provider 被常驻页面(首页仪表盘的模块卡片、「我的」页)
|
||||||
|
/// 一直监听着,永远不会被 autoDispose 回收 —— 不失效就是旧数。
|
||||||
|
/// 表现会很迷惑:账单列表反而刷新了(它是 push 出来的页面,provider 被回收过),
|
||||||
|
/// 于是看起来"列表对了、汇总和预算环没对"。
|
||||||
|
void _refreshMutatedData(Set<_Mutated> mutated) {
|
||||||
|
if (mutated.contains(_Mutated.bill)) {
|
||||||
|
invalidateBillData(ref);
|
||||||
|
}
|
||||||
|
if (mutated.contains(_Mutated.note)) {
|
||||||
|
// 四个都要失效:列表是 keepAlive 的 Notifier;概览被首页的笔记卡片常驻监听;
|
||||||
|
// 标签和目录计数会随新建的笔记一起变
|
||||||
|
ref.invalidate(noteListProvider);
|
||||||
|
ref.invalidate(noteOverviewProvider);
|
||||||
|
ref.invalidate(noteTagsProvider);
|
||||||
|
ref.invalidate(noteFolderTreeProvider);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _appendDelta(String delta) {
|
void _appendDelta(String delta) {
|
||||||
|
|||||||
@@ -1,13 +1,27 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../core/navigation/navigator_key.dart';
|
import '../core/navigation/navigator_key.dart';
|
||||||
|
import '../widgets/cute_icon.dart';
|
||||||
|
|
||||||
/// 统一提示封装(对齐 uni.showToast 的常用场景)
|
/// 统一提示封装(对齐 uni.showToast 的常用场景)
|
||||||
void showToast(BuildContext context, String message, {bool error = false}) {
|
///
|
||||||
|
/// [icon] 传图标名(见 [cuteIconNames]),会在文字前面显示一个小图标 ——
|
||||||
|
/// 比在文案里塞 emoji 干净,而且能跟着主题色走。
|
||||||
|
void showToast(BuildContext context, String message,
|
||||||
|
{bool error = false, String? icon}) {
|
||||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(message),
|
content: Row(
|
||||||
|
children: [
|
||||||
|
if (icon != null) ...[
|
||||||
|
// 深色底上用单色,双色在小尺寸下会糊成一团
|
||||||
|
CuteIcon(icon, size: 18, tint: Colors.white),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
Expanded(child: Text(message)),
|
||||||
|
],
|
||||||
|
),
|
||||||
backgroundColor: error ? const Color(0xFFB71C1C) : const Color(0xFF323232),
|
backgroundColor: error ? const Color(0xFFB71C1C) : const Color(0xFF323232),
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
duration: const Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
@@ -16,7 +30,7 @@ void showToast(BuildContext context, String message, {bool error = false}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 全局提示:供没有 BuildContext 的地方调用(网络层、Provider)
|
/// 全局提示:供没有 BuildContext 的地方调用(网络层、Provider)
|
||||||
void showGlobalToast(String message, {bool error = false}) {
|
void showGlobalToast(String message, {bool error = false, String? icon}) {
|
||||||
final context = navigatorKey.currentContext;
|
final context = navigatorKey.currentContext;
|
||||||
if (context != null) showToast(context, message, error: error);
|
if (context != null) showToast(context, message, error: error, icon: icon);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../models/category.dart';
|
import '../models/category.dart';
|
||||||
|
import 'cute_icon.dart';
|
||||||
|
|
||||||
/// 分类 emoji 宫格(手动记账页与账单编辑页共用)
|
/// 分类 emoji 宫格(手动记账页与账单编辑页共用)
|
||||||
///
|
///
|
||||||
@@ -67,7 +68,7 @@ class CategoryPickerGrid extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Text(category.icon, style: const TextStyle(fontSize: 24)),
|
child: CuteIcon(category.icon, size: 26),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
|
||||||
|
/// 双色图标组件。
|
||||||
|
///
|
||||||
|
/// 图标是 SVG 资源(`assets/icons/*.svg`),由 `tools/make_icons.py` 生成 ——
|
||||||
|
/// 改形状或配色请改那个脚本再重跑,不要手工编辑 SVG 文件。
|
||||||
|
///
|
||||||
|
/// 每个图标自带一套配色(浅色填充 + 深色描边)。需要单色时传 [tint],
|
||||||
|
/// 它会把整个图标压成一种颜色 —— 比如放在焦糖色主按钮上的白色图标。
|
||||||
|
class CuteIcon extends StatelessWidget {
|
||||||
|
const CuteIcon(this.name, {super.key, this.size = 22, this.tint});
|
||||||
|
|
||||||
|
/// 图标名,见 [cuteIconNames]
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
/// 传了就压成单色(丢失双色效果,但能适配任意背景)
|
||||||
|
final Color? tint;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final resolved = resolveIconName(name);
|
||||||
|
return SvgPicture.asset(
|
||||||
|
'assets/icons/$resolved.svg',
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
colorFilter:
|
||||||
|
tint == null ? null : ColorFilter.mode(tint!, BlendMode.srcIn),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 所有合法的图标名(和 tools/make_icons.py 里的 ICONS 一一对应)
|
||||||
|
const cuteIconNames = <String>{
|
||||||
|
// 记账分类
|
||||||
|
'food', 'transport', 'shopping', 'entertainment', 'medical', 'education',
|
||||||
|
'housing', 'utilities', 'phone', 'other', 'salary', 'bonus', 'invest',
|
||||||
|
'parttime',
|
||||||
|
// 模块
|
||||||
|
'bill', 'note', 'assistant',
|
||||||
|
// 界面
|
||||||
|
'expense', 'income', 'camera', 'search', 'user', 'lock', 'eye', 'image',
|
||||||
|
'file', 'folder', 'folder-open', 'check', 'pin', 'target', 'inbox', 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 老数据的兼容映射:emoji → 图标名。
|
||||||
|
///
|
||||||
|
/// 分类图标以前是 emoji 存在数据库里(`category.icon`),现在换成了图标名。
|
||||||
|
/// 这份映射有两个作用:
|
||||||
|
/// ① 迁移还没跑的时候,老数据照样能显示
|
||||||
|
/// ② 迁移跑完之后,用户自己建分类时随手挑个 emoji 也能兜住
|
||||||
|
/// 都不认识就退到 `other`,不会出现空白图标。
|
||||||
|
///
|
||||||
|
/// 公开出来是为了能写测试(映射表里写错一个名字会静默退化成兜底图标,
|
||||||
|
/// 不测的话很难发现)。
|
||||||
|
const cuteEmojiAliases = <String, String>{
|
||||||
|
'🍔': 'food', '🍜': 'food', '🍚': 'food', '🍕': 'food', '☕': 'food',
|
||||||
|
'🚗': 'transport', '🚌': 'transport', '🚇': 'transport', '✈': 'transport',
|
||||||
|
'🛍': 'shopping', '🛒': 'shopping', '👕': 'shopping', '🎁': 'bonus',
|
||||||
|
'🎬': 'entertainment', '🎮': 'entertainment', '🎵': 'entertainment',
|
||||||
|
'🏥': 'medical', '💊': 'medical', '💉': 'medical',
|
||||||
|
'📚': 'education', '🎓': 'education', '📖': 'education', '✏': 'education',
|
||||||
|
'🏠': 'housing', '🏡': 'housing', '🛏': 'housing',
|
||||||
|
'💡': 'utilities', '💧': 'utilities', '⚡': 'utilities', '🔌': 'utilities',
|
||||||
|
'📱': 'phone', '☎': 'phone', '💻': 'phone',
|
||||||
|
'💰': 'salary', '💵': 'salary', '🏦': 'salary', '💴': 'salary',
|
||||||
|
'📈': 'invest', '📊': 'invest', '💹': 'invest', '🪙': 'invest',
|
||||||
|
'💼': 'parttime', '🧑💻': 'parttime', '🔧': 'parttime',
|
||||||
|
'📦': 'other', '📌': 'other', '🏷': 'other', '❓': 'other',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 把任意来源的图标值(图标名 / emoji / 空)解析成一个合法的图标名
|
||||||
|
String resolveIconName(String? raw) {
|
||||||
|
final value = (raw ?? '').trim();
|
||||||
|
if (value.isEmpty) return 'other';
|
||||||
|
if (cuteIconNames.contains(value)) return value;
|
||||||
|
|
||||||
|
// emoji 里可能带变体选择符(U+FE0F),去掉再查一次
|
||||||
|
final stripped = value.replaceAll('\uFE0F', '');
|
||||||
|
return cuteEmojiAliases[value] ?? cuteEmojiAliases[stripped] ?? 'other';
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'cute_icon.dart';
|
||||||
|
|
||||||
/// 加载态
|
/// 加载态
|
||||||
class LoadingView extends StatelessWidget {
|
class LoadingView extends StatelessWidget {
|
||||||
const LoadingView({super.key, this.tip = '加载中...', this.padding = const EdgeInsets.all(40)});
|
const LoadingView({super.key, this.tip = '加载中...', this.padding = const EdgeInsets.all(40)});
|
||||||
@@ -43,8 +45,8 @@ class ErrorView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const Text('😵', style: TextStyle(fontSize: 30)),
|
const CuteIcon('error', size: 32),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 10),
|
||||||
Text(
|
Text(
|
||||||
message,
|
message,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
@@ -69,10 +71,13 @@ class ErrorView extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 空状态
|
/// 空状态
|
||||||
|
///
|
||||||
|
/// [icon] 传图标名(见 [cuteIconNames]);为了兼容以前传 emoji 的调用点,
|
||||||
|
/// 走的是 [resolveIconName],emoji 和未知值都能落到一个兜底图标上。
|
||||||
class EmptyView extends StatelessWidget {
|
class EmptyView extends StatelessWidget {
|
||||||
const EmptyView({
|
const EmptyView({
|
||||||
super.key,
|
super.key,
|
||||||
this.icon = '📭',
|
this.icon = 'inbox',
|
||||||
required this.title,
|
required this.title,
|
||||||
this.tip,
|
this.tip,
|
||||||
this.padding = const EdgeInsets.symmetric(vertical: 40),
|
this.padding = const EdgeInsets.symmetric(vertical: 40),
|
||||||
@@ -89,8 +94,8 @@ class EmptyView extends StatelessWidget {
|
|||||||
padding: padding,
|
padding: padding,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Text(icon, style: const TextStyle(fontSize: 44)),
|
CuteIcon(icon, size: 46),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 12),
|
||||||
Text(title, style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037))),
|
Text(title, style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037))),
|
||||||
if (tip != null) ...[
|
if (tip != null) ...[
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
|
|||||||
@@ -238,6 +238,14 @@ packages:
|
|||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.4.3"
|
version: "3.4.3"
|
||||||
|
flutter_svg:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_svg
|
||||||
|
sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.0"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -440,6 +448,14 @@ packages:
|
|||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
|
path_parsing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_parsing
|
||||||
|
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
path_provider_linux:
|
path_provider_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -464,6 +480,14 @@ packages:
|
|||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
|
petitparser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: petitparser
|
||||||
|
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.2"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -629,6 +653,30 @@ packages:
|
|||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.6.0"
|
version: "4.6.0"
|
||||||
|
vector_graphics:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vector_graphics
|
||||||
|
sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.3"
|
||||||
|
vector_graphics_codec:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vector_graphics_codec
|
||||||
|
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.13"
|
||||||
|
vector_graphics_compiler:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vector_graphics_compiler
|
||||||
|
sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -661,6 +709,14 @@ packages:
|
|||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
|
xml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xml
|
||||||
|
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.1"
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ dependencies:
|
|||||||
intl: ^0.20.0
|
intl: ^0.20.0
|
||||||
fl_chart: ^1.2.0
|
fl_chart: ^1.2.0
|
||||||
flutter_markdown_plus: ^1.0.12
|
flutter_markdown_plus: ^1.0.12
|
||||||
|
flutter_svg: ^2.3.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -57,6 +58,9 @@ flutter:
|
|||||||
|
|
||||||
assets:
|
assets:
|
||||||
- assets/images/
|
- assets/images/
|
||||||
|
# 双色图标集,由 tools/make_icons.py 生成(改形状/配色改脚本再重跑,
|
||||||
|
# 不要手工编辑 assets/icons/ 下的文件)
|
||||||
|
- assets/icons/
|
||||||
|
|
||||||
# To add assets to your application, add an assets section, like this:
|
# To add assets to your application, add an assets section, like this:
|
||||||
# assets:
|
# assets:
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:qiangbao_accounting/api/ai_api.dart';
|
||||||
|
import 'package:qiangbao_accounting/models/ai.dart';
|
||||||
|
import 'package:qiangbao_accounting/models/statistics_response.dart';
|
||||||
|
import 'package:qiangbao_accounting/providers/ai_provider.dart';
|
||||||
|
import 'package:qiangbao_accounting/providers/bill_provider.dart';
|
||||||
|
|
||||||
|
/// 假的 AI 接口:按脚本吐一串 SSE 事件。
|
||||||
|
class FakeAiApi extends AiApi {
|
||||||
|
FakeAiApi(this._events) : super(Dio());
|
||||||
|
|
||||||
|
final List<AiEvent> _events;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<AiEvent> chat({int? sessionId, required String content}) async* {
|
||||||
|
for (final e in _events) {
|
||||||
|
yield e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ChatSessionItem>> sessions() async => const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 回归测试:AI 在服务端写库后,App 必须主动让相关 provider 失效。
|
||||||
|
///
|
||||||
|
/// 背景(这是个真实踩过的 bug):`IndexedStack` 让首页仪表盘和「我的」页常驻,
|
||||||
|
/// 它们 watch 的 monthlyStatisticsProvider / budgetProvider / accountProvider
|
||||||
|
/// **永远不会被 autoDispose 回收**,只能靠「写操作后显式失效」更新。
|
||||||
|
/// 以前所有写操作都在 App 内,会调 invalidateBillData;
|
||||||
|
/// AI 改成在服务端直接写库之后,App 毫不知情 —— 于是账单列表刷新了
|
||||||
|
/// (它是 push 出来的页面,provider 被回收过),但汇总和预算环还是旧数,
|
||||||
|
/// 看起来就是「列表对了、图表没对」。
|
||||||
|
void main() {
|
||||||
|
final stats = StatisticsResponse(
|
||||||
|
totalIncome: 0,
|
||||||
|
totalExpense: 134,
|
||||||
|
balance: -134,
|
||||||
|
categoryStatistics: const [],
|
||||||
|
dailyStatistics: const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 模拟「首页仪表盘一直挂在树上」,让 provider 有常驻监听者
|
||||||
|
Future<(ProviderContainer, int Function())> boot(List<AiEvent> events) async {
|
||||||
|
var builds = 0;
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
aiApiProvider.overrideWithValue(FakeAiApi(events)),
|
||||||
|
monthlyStatisticsProvider.overrideWith((ref, month) {
|
||||||
|
builds++;
|
||||||
|
return Future.value(stats);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final anchor = DateTime(2026, 9);
|
||||||
|
final sub = container.listen(monthlyStatisticsProvider(anchor), (_, _) {});
|
||||||
|
addTearDown(sub.close);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
await container.read(monthlyStatisticsProvider(anchor).future);
|
||||||
|
return (container, () => builds);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('AI 调了 create_bill 之后,账单汇总会被失效重取', () async {
|
||||||
|
final (container, builds) = await boot([
|
||||||
|
const AiEvent.tool('create_bill'),
|
||||||
|
const AiEvent.delta('记好啦宝'),
|
||||||
|
const AiEvent.done(1),
|
||||||
|
]);
|
||||||
|
expect(builds(), 1, reason: '初始只取过一次');
|
||||||
|
|
||||||
|
await container.read(assistantProvider.notifier).send('帮我记一笔 25 块');
|
||||||
|
await container.read(monthlyStatisticsProvider(DateTime(2026, 9)).future);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(builds(), 2, reason: 'AI 写了账单,汇总必须重取');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI 调了 create_note 不会误伤账单数据', () async {
|
||||||
|
final (container, builds) = await boot([
|
||||||
|
const AiEvent.tool('create_note'),
|
||||||
|
const AiEvent.delta('记好了宝'),
|
||||||
|
const AiEvent.done(1),
|
||||||
|
]);
|
||||||
|
expect(builds(), 1);
|
||||||
|
|
||||||
|
await container.read(assistantProvider.notifier).send('帮我记一下:周三开会');
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(builds(), 1, reason: '只是记笔记,账单汇总不该被失效');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('纯查询不触发任何重取', () async {
|
||||||
|
final (container, builds) = await boot([
|
||||||
|
const AiEvent.tool('query_bills'),
|
||||||
|
const AiEvent.tool('web_search'),
|
||||||
|
const AiEvent.delta('这个月花了 134'),
|
||||||
|
const AiEvent.done(1),
|
||||||
|
]);
|
||||||
|
expect(builds(), 1);
|
||||||
|
|
||||||
|
await container.read(assistantProvider.notifier).send('这个月花了多少');
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(builds(), 1, reason: '只读工具不该让 App 白刷一遍数据');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -130,7 +130,8 @@ void main() {
|
|||||||
expect(b.amount, 35.5);
|
expect(b.amount, 35.5);
|
||||||
expect(b.billDate, '2026-09-21');
|
expect(b.billDate, '2026-09-21');
|
||||||
expect(b.categoryName, '未分类');
|
expect(b.categoryName, '未分类');
|
||||||
expect(b.categoryIcon, '📦');
|
// 图标兜底值是「图标名」不再是 emoji(2026-09-24 换成自绘图标集)
|
||||||
|
expect(b.categoryIcon, 'other');
|
||||||
expect(b.type, 1); // 缺省按支出
|
expect(b.type, 1); // 缺省按支出
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:qiangbao_accounting/widgets/cute_icon.dart';
|
||||||
|
|
||||||
|
/// 图标系统的结构性测试。
|
||||||
|
///
|
||||||
|
/// 这类测试的价值在于:图标名是**字符串**,写错一个字母不会编译报错,
|
||||||
|
/// 只会静默退化成兜底图标 —— 那种错在界面上很不显眼,容易一直没人发现。
|
||||||
|
void main() {
|
||||||
|
test('图标名原样返回', () {
|
||||||
|
expect(resolveIconName('food'), 'food');
|
||||||
|
expect(resolveIconName('folder-open'), 'folder-open');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emoji 映射到图标名(迁移前的旧数据要照样能显示)', () {
|
||||||
|
expect(resolveIconName('🍔'), 'food');
|
||||||
|
expect(resolveIconName('🚗'), 'transport');
|
||||||
|
expect(resolveIconName('💰'), 'salary');
|
||||||
|
expect(resolveIconName('📦'), 'other');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('带变体选择符的 emoji 也能认出来', () {
|
||||||
|
// '🛍️' 实际是 U+1F6CD + U+FE0F,不同平台的输入法拼法不一致
|
||||||
|
expect(resolveIconName('🛍️'), 'shopping');
|
||||||
|
expect(resolveIconName('🛍'), 'shopping');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('空值和未知值兜底到 other,不返回空图标', () {
|
||||||
|
expect(resolveIconName(null), 'other');
|
||||||
|
expect(resolveIconName(''), 'other');
|
||||||
|
expect(resolveIconName(' '), 'other');
|
||||||
|
expect(resolveIconName('这个不是图标'), 'other');
|
||||||
|
expect(resolveIconName('🍣未知的寿司'), 'other');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('映射表里的每个目标都是合法图标名(防手滑写错)', () {
|
||||||
|
final bad = <String, String>{};
|
||||||
|
cuteEmojiAliases.forEach((emoji, icon) {
|
||||||
|
if (!cuteIconNames.contains(icon)) bad[emoji] = icon;
|
||||||
|
});
|
||||||
|
expect(bad, isEmpty, reason: '这些映射指向了不存在的图标: $bad');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('每个图标名都有对应的 SVG 文件(防漏生成)', () {
|
||||||
|
final missing = cuteIconNames
|
||||||
|
.where((n) => !File('assets/icons/$n.svg').existsSync())
|
||||||
|
.toList();
|
||||||
|
expect(missing, isEmpty, reason: '缺少 SVG: $missing,跑一下 python tools/make_icons.py');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SVG 目录里没有多余文件(防改名后留下孤儿)', () {
|
||||||
|
final onDisk = Directory('assets/icons')
|
||||||
|
.listSync()
|
||||||
|
.whereType<File>()
|
||||||
|
.map((f) => f.uri.pathSegments.last.replaceAll('.svg', ''))
|
||||||
|
.toSet();
|
||||||
|
final orphans = onDisk.difference(cuteIconNames);
|
||||||
|
expect(orphans, isEmpty, reason: '这些 SVG 在 cuteIconNames 里没有登记: $orphans');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('没有把图标字段直接丢给 Text 渲染(防漏改渲染点)', () {
|
||||||
|
// 这个测试是真踩出来的:图标从 emoji 换成图标名之后,绝大多数渲染点都
|
||||||
|
// 改成了 CuteIcon,但分类宫格漏了一处,结果手机上显示成一串 "food"
|
||||||
|
// "transport" 文字,把圆圈挤爆了。
|
||||||
|
//
|
||||||
|
// 图标值是**字符串**,漏改不会编译报错、analyze 也查不出来,只能靠扫源码。
|
||||||
|
//
|
||||||
|
// 注意:Text 的首参会带 '$' 做字符串插值(如 '¥${x}'),那种是数字/文本,
|
||||||
|
// 这里只拦「整个参数就是某个 .icon 字段」的情况。
|
||||||
|
final iconField = RegExp(r'\b\w+\.icon\b|\bcategoryIcon\b|\bmodule\.icon\b');
|
||||||
|
final textWithIcon = RegExp(
|
||||||
|
r'Text\(\s*(?:const\s+)?[^,()\n]*?(?:\w+\.icon|categoryIcon)[^,()\n]*?(?:,|\))',
|
||||||
|
);
|
||||||
|
|
||||||
|
final offenders = <String>[];
|
||||||
|
for (final f in Directory('lib').listSync(recursive: true)) {
|
||||||
|
if (f is! File || !f.path.endsWith('.dart')) continue;
|
||||||
|
final lines = f.readAsLinesSync();
|
||||||
|
for (var i = 0; i < lines.length; i++) {
|
||||||
|
final line = lines[i];
|
||||||
|
if (!iconField.hasMatch(line)) continue;
|
||||||
|
if (textWithIcon.hasMatch(line)) {
|
||||||
|
offenders.add('${f.path}:${i + 1} ${line.trim()}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(
|
||||||
|
offenders,
|
||||||
|
isEmpty,
|
||||||
|
reason: '这些地方把图标名当文字渲染了,应该用 CuteIcon:\n${offenders.join('\n')}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<!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>
|
||||||
|
<h2>记账分类</h2><div class='row'>
|
||||||
|
<div class='cell'><img src='../assets/icons/food.svg'><span class='n'>food</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/transport.svg'><span class='n'>transport</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/shopping.svg'><span class='n'>shopping</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/entertainment.svg'><span class='n'>entertainment</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/medical.svg'><span class='n'>medical</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/education.svg'><span class='n'>education</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/housing.svg'><span class='n'>housing</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/utilities.svg'><span class='n'>utilities</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/phone.svg'><span class='n'>phone</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/other.svg'><span class='n'>other</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/salary.svg'><span class='n'>salary</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/bonus.svg'><span class='n'>bonus</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/invest.svg'><span class='n'>invest</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/parttime.svg'><span class='n'>parttime</span></div>
|
||||||
|
</div>
|
||||||
|
<h2>模块 / 界面</h2><div class='row'>
|
||||||
|
<div class='cell'><img src='../assets/icons/bill.svg'><span class='n'>bill</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/note.svg'><span class='n'>note</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/assistant.svg'><span class='n'>assistant</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/expense.svg'><span class='n'>expense</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/income.svg'><span class='n'>income</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/camera.svg'><span class='n'>camera</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/search.svg'><span class='n'>search</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/user.svg'><span class='n'>user</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/lock.svg'><span class='n'>lock</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/eye.svg'><span class='n'>eye</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/image.svg'><span class='n'>image</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/file.svg'><span class='n'>file</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/folder.svg'><span class='n'>folder</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/folder-open.svg'><span class='n'>folder-open</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/check.svg'><span class='n'>check</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/pin.svg'><span class='n'>pin</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/target.svg'><span class='n'>target</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/inbox.svg'><span class='n'>inbox</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/error.svg'><span class='n'>error</span></div>
|
||||||
|
</div>
|
||||||
|
<h2>放大检查(72px)</h2><div class='row big'>
|
||||||
|
<div class='cell'><img src='../assets/icons/food.svg'><span class='n'>food</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/transport.svg'><span class='n'>transport</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/shopping.svg'><span class='n'>shopping</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/entertainment.svg'><span class='n'>entertainment</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/medical.svg'><span class='n'>medical</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/education.svg'><span class='n'>education</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/housing.svg'><span class='n'>housing</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/utilities.svg'><span class='n'>utilities</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/phone.svg'><span class='n'>phone</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/other.svg'><span class='n'>other</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/salary.svg'><span class='n'>salary</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/bonus.svg'><span class='n'>bonus</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/invest.svg'><span class='n'>invest</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/parttime.svg'><span class='n'>parttime</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/bill.svg'><span class='n'>bill</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/note.svg'><span class='n'>note</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/assistant.svg'><span class='n'>assistant</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/expense.svg'><span class='n'>expense</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/income.svg'><span class='n'>income</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/camera.svg'><span class='n'>camera</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/search.svg'><span class='n'>search</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/user.svg'><span class='n'>user</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/lock.svg'><span class='n'>lock</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/eye.svg'><span class='n'>eye</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/image.svg'><span class='n'>image</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/file.svg'><span class='n'>file</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/folder.svg'><span class='n'>folder</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/folder-open.svg'><span class='n'>folder-open</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/check.svg'><span class='n'>check</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/pin.svg'><span class='n'>pin</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/target.svg'><span class='n'>target</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/inbox.svg'><span class='n'>inbox</span></div>
|
||||||
|
<div class='cell'><img src='../assets/icons/error.svg'><span class='n'>error</span></div>
|
||||||
|
</div></body></html>
|
||||||
@@ -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()
|
||||||