AI 接入 V1.0

UI更新
This commit is contained in:
2026-09-24 14:24:34 +08:00
parent 34bc2e04b0
commit 9a144f159f
65 changed files with 1195 additions and 196 deletions
+3
View File
@@ -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。
+4
View File
@@ -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

+5
View File
@@ -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

+7
View File
@@ -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

+5
View File
@@ -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

+4
View File
@@ -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

+5
View File
@@ -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

+5
View File
@@ -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

+5
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+5
View File
@@ -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

+4
View File
@@ -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

+3
View File
@@ -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

+5
View File
@@ -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

+4
View File
@@ -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

+5
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+7
View File
@@ -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

+5
View File
@@ -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

+4
View File
@@ -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

+5
View File
@@ -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

+5
View File
@@ -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

+5
View File
@@ -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

+5
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+5
View File
@@ -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

+6
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+3 -3
View File
@@ -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(),
), ),
+1 -1
View File
@@ -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'])),
+1 -1
View File
@@ -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(),
); );
+1 -1
View File
@@ -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,
); );
+5 -4
View File
@@ -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 (_) {
+3 -2
View File
@@ -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))),
], ],
+13 -1
View File
@@ -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(
+5 -4
View File
@@ -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);
+2 -1
View File
@@ -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)),
+2 -1
View File
@@ -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(
+5 -2
View File
@@ -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(
+3 -2
View File
@@ -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,
+23 -122
View File
@@ -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,40 +290,32 @@ 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: 14),
_inputField(
icon: '✨',
controller: _nicknameController,
hint: '请输入昵称(可选)',
maxLength: 20,
),
],
if (!_isRegister) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(
children: [ children: [
@@ -365,33 +339,6 @@ class _LoginPageState extends ConsumerState<LoginPage>
), ),
], ],
), ),
],
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(
+8 -5
View File
@@ -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 (_) {
+5 -4
View File
@@ -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(
+3 -3
View File
@@ -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);
} }
+2 -2
View File
@@ -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: '切回编辑模式写点什么吧'),
); );
} }
+4 -3
View File
@@ -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 (_) {
+62 -1
View File
@@ -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) {
+18 -4
View File
@@ -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);
} }
+2 -1
View File
@@ -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(
+83
View File
@@ -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';
}
+10 -5
View File
@@ -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),
+56
View File
@@ -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:
+4
View File
@@ -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:
+110
View File
@@ -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 白刷一遍数据');
});
}
+2 -1
View File
@@ -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); // 缺省按支出
}); });
+96
View File
@@ -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')}',
);
});
}
+86
View File
@@ -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>
+398
View File
@@ -0,0 +1,398 @@
# -*- coding: utf-8 -*-
"""
生成 App 的双色图标集(SVG)。
为什么要有这个脚本:
图标是「设计系统」的一部分,不是一堆散文件。把形状和配色集中写在这里,
改动(换圆角、调粗细、换某个颜色的深浅)只需要改一个地方再重跑,
不用手工去改 30 个 SVG。
设计规范:
· 画布 24×24,内容控制在 3~21 之间留出视觉边距
· 线条粗细统一 2.0,端点和连接处全部圆角(这是「可爱」的关键)
· 双色:浅色填充 + 深色描边(类似 Phosphor Duotone 的做法)
· 配色以品牌焦糖色 #C8956E 为锚,压低饱和度,保证整体暖调统一
产物:
assets/icons/<name>.svg 提交进仓库,由 flutter_svg 渲染
<后台>/static/admin/icons/<name>.svg 同一套图标给网页后台用
<后台>/static/admin/icons.js 图标名清单 + emoji 兼容映射
tools/_icons_preview.html 预览页(浏览器打开检查,不提交)
用法:
python tools/make_icons.py # 只生成 App 的图标
python tools/make_icons.py --preview # 额外生成预览页
python tools/make_icons.py --sync-web # 同时同步给网页后台(改了图标就带上)
"""
from __future__ import annotations
import argparse
import pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent
OUT_DIR = ROOT / "assets" / "icons"
PREVIEW = ROOT / "tools" / "_icons_preview.html"
# 网页后台也要用同一套图标(否则分类图标从 emoji 换成图标名之后,
# 后台会把 "food" 当文字显示出来)。这里是后台的静态资源目录。
WEB_DIR = ROOT.parent / "AI-accounting-soft" / "src" / "main" / "resources" / "static" / "admin"
STROKE = 2.0
# ---------------------------------------------------------------- 形状原语
# 所有图标都由这几个原语拼出来 —— 这是「风格统一」的机制保证:
# 圆角半径、线宽、端点全是同一套参数,不会出现某个图标特别突兀。
def rr(x: float, y: float, w: float, h: float, r: float) -> str:
"""圆角矩形"""
return (
f"M{x + r} {y}h{w - 2 * r}a{r} {r} 0 0 1 {r} {r}v{h - 2 * r}"
f"a{r} {r} 0 0 1 -{r} {r}h-{w - 2 * r}a{r} {r} 0 0 1 -{r} -{r}"
f"v-{h - 2 * r}a{r} {r} 0 0 1 {r} -{r}z"
)
def circle(cx: float, cy: float, r: float) -> str:
return f"M{cx - r} {cy}a{r} {r} 0 1 0 {2 * r} 0a{r} {r} 0 1 0 -{2 * r} 0z"
def bowl() -> str:
"""半圆碗(上沿平、下半圆)"""
return "M4 11h16a8 8 0 0 1-16 0z"
def sparkle() -> str:
"""四角星光 —— 用曲线收腰,直线版会像菱形"""
return (
"M12 2.5C12.6 7.8 16.2 11.4 21.5 12 16.2 12.6 12.6 16.2 12 21.5"
"5.8 16.2 9.4 12.6 4.2 12 9.4 11.4 12.6 7.8 12 2.5z"
)
# ---------------------------------------------------------------- 配色
# (描边色, 填充色)。以焦糖 #C8956E 为锚,整体暖调、低饱和。
C_BRAND = "#C8956E"
C_BRAND_L = "#F6E8DB"
C_TEXT = "#8D6E63" # UI 图标默认色(次级文字色)
C_TEXT_L = "#F1E7E0"
PALETTE = {
"food": ("#E2914B", "#FBE7D0"), # 橙
"transport": ("#5B8DB8", "#DCE8F2"), # 灰蓝
"shopping": ("#D97C93", "#F7E2E8"), # 玫红
"entertainment": ("#9B7BC4", "#EBE3F5"), # 紫
"medical": ("#E0725C", "#FAE2DD"), # 珊瑚红
"education": ("#6B8E4E", "#E4EDDA"), # 橄榄绿
"housing": ("#B5794F", "#F0E2D6"), # 棕
"utilities": ("#D9A93C", "#F8ECD2"), # 琥珀
"phone": ("#4FA0B0", "#DBEDF1"), # 青
"other": ("#9A8C80", "#EBE5E0"), # 灰棕
"salary": (C_BRAND, C_BRAND_L), # 品牌焦糖
"bonus": ("#D99C3C", "#F9EDD5"), # 金
"invest": ("#4FA98A", "#DBEFE8"), # 青绿
"parttime": ("#8A7BB8", "#E8E3F4"), # 蓝紫
"bill": (C_BRAND, C_BRAND_L),
"note": (C_BRAND, C_BRAND_L),
"assistant": ("#D9A93C", "#F8ECD2"),
}
# name -> (palette_key, [(path, 是否填充), ...])
ICONS: dict[str, tuple[str, list[tuple[str, bool]]]] = {
# ============================ 记账分类 ============================
"food": ("food", [
(bowl(), True),
("M9.2 8.2c1-1.4-.9-2.6.1-4", False),
("M13.6 8.2c1-1.4-.9-2.6.1-4", False),
]),
"transport": ("transport", [
(rr(7, 4.8, 10, 5.4, 2.6), True), # 车厢
(rr(3.2, 9.4, 17.6, 7.6, 3), True), # 车身
(circle(8, 17.2, 1.9), True), # 轮
(circle(16, 17.2, 1.9), True),
]),
"shopping": ("shopping", [
(rr(4.8, 8.6, 14.4, 11.8, 3), True), # 袋身
("M9 8.6V7a3 3 0 0 1 6 0v1.6", False), # 提手
]),
"entertainment": ("entertainment", [
(rr(3.2, 9.6, 17.6, 10.4, 2.5), True), # 板身
("M3.2 9.6h17.6", False),
("M7 6.2l-1.6 3.4M12 6.2l-1.6 3.4M17 6.2l-1.6 3.4", False), # 斜纹
]),
"medical": ("medical", [
(rr(3.6, 3.6, 16.8, 16.8, 5), True),
("M12 8.4v7.2M8.4 12h7.2", False), # 十字
]),
"education": ("education", [
("M12 4.4l9 4.6-9 4.6-9-4.6 9-4.6z", True), # 帽檐
("M20.6 9.2v5.4", False), # 流苏
(circle(20.6, 16.2, 1.3), True),
]),
"housing": ("housing", [
("M3.8 11.4L12 4.6l8.2 6.8V19a2.2 2.2 0 0 1-2.2 2.2H6a2.2 2.2 0 0 1-2.2-2.2z", True),
(rr(9.8, 14, 4.4, 7.2, 1.6), False), # 门
]),
"utilities": ("utilities", [
("M12 2.8a6.4 6.4 0 0 0-3.7 11.6c.8.6 1.3 1.4 1.3 2.3v.6h4.8v-.6"
"c0-.9.5-1.7 1.3-2.3A6.4 6.4 0 0 0 12 2.8z", True),
("M9.8 20.2h4.4M10.8 22.2h2.4", False), # 灯座
]),
"phone": ("phone", [
(rr(6.4, 2.8, 11.2, 18.4, 3), True),
("M10.6 5.6h2.8", False),
("M11 18.4h2", False),
]),
"other": ("other", [
(rr(3.4, 6.8, 17.2, 13.4, 2.6), True),
("M3.4 11.4h17.2", False),
("M12 6.8v4.6", False),
]),
"salary": ("salary", [
(rr(2.4, 6.4, 19.2, 11.2, 2.6), True),
(circle(12, 12, 3.1), False),
]),
"bonus": ("bonus", [
(rr(3.4, 10, 17.2, 10.2, 2.2), True), # 盒身
(rr(2.4, 6.2, 19.2, 4, 1.6), True), # 盒盖
("M12 6.2v14", False), # 丝带
("M12 6.2C10 3.4 6.6 4.2 7.6 6.6", False), # 蝴蝶结
("M12 6.2c2-2.8 5.4-2 4.4.4", False),
]),
"invest": ("invest", [
(rr(3.4, 14.4, 3.2, 6, 1.4), True), # 柱状
(rr(10.4, 11.4, 3.2, 9, 1.4), True),
(rr(17.4, 8.4, 3.2, 12, 1.4), True),
("M4 11.6l5-4 3.4 2.8L19.6 5", False), # 趋势线
("M16 5h3.6v3.4", False), # 箭头
]),
"parttime": ("parttime", [
(rr(3, 8.4, 18, 12.2, 2.6), True),
("M9 8.4V7a1.6 1.6 0 0 1 1.6-1.6h2.8A1.6 1.6 0 0 1 15 7v1.4", False),
("M3 13.2h18", False),
]),
# ============================ 模块图标 ============================
"bill": ("bill", [
(rr(2.8, 6.6, 18.4, 12.4, 3), True), # 钱包
("M2.8 11.4h18.4", False),
(circle(16.4, 15, 1.5), True), # 扣子
]),
"note": ("note", [
(rr(4.4, 3.4, 15.2, 17.2, 2.6), True), # 本子
("M8.6 3.4v17.2", False), # 书脊
("M11.8 9h5M11.8 12.6h5", False), # 文字线
]),
"assistant": ("assistant", [
(sparkle(), True),
("M18.4 15.6l.9 2.3 2.3.9-2.3.9-.9 2.3-.9-2.3-2.3-.9 2.3-.9.9-2.3z", True),
]),
# ============================ 界面图标 ============================
"expense": (None, [ # 支出:托盘 + 向上箭头
("M4 13.6v3.4a2.6 2.6 0 0 0 2.6 2.6h10.8a2.6 2.6 0 0 0 2.6-2.6v-3.4"
"H15.4a3.4 3.4 0 0 1-6.8 0H4z", True),
("M12 14.6V4.4M7.8 8.6L12 4.4l4.2 4.2", False),
]),
"income": (None, [ # 收入:托盘 + 向下箭头
("M4 13.6v3.4a2.6 2.6 0 0 0 2.6 2.6h10.8a2.6 2.6 0 0 0 2.6-2.6v-3.4"
"H15.4a3.4 3.4 0 0 1-6.8 0H4z", True),
("M12 4.4v10.2M7.8 10.4L12 14.6l4.2-4.2", False),
]),
"camera": (None, [
(rr(3, 6.8, 18, 13.4, 3), True),
(circle(12, 13.4, 3.8), False),
("M9 6.8l1.3-2.4h3.4L15 6.8", False),
]),
"search": (None, [
(circle(10.4, 10.4, 6.4), True),
("M15.2 15.2L20.6 20.6", False),
]),
"user": (None, [
(circle(12, 8.4, 3.9), True),
("M4.8 20.4c0-4 3.2-6.6 7.2-6.6s7.2 2.6 7.2 6.6z", True),
]),
"lock": (None, [
(rr(4.4, 10.4, 15.2, 10.2, 2.6), True),
("M8.4 10.4V7.8a3.6 3.6 0 0 1 7.2 0v2.6", False),
(circle(12, 15.4, 1.2), True),
]),
"eye": (None, [
("M2.6 12C5.2 7.6 8.6 5.6 12 5.6s6.8 2 9.4 6.4C18.8 16.4 15.4 18.4 12 18.4"
"S5.2 16.4 2.6 12z", True),
(circle(12, 12, 2.9), False),
]),
"image": (None, [
(rr(3, 4.8, 18, 14.4, 3), True),
("M4.4 16.6l4.6-5 3.4 3.8 3.2-3.4 4.6 4.8", False),
(circle(8.6, 9.4, 1.5), True),
]),
"file": (None, [
("M6 3h7.4L19 8.6V19a2.2 2.2 0 0 1-2.2 2.2H6A2.2 2.2 0 0 1 3.8 19V5.2"
"A2.2 2.2 0 0 1 6 3z", True),
("M13.4 3v5.6H19", False),
("M7.6 13h8.8M7.6 16.6h5.4", False),
]),
"folder": (None, [
("M3.4 7.4a2.2 2.2 0 0 1 2.2-2.2h3.6a2.2 2.2 0 0 1 1.8.9l.9 1.3h8.5"
"a2.2 2.2 0 0 1 2.2 2.2v8.6a2.2 2.2 0 0 1-2.2 2.2H5.6a2.2 2.2 0 0 1-2.2-2.2z", True),
]),
"folder-open": (None, [
("M3.4 17.6V7.4a2.2 2.2 0 0 1 2.2-2.2h3.6a2.2 2.2 0 0 1 1.8.9l.9 1.3h6.5"
"a2.2 2.2 0 0 1 2.2 2.2v1.2", False),
("M3.4 18.4l2.3-7h15.1l-2.3 7a2.2 2.2 0 0 1-2.1 1.6H5.5a2.2 2.2 0 0 1-2.1-1.6z", True),
]),
"check": (None, [
(circle(12, 12, 8.6), True),
("M8 12.3l2.9 2.9 5.2-6", False),
]),
"pin": (None, [
("M8.8 3.2h6.4l-1.1 5.4 3 2.9v1.6H6.9v-1.6l3-2.9z", True),
("M12 13.1v7.6", False),
]),
"target": (None, [
(circle(12, 12, 8.6), True),
(circle(12, 12, 4.6), False),
(circle(12, 12, 1.5), True),
]),
"inbox": (None, [
("M5.6 4.6h12.8l2.6 8.2v4.4a2.2 2.2 0 0 1-2.2 2.2H5.2A2.2 2.2 0 0 1 3 17.2v-4.4z", True),
("M3 12.8h4.6l1.4 2.6h6l1.4-2.6H21", False),
]),
"error": (None, [
(circle(12, 12, 8.6), True),
("M12 7.6v5.2", False),
(circle(12, 16.4, 1.15), True),
]),
}
FALLBACK = "other"
# 和 cute_icon.dart 里的 cuteEmojiAliases 保持一致 ——
# 这份只用于「迁移还没跑」的过渡期,以及用户自己填 emoji 的情况
EMOJI_ALIASES = {
"🍔": "food", "🍜": "food", "🍚": "food", "🍕": "food", "☕": "food",
"🚗": "transport", "🚌": "transport", "🚇": "transport", "✈": "transport",
"🛍": "shopping", "🛒": "shopping", "👕": "shopping",
"🎬": "entertainment", "🎮": "entertainment", "🎵": "entertainment",
"🏥": "medical", "💊": "medical", "💉": "medical",
"📚": "education", "🎓": "education", "📖": "education", "✏": "education",
"🏠": "housing", "🏡": "housing", "🛏": "housing",
"💡": "utilities", "💧": "utilities", "⚡": "utilities", "🔌": "utilities",
"📱": "phone", "☎": "phone", "💻": "phone",
"💰": "salary", "💵": "salary", "🏦": "salary", "💴": "salary",
"🎁": "bonus", "🧧": "bonus",
"📈": "invest", "📊": "invest", "💹": "invest", "🪙": "invest",
"💼": "parttime", "🔧": "parttime",
"📦": "other", "📌": "other", "🏷": "other", "❓": "other",
}
def render(name: str, key: str | None, paths: list[tuple[str, bool]]) -> str:
"""把形状列表拼成 SVG。key=None 表示界面图标,用默认的次级文字色"""
if key is None:
stroke, fill = C_TEXT, C_TEXT_L
else:
stroke, fill = PALETTE[key]
body = "\n".join(
f' <path d="{d}" fill="{fill if filled else "none"}"/>'
for d, filled in paths
)
return (
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" '
f'width="24" height="24" fill="none" stroke="{stroke}" '
f'stroke-width="{STROKE}" stroke-linecap="round" stroke-linejoin="round">\n'
f'{body}\n</svg>\n'
)
def build() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
for name, (key, paths) in ICONS.items():
(OUT_DIR / f"{name}.svg").write_text(render(name, key, paths), encoding="utf-8")
print(f"-> {OUT_DIR} ({len(ICONS)} 个图标)")
def preview() -> None:
"""生成预览页 —— SVG 好不好看必须用眼睛验,光看代码看不出来"""
groups = [("记账分类", list(PALETTE.keys())[:14]),
("模块 / 界面", [n for n in ICONS if n not in list(PALETTE.keys())[:14]])]
html = ["""<!DOCTYPE html><html><head><meta charset="utf-8">
<title>图标预览</title>
<style>
body{font-family:-apple-system,"Microsoft YaHei",sans-serif;background:#FAF8F5;color:#2B2B2B;padding:28px}
h2{font-size:15px;margin:26px 0 12px;color:#5D4037}
.row{display:flex;flex-wrap:wrap;gap:14px}
.cell{width:104px;background:#fff;border:1px solid #EAE3DC;border-radius:12px;
padding:14px 8px;text-align:center}
.cell img{width:44px;height:44px;display:block;margin:0 auto 8px}
.cell .n{font-size:11.5px;color:#8D7B70;word-break:break-all}
.big img{width:72px;height:72px}
</style></head><body>
<h1 style="font-size:19px;margin:0">强宝小助手 · 图标集</h1>
<p style="color:#8D7B70;font-size:13px;margin:6px 0 0">上:44px 实际显示尺寸 下:72px 放大看细节</p>"""]
for title, names in groups:
html.append(f"<h2>{title}</h2><div class='row'>")
for n in names:
if n not in ICONS:
continue
html.append(
f"<div class='cell'><img src='../assets/icons/{n}.svg'>"
f"<span class='n'>{n}</span></div>")
html.append("</div>")
html.append("<h2>放大检查(72px)</h2><div class='row big'>")
for n in ICONS:
html.append(f"<div class='cell'><img src='../assets/icons/{n}.svg'>"
f"<span class='n'>{n}</span></div>")
html.append("</div></body></html>")
PREVIEW.write_text("\n".join(html), encoding="utf-8")
print("->", PREVIEW)
def sync_web() -> None:
"""把图标同步给网页后台。
后台也要显示分类图标,而分类图标已经从 emoji 换成了图标名 ——
不同步的话后台会把 "food" 当文字显示出来。
顺手生成一份 icons.js,里面有图标名清单和 emoji 兼容映射,
免得在两个项目里各维护一份(那种重复迟早会不同步)。
"""
if not WEB_DIR.parent.exists():
print(f" (跳过) 找不到网页后台目录: {WEB_DIR}")
return
dst = WEB_DIR / "icons"
dst.mkdir(parents=True, exist_ok=True)
for f in dst.glob("*.svg"):
f.unlink()
for name in ICONS:
(dst / f"{name}.svg").write_text(
render(name, *ICONS[name]), encoding="utf-8")
names = ",\n ".join(f"'{n}'" for n in ICONS)
aliases = ",\n ".join(f"'{k}': '{v}'" for k, v in sorted(EMOJI_ALIASES.items()))
(WEB_DIR / "icons.js").write_text(
"/* 由 tools/make_icons.py 生成,不要手工编辑 —— 改图标请改脚本再重跑 */\n"
"window.CUTE_ICONS = {\n"
f" fallback: '{FALLBACK}',\n names: [\n {names},\n ],\n"
f" aliases: {{\n {aliases},\n }},\n"
"};\n", encoding="utf-8")
print(f"-> {dst} ({len(ICONS)} 个图标) + icons.js")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--preview", action="store_true", help="额外生成预览页")
ap.add_argument("--sync-web", action="store_true", help="同时同步给网页后台")
args = ap.parse_args()
build()
if args.preview:
preview()
if args.sync_web:
sync_web()
if __name__ == "__main__":
main()