From 9a144f159f5eb5a9fac0acf738c036be7fe9fb12 Mon Sep 17 00:00:00 2001 From: handsomeAq Date: Thu, 24 Sep 2026 14:24:34 +0800 Subject: [PATCH] =?UTF-8?q?AI=20=E6=8E=A5=E5=85=A5=20V1.0=20UI=E6=9B=B4?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codebuddy/memory/MEMORY.md | 3 + assets/icons/assistant.svg | 4 + assets/icons/bill.svg | 5 + assets/icons/bonus.svg | 7 + assets/icons/camera.svg | 5 + assets/icons/check.svg | 4 + assets/icons/education.svg | 5 + assets/icons/entertainment.svg | 5 + assets/icons/error.svg | 5 + assets/icons/expense.svg | 4 + assets/icons/eye.svg | 4 + assets/icons/file.svg | 5 + assets/icons/folder-open.svg | 4 + assets/icons/folder.svg | 3 + assets/icons/food.svg | 5 + assets/icons/housing.svg | 4 + assets/icons/image.svg | 5 + assets/icons/inbox.svg | 4 + assets/icons/income.svg | 4 + assets/icons/invest.svg | 7 + assets/icons/lock.svg | 5 + assets/icons/medical.svg | 4 + assets/icons/note.svg | 5 + assets/icons/other.svg | 5 + assets/icons/parttime.svg | 5 + assets/icons/phone.svg | 5 + assets/icons/pin.svg | 4 + assets/icons/salary.svg | 4 + assets/icons/search.svg | 4 + assets/icons/shopping.svg | 4 + assets/icons/target.svg | 5 + assets/icons/transport.svg | 6 + assets/icons/user.svg | 4 + assets/icons/utilities.svg | 4 + lib/core/module/app_module.dart | 6 +- lib/models/bill.dart | 2 +- lib/models/category.dart | 2 +- lib/models/statistics_response.dart | 2 +- lib/pages/add/widgets/manual_entry_tab.dart | 9 +- lib/pages/add/widgets/ocr_tab.dart | 5 +- lib/pages/assistant/assistant_page.dart | 14 +- lib/pages/bill/bill_edit_page.dart | 9 +- lib/pages/bill/bill_home_page.dart | 3 +- lib/pages/bill/widgets/bill_group_card.dart | 3 +- lib/pages/bill/widgets/budget_card.dart | 7 +- .../bill/widgets/category_rank_list.dart | 3 +- lib/pages/home/dashboard_page.dart | 5 +- lib/pages/login/login_page.dart | 187 ++------ lib/pages/mine/mine_page.dart | 13 +- lib/pages/note/folder_picker_sheet.dart | 9 +- lib/pages/note/note_detail_page.dart | 6 +- lib/pages/note/note_edit_page.dart | 4 +- lib/pages/note/note_list_page.dart | 7 +- lib/providers/ai_provider.dart | 63 ++- lib/utils/toast.dart | 22 +- lib/widgets/category_picker_grid.dart | 3 +- lib/widgets/cute_icon.dart | 83 ++++ lib/widgets/state_views.dart | 15 +- pubspec.lock | 56 +++ pubspec.yaml | 4 + test/ai_refresh_test.dart | 110 +++++ test/bill_logic_test.dart | 3 +- test/cute_icon_test.dart | 96 +++++ tools/_icons_preview.html | 86 ++++ tools/make_icons.py | 398 ++++++++++++++++++ 65 files changed, 1195 insertions(+), 196 deletions(-) create mode 100644 assets/icons/assistant.svg create mode 100644 assets/icons/bill.svg create mode 100644 assets/icons/bonus.svg create mode 100644 assets/icons/camera.svg create mode 100644 assets/icons/check.svg create mode 100644 assets/icons/education.svg create mode 100644 assets/icons/entertainment.svg create mode 100644 assets/icons/error.svg create mode 100644 assets/icons/expense.svg create mode 100644 assets/icons/eye.svg create mode 100644 assets/icons/file.svg create mode 100644 assets/icons/folder-open.svg create mode 100644 assets/icons/folder.svg create mode 100644 assets/icons/food.svg create mode 100644 assets/icons/housing.svg create mode 100644 assets/icons/image.svg create mode 100644 assets/icons/inbox.svg create mode 100644 assets/icons/income.svg create mode 100644 assets/icons/invest.svg create mode 100644 assets/icons/lock.svg create mode 100644 assets/icons/medical.svg create mode 100644 assets/icons/note.svg create mode 100644 assets/icons/other.svg create mode 100644 assets/icons/parttime.svg create mode 100644 assets/icons/phone.svg create mode 100644 assets/icons/pin.svg create mode 100644 assets/icons/salary.svg create mode 100644 assets/icons/search.svg create mode 100644 assets/icons/shopping.svg create mode 100644 assets/icons/target.svg create mode 100644 assets/icons/transport.svg create mode 100644 assets/icons/user.svg create mode 100644 assets/icons/utilities.svg create mode 100644 lib/widgets/cute_icon.dart create mode 100644 test/ai_refresh_test.dart create mode 100644 test/cute_icon_test.dart create mode 100644 tools/_icons_preview.html create mode 100644 tools/make_icons.py diff --git a/.codebuddy/memory/MEMORY.md b/.codebuddy/memory/MEMORY.md index b0a0c5f..57de386 100644 --- a/.codebuddy/memory/MEMORY.md +++ b/.codebuddy/memory/MEMORY.md @@ -36,6 +36,9 @@ - 历史会话存后端(`chat_session` / `chat_message` 两张表),App 只发 `sessionId + content`。**工具调用的中间过程不入库**,所以 App 不用理解 tool 消息格式。 - 助手气泡用 `MarkdownBody` 渲染(回答里常有列表/代码块),用户气泡用 `SelectableText`。 - 后端未配 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。 diff --git a/assets/icons/assistant.svg b/assets/icons/assistant.svg new file mode 100644 index 0000000..c143800 --- /dev/null +++ b/assets/icons/assistant.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/bill.svg b/assets/icons/bill.svg new file mode 100644 index 0000000..4b0a614 --- /dev/null +++ b/assets/icons/bill.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/bonus.svg b/assets/icons/bonus.svg new file mode 100644 index 0000000..493de0a --- /dev/null +++ b/assets/icons/bonus.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/camera.svg b/assets/icons/camera.svg new file mode 100644 index 0000000..1cd5237 --- /dev/null +++ b/assets/icons/camera.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/check.svg b/assets/icons/check.svg new file mode 100644 index 0000000..bbe4544 --- /dev/null +++ b/assets/icons/check.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/education.svg b/assets/icons/education.svg new file mode 100644 index 0000000..f40fcbc --- /dev/null +++ b/assets/icons/education.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/entertainment.svg b/assets/icons/entertainment.svg new file mode 100644 index 0000000..19529a0 --- /dev/null +++ b/assets/icons/entertainment.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/error.svg b/assets/icons/error.svg new file mode 100644 index 0000000..1b4f826 --- /dev/null +++ b/assets/icons/error.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/expense.svg b/assets/icons/expense.svg new file mode 100644 index 0000000..c469d14 --- /dev/null +++ b/assets/icons/expense.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/eye.svg b/assets/icons/eye.svg new file mode 100644 index 0000000..a42b49b --- /dev/null +++ b/assets/icons/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/file.svg b/assets/icons/file.svg new file mode 100644 index 0000000..6b24d8f --- /dev/null +++ b/assets/icons/file.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/folder-open.svg b/assets/icons/folder-open.svg new file mode 100644 index 0000000..dd077a3 --- /dev/null +++ b/assets/icons/folder-open.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/folder.svg b/assets/icons/folder.svg new file mode 100644 index 0000000..5cca37b --- /dev/null +++ b/assets/icons/folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/food.svg b/assets/icons/food.svg new file mode 100644 index 0000000..a4fabe0 --- /dev/null +++ b/assets/icons/food.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/housing.svg b/assets/icons/housing.svg new file mode 100644 index 0000000..223431e --- /dev/null +++ b/assets/icons/housing.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/image.svg b/assets/icons/image.svg new file mode 100644 index 0000000..cc853a2 --- /dev/null +++ b/assets/icons/image.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/inbox.svg b/assets/icons/inbox.svg new file mode 100644 index 0000000..62786c8 --- /dev/null +++ b/assets/icons/inbox.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/income.svg b/assets/icons/income.svg new file mode 100644 index 0000000..342a29d --- /dev/null +++ b/assets/icons/income.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/invest.svg b/assets/icons/invest.svg new file mode 100644 index 0000000..79971c5 --- /dev/null +++ b/assets/icons/invest.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/lock.svg b/assets/icons/lock.svg new file mode 100644 index 0000000..b5122ee --- /dev/null +++ b/assets/icons/lock.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/medical.svg b/assets/icons/medical.svg new file mode 100644 index 0000000..a8f5bd0 --- /dev/null +++ b/assets/icons/medical.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/note.svg b/assets/icons/note.svg new file mode 100644 index 0000000..11fab71 --- /dev/null +++ b/assets/icons/note.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/other.svg b/assets/icons/other.svg new file mode 100644 index 0000000..9cb5a69 --- /dev/null +++ b/assets/icons/other.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/parttime.svg b/assets/icons/parttime.svg new file mode 100644 index 0000000..79671be --- /dev/null +++ b/assets/icons/parttime.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/phone.svg b/assets/icons/phone.svg new file mode 100644 index 0000000..f13724d --- /dev/null +++ b/assets/icons/phone.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/pin.svg b/assets/icons/pin.svg new file mode 100644 index 0000000..227a76c --- /dev/null +++ b/assets/icons/pin.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/salary.svg b/assets/icons/salary.svg new file mode 100644 index 0000000..b362978 --- /dev/null +++ b/assets/icons/salary.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/search.svg b/assets/icons/search.svg new file mode 100644 index 0000000..3762ba6 --- /dev/null +++ b/assets/icons/search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/shopping.svg b/assets/icons/shopping.svg new file mode 100644 index 0000000..157442b --- /dev/null +++ b/assets/icons/shopping.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/target.svg b/assets/icons/target.svg new file mode 100644 index 0000000..73d00b5 --- /dev/null +++ b/assets/icons/target.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/transport.svg b/assets/icons/transport.svg new file mode 100644 index 0000000..6d05504 --- /dev/null +++ b/assets/icons/transport.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/user.svg b/assets/icons/user.svg new file mode 100644 index 0000000..9646a13 --- /dev/null +++ b/assets/icons/user.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/utilities.svg b/assets/icons/utilities.svg new file mode 100644 index 0000000..7122029 --- /dev/null +++ b/assets/icons/utilities.svg @@ -0,0 +1,4 @@ + + + + diff --git a/lib/core/module/app_module.dart b/lib/core/module/app_module.dart index 9161079..e2dc4f4 100644 --- a/lib/core/module/app_module.dart +++ b/lib/core/module/app_module.dart @@ -30,7 +30,7 @@ class AppModule { /// 一句话说明,让用户在首页就能看懂这个模块是干什么的 final String blurb; - /// 用 emoji 当图标 —— 和项目现有的卡片风格一致(笔记入口、预算提示都是 emoji) + /// 图标名,对应 `assets/icons/.svg`(见 [cuteIconNames]) final String icon; /// 打开模块入口页 @@ -53,7 +53,7 @@ final List appModules = [ id: 'bill', name: '记账', blurb: '收支记录、统计与预算', - icon: '💰', + icon: 'bill', open: (_) => const BillHomePage(), summary: (_) => const BillModuleSummary(), ), @@ -61,7 +61,7 @@ final List appModules = [ id: 'note', name: '笔记', blurb: 'Markdown 知识库,随时查看和记录', - icon: '📝', + icon: 'note', open: (_) => const NoteListPage(), summary: (_) => const NoteModuleSummary(), ), diff --git a/lib/models/bill.dart b/lib/models/bill.dart index 14bb020..1238d61 100644 --- a/lib/models/bill.dart +++ b/lib/models/bill.dart @@ -39,7 +39,7 @@ class Bill { id: asInt(json['id']), categoryId: asInt(json['categoryId']), categoryName: asNonEmpty(json['categoryName'], '未分类'), - categoryIcon: asNonEmpty(json['categoryIcon'], '📦'), + categoryIcon: asNonEmpty(json['categoryIcon'], 'other'), amount: asDouble(json['amount']), description: asString(json['description']), billDate: parseBillDate(asString(json['billDate'])), diff --git a/lib/models/category.dart b/lib/models/category.dart index a0505c7..4f850be 100644 --- a/lib/models/category.dart +++ b/lib/models/category.dart @@ -17,7 +17,7 @@ class Category { factory Category.fromJson(Map json) => Category( id: (json['id'] as num?)?.toInt() ?? 0, name: json['name']?.toString() ?? '', - icon: json['icon']?.toString() ?? '📦', + icon: json['icon']?.toString() ?? 'other', type: (json['type'] as num?)?.toInt() ?? 1, sortOrder: (json['sortOrder'] as num?)?.toInt(), ); diff --git a/lib/models/statistics_response.dart b/lib/models/statistics_response.dart index 1ce0b05..7a647ae 100644 --- a/lib/models/statistics_response.dart +++ b/lib/models/statistics_response.dart @@ -68,7 +68,7 @@ class CategoryStat { factory CategoryStat.fromJson(Map json) => CategoryStat( categoryId: asInt(json['categoryId']), categoryName: asNonEmpty(json['categoryName'], '未分类'), - categoryIcon: asNonEmpty(json['categoryIcon'], '📦'), + categoryIcon: asNonEmpty(json['categoryIcon'], 'other'), amount: asDouble(json['amount']), type: asInt(json['type'], fallback: 1) == 2 ? 2 : 1, ); diff --git a/lib/pages/add/widgets/manual_entry_tab.dart b/lib/pages/add/widgets/manual_entry_tab.dart index a7c3bd5..aa3827d 100644 --- a/lib/pages/add/widgets/manual_entry_tab.dart +++ b/lib/pages/add/widgets/manual_entry_tab.dart @@ -9,6 +9,7 @@ import '../../../utils/date_util.dart'; import '../../../utils/toast.dart'; import '../../../widgets/category_picker_grid.dart'; import '../../../widgets/state_views.dart'; +import '../../../widgets/cute_icon.dart'; /// 手动记账表单(复刻 uniApp add.vue 手动 Tab) class ManualEntryTab extends ConsumerStatefulWidget { @@ -78,9 +79,9 @@ class _ManualEntryTabState extends ConsumerState { Widget _typeSelector(AddBillFormState form, AddBillFormNotifier notifier) { return Row( children: [ - _typeCard('📤', '支出', 1, form, notifier), + _typeCard('expense', '支出', 1, form, notifier), const SizedBox(width: 10), - _typeCard('📥', '收入', 2, form, notifier), + _typeCard('income', '收入', 2, form, notifier), ], ); } @@ -107,7 +108,7 @@ class _ManualEntryTabState extends ConsumerState { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text(icon, style: const TextStyle(fontSize: 20)), + CuteIcon(icon, size: 22), const SizedBox(width: 8), Text( label, @@ -228,7 +229,7 @@ class _ManualEntryTabState extends ConsumerState { await notifier.submit(); _amountController.clear(); _descriptionController.clear(); - if (context.mounted) showToast(context, '✅ 记账成功'); + if (context.mounted) showToast(context, '记账成功', icon: 'check'); } on ApiException catch (e) { if (context.mounted) showToast(context, e.message, error: true); } catch (_) { diff --git a/lib/pages/add/widgets/ocr_tab.dart b/lib/pages/add/widgets/ocr_tab.dart index a9187ce..5b47a5e 100644 --- a/lib/pages/add/widgets/ocr_tab.dart +++ b/lib/pages/add/widgets/ocr_tab.dart @@ -8,6 +8,7 @@ import '../../../core/network/api_exception.dart'; import '../../../providers/ocr_provider.dart'; import '../../../utils/toast.dart'; import 'ocr_result_card.dart'; +import '../../../widgets/cute_icon.dart'; /// OCR 识别 Tab:选图 → 识别 → 结果列表(复刻 uniApp add.vue OCR Tab) class OcrTab extends ConsumerWidget { @@ -38,7 +39,7 @@ class OcrTab extends ConsumerWidget { Future _saveDirectly(BuildContext context, WidgetRef ref) async { try { await ref.read(ocrProvider.notifier).saveDirectly(); - if (context.mounted) showToast(context, '✅ 已保存识别的账单'); + if (context.mounted) showToast(context, '已保存识别的账单', icon: 'check'); } on ApiException catch (e) { if (context.mounted) showToast(context, e.message, error: true); } catch (_) { @@ -86,7 +87,7 @@ class OcrTab extends ConsumerWidget { child: const Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('📷', style: TextStyle(fontSize: 36)), + CuteIcon('camera', size: 40), SizedBox(height: 6), Text('点击选择图片', style: TextStyle(color: Color(0xFF8D6E63))), ], diff --git a/lib/pages/assistant/assistant_page.dart b/lib/pages/assistant/assistant_page.dart index 4f7c48a..7d5eeea 100644 --- a/lib/pages/assistant/assistant_page.dart +++ b/lib/pages/assistant/assistant_page.dart @@ -9,6 +9,7 @@ import '../../models/ai.dart'; import '../../providers/ai_provider.dart'; import '../../utils/toast.dart'; import '../../widgets/state_views.dart'; +import '../../widgets/cute_icon.dart'; /// 助手:和 AI 对话,问账单、问笔记。 /// @@ -32,6 +33,17 @@ class _AssistantPageState extends ConsumerState { '我的笔记里有哪些关于 MySQL 的?', ]; + @override + void initState() { + super.initState(); + // 打开就把上一次的对话展开,而不是每次都从空白开始。 + // 放进 postFrameCallback:initState 里改 provider 状态会撞上「构建期间改状态」。 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ref.read(assistantProvider.notifier).restoreLastSessionIfNeeded(); + }); + } + @override void dispose() { _input.dispose(); @@ -156,7 +168,7 @@ class _AssistantPageState extends ConsumerState { return ListView( padding: const EdgeInsets.fromLTRB(24, 40, 24, 0), children: [ - const Center(child: Text('✨', style: TextStyle(fontSize: 44))), + const Center(child: CuteIcon('assistant', size: 48)), const SizedBox(height: 14), const Center( child: Text( diff --git a/lib/pages/bill/bill_edit_page.dart b/lib/pages/bill/bill_edit_page.dart index a1fec44..c84ef17 100644 --- a/lib/pages/bill/bill_edit_page.dart +++ b/lib/pages/bill/bill_edit_page.dart @@ -9,6 +9,7 @@ import '../../utils/date_util.dart'; import '../../utils/toast.dart'; import '../../widgets/category_picker_grid.dart'; import '../../widgets/state_views.dart'; +import '../../widgets/cute_icon.dart'; /// 账单编辑页(复刻 uniApp pages/bill/detail.vue 的编辑能力,并补上删除) class BillEditPage extends ConsumerStatefulWidget { @@ -117,9 +118,9 @@ class _BillEditPageState extends ConsumerState { Widget _typeSelector(BillEditState state, BillEditNotifier notifier) { return Row( children: [ - _typeCard('📤', '支出', 1, state, notifier), + _typeCard('expense', '支出', 1, state, notifier), const SizedBox(width: 10), - _typeCard('📥', '收入', 2, state, notifier), + _typeCard('income', '收入', 2, state, notifier), ], ); } @@ -149,7 +150,7 @@ class _BillEditPageState extends ConsumerState { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text(icon, style: const TextStyle(fontSize: 20)), + CuteIcon(icon, size: 22), const SizedBox(width: 8), Text( label, @@ -314,7 +315,7 @@ class _BillEditPageState extends ConsumerState { try { await notifier.save(); if (!mounted) return; - showToast(context, '✅ 已保存'); + showToast(context, '已保存', icon: 'check'); Navigator.of(context).pop(); } on ApiException catch (e) { if (mounted) showToast(context, e.message, error: true); diff --git a/lib/pages/bill/bill_home_page.dart b/lib/pages/bill/bill_home_page.dart index 62caab3..3b9f2f3 100644 --- a/lib/pages/bill/bill_home_page.dart +++ b/lib/pages/bill/bill_home_page.dart @@ -16,6 +16,7 @@ import 'bill_statistics_page.dart'; import 'widgets/bill_group_card.dart'; import 'widgets/budget_card.dart'; import 'widgets/month_switcher.dart'; +import '../../widgets/cute_icon.dart'; /// 记账模块的首页:月份切换 + 本月汇总 + 预算环 + 按日期分组的账单列表。 /// @@ -184,7 +185,7 @@ class BillHomePage extends ConsumerWidget { child: const Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('🎯 ', style: TextStyle(fontSize: 14)), + CuteIcon('target', size: 15), Text( '仅可查看本月预算', style: TextStyle(fontSize: 13, color: Color(0xFF8D6E63)), diff --git a/lib/pages/bill/widgets/bill_group_card.dart b/lib/pages/bill/widgets/bill_group_card.dart index a9d0ab4..a774402 100644 --- a/lib/pages/bill/widgets/bill_group_card.dart +++ b/lib/pages/bill/widgets/bill_group_card.dart @@ -4,6 +4,7 @@ import '../../../models/bill.dart'; import '../../../utils/amount_util.dart'; import '../../../utils/bill_grouping.dart'; import '../../../utils/date_util.dart'; +import '../../../widgets/cute_icon.dart'; /// 单日分组卡:组头(日期 + 分开的支出/收入小计)+ 账单条目 class BillGroupCard extends StatelessWidget { @@ -91,7 +92,7 @@ class _BillTile extends StatelessWidget { color: Color(0xFFFBEEDD), ), alignment: Alignment.center, - child: Text(bill.categoryIcon, style: const TextStyle(fontSize: 19)), + child: CuteIcon(bill.categoryIcon, size: 21), ), const SizedBox(width: 12), Expanded( diff --git a/lib/pages/bill/widgets/budget_card.dart b/lib/pages/bill/widgets/budget_card.dart index d66b197..f1b3c6e 100644 --- a/lib/pages/bill/widgets/budget_card.dart +++ b/lib/pages/bill/widgets/budget_card.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../models/budget.dart'; import '../../../utils/amount_util.dart'; +import '../../../widgets/cute_icon.dart'; import '../../../widgets/donut_chart.dart'; import '../../../widgets/state_views.dart'; @@ -30,7 +31,7 @@ class BudgetCard extends StatelessWidget { Widget _noBudget() { return const EmptyView( - icon: '🎯', + icon: 'target', title: '本月未设置预算', tip: '可在「我的 → 预算设置」中设置', padding: EdgeInsets.symmetric(vertical: 20), @@ -110,7 +111,9 @@ class BudgetCard extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.center, 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( '¥${formatAmount(info.remainingDaily)}', style: const TextStyle( diff --git a/lib/pages/bill/widgets/category_rank_list.dart b/lib/pages/bill/widgets/category_rank_list.dart index 6a415c7..f155aa1 100644 --- a/lib/pages/bill/widgets/category_rank_list.dart +++ b/lib/pages/bill/widgets/category_rank_list.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../../models/statistics_response.dart'; import '../../../utils/amount_util.dart'; +import '../../../widgets/cute_icon.dart'; /// 分类排行:emoji + 名称 + 进度条 + 金额 + 占比 class CategoryRankList extends StatelessWidget { @@ -50,7 +51,7 @@ class CategoryRankList extends StatelessWidget { children: [ Row( children: [ - Text(item.categoryIcon, style: const TextStyle(fontSize: 16)), + CuteIcon(item.categoryIcon, size: 18), const SizedBox(width: 8), Expanded( child: Text( diff --git a/lib/pages/home/dashboard_page.dart b/lib/pages/home/dashboard_page.dart index f96638d..6a45b6e 100644 --- a/lib/pages/home/dashboard_page.dart +++ b/lib/pages/home/dashboard_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../core/module/app_module.dart'; +import '../../widgets/cute_icon.dart'; import '../assistant/assistant_page.dart'; /// 首页仪表盘:AI 输入入口 + 模块卡片网格。 @@ -106,7 +107,7 @@ class DashboardPage extends StatelessWidget { ), child: Row( children: [ - const Text('✨', style: TextStyle(fontSize: 18)), + const CuteIcon('assistant', size: 19), const SizedBox(width: 10), const Expanded( child: Text( @@ -175,7 +176,7 @@ class DashboardPage extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(module.icon, style: const TextStyle(fontSize: 24)), + CuteIcon(module.icon, size: 26), const SizedBox(height: 8), Text( module.name, diff --git a/lib/pages/login/login_page.dart b/lib/pages/login/login_page.dart index bbd070c..b57ff4f 100644 --- a/lib/pages/login/login_page.dart +++ b/lib/pages/login/login_page.dart @@ -10,8 +10,13 @@ import '../../providers/auth_provider.dart'; import '../../utils/crypto.dart'; import '../../utils/toast.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 { const LoginPage({super.key}); @@ -21,15 +26,12 @@ class LoginPage extends ConsumerStatefulWidget { class _LoginPageState extends ConsumerState with SingleTickerProviderStateMixin { - bool _isRegister = false; bool _showPassword = false; bool _loading = false; - bool _agreeProtocol = false; bool _rememberMe = false; final _usernameController = TextEditingController(); final _passwordController = TextEditingController(); - final _nicknameController = TextEditingController(); final _formKey = GlobalKey(); late final AnimationController _floatController = AnimationController( @@ -53,7 +55,6 @@ class _LoginPageState extends ConsumerState _floatController.dispose(); _usernameController.dispose(); _passwordController.dispose(); - _nicknameController.dispose(); super.dispose(); } @@ -66,43 +67,24 @@ class _LoginPageState extends ConsumerState showToast(context, '请输入用户名和密码', error: true); return; } - if (_isRegister) { - if (password.length < 6) { - showToast(context, '密码至少6位', error: true); - return; - } - if (!_agreeProtocol) { - showToast(context, '请先阅读并同意用户协议'); - return; - } - } setState(() => _loading = true); try { - final api = ref.read(authApiProvider); - final response = _isRegister - ? await api.register( - username, - password, - _nicknameController.text.trim().isEmpty - ? username - : _nicknameController.text.trim(), - ) - : await api.login(username, password); + final response = await ref.read(authApiProvider).login(username, password); // 记住密码 - if (!_isRegister && _rememberMe) { + if (_rememberMe) { await LocalStorage.instance.saveRememberedPassword( username, encryptPassword(password), ); - } else if (!_isRegister && !_rememberMe) { + } else { await LocalStorage.instance.clearRememberedPassword(); } await ref.read(authProvider.notifier).loginSuccess(response); if (!mounted) return; - showToast(context, _isRegister ? '🎉 注册成功' : '🚀 登录成功'); + showToast(context, '登录成功', icon: 'check'); Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute(builder: (_) => const MainShell()), (route) => false, @@ -308,90 +290,55 @@ class _LoginPageState extends ConsumerState key: _formKey, child: Column( children: [ - _buildSegmentTabs(), - const SizedBox(height: 20), _inputField( - icon: '👤', + icon: 'user', controller: _usernameController, hint: '请输入用户名', maxLength: 20, ), const SizedBox(height: 14), _inputField( - icon: '🔒', + icon: 'lock', controller: _passwordController, hint: '请输入密码', maxLength: 20, obscure: !_showPassword, suffix: IconButton( - icon: Text( - _showPassword ? '👁️' : '👁️‍🗨️', - style: const TextStyle(fontSize: 16), + // 一个图标用颜色表示状态:显示中=品牌色,隐藏中=浅灰 + icon: CuteIcon( + 'eye', + size: 18, + tint: _showPassword + ? const Color(0xFFC8956E) + : const Color(0xFFBCAAA4), ), onPressed: () => setState(() => _showPassword = !_showPassword), ), ), - if (_isRegister) ...[ - const SizedBox(height: 14), - _inputField( - icon: '✨', - controller: _nicknameController, - hint: '请输入昵称(可选)', - maxLength: 20, - ), - ], - if (!_isRegister) ...[ - const SizedBox(height: 8), - Row( - children: [ - SizedBox( - width: 22, - height: 22, - child: Checkbox( - value: _rememberMe, - activeColor: const Color(0xFFC8956E), - onChanged: (v) => - setState(() => _rememberMe = v ?? false), - ), + const SizedBox(height: 8), + 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( - '记住密码', - style: TextStyle( - fontSize: 13, - color: Color(0xFF8D6E63), - ), + ), + const SizedBox(width: 6), + const Text( + '记住密码', + style: TextStyle( + 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), SizedBox( width: double.infinity, @@ -414,9 +361,9 @@ class _LoginPageState extends ConsumerState color: Colors.white, ), ) - : Text( - _isRegister ? '注 册' : '登 录', - style: const TextStyle( + : const Text( + '登 录', + style: TextStyle( fontSize: 17, fontWeight: FontWeight.bold, letterSpacing: 4, @@ -437,52 +384,6 @@ class _LoginPageState extends ConsumerState 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({ required String icon, @@ -501,7 +402,7 @@ class _LoginPageState extends ConsumerState ), child: Row( children: [ - Text(icon, style: const TextStyle(fontSize: 18)), + CuteIcon(icon, size: 20), const SizedBox(width: 10), Expanded( child: TextField( diff --git a/lib/pages/mine/mine_page.dart b/lib/pages/mine/mine_page.dart index b256fa7..8298502 100644 --- a/lib/pages/mine/mine_page.dart +++ b/lib/pages/mine/mine_page.dart @@ -12,6 +12,7 @@ import '../../providers/budget_provider.dart'; import '../../utils/amount_input_formatter.dart'; import '../../utils/amount_util.dart'; import '../../utils/toast.dart'; +import '../../widgets/cute_icon.dart'; import '../../widgets/state_views.dart'; /// 「我的」页面 @@ -101,7 +102,7 @@ class MinePage extends ConsumerWidget { height: 60, decoration: const BoxDecoration(shape: BoxShape.circle, color: Colors.white), alignment: Alignment.center, - child: const Text('👤', style: TextStyle(fontSize: 30)), + child: const CuteIcon('user', size: 32), ), const SizedBox(width: 16), Expanded( @@ -338,7 +339,9 @@ class MinePage extends ConsumerWidget { child: Row( mainAxisAlignment: MainAxisAlignment.center, 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( '¥${formatAmount(budget.remainingDaily)}', style: const TextStyle( @@ -408,7 +411,7 @@ class MinePage extends ConsumerWidget { padding: const EdgeInsets.symmetric(vertical: 12), child: Row( children: [ - Text(module.icon, style: const TextStyle(fontSize: 20)), + CuteIcon(module.icon, size: 21), const SizedBox(width: 12), Expanded( child: Column( @@ -456,7 +459,7 @@ class MinePage extends ConsumerWidget { try { await ref.read(accountProvider.notifier).setBalance(input); - if (context.mounted) showToast(context, '✅ 余额已更新'); + if (context.mounted) showToast(context, '余额已更新', icon: 'check'); } on ApiException catch (e) { if (context.mounted) showToast(context, e.message, error: true); } catch (_) { @@ -477,7 +480,7 @@ class MinePage extends ConsumerWidget { try { await ref.read(budgetProvider.notifier).setMonthBudget(input); - if (context.mounted) showToast(context, '✅ 预算已保存'); + if (context.mounted) showToast(context, '预算已保存', icon: 'check'); } on ApiException catch (e) { if (context.mounted) showToast(context, e.message, error: true); } catch (_) { diff --git a/lib/pages/note/folder_picker_sheet.dart b/lib/pages/note/folder_picker_sheet.dart index 2d82e85..6ae9cae 100644 --- a/lib/pages/note/folder_picker_sheet.dart +++ b/lib/pages/note/folder_picker_sheet.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../models/note.dart'; +import '../../widgets/cute_icon.dart'; /// 目录选择面板 /// @@ -114,7 +115,7 @@ class _FolderPickerSheetState extends State { children: [ if (widget.showAll) _row( - icon: '📚', + icon: 'education', name: '全部', count: widget.tree.totalCount, depth: 0, @@ -122,7 +123,7 @@ class _FolderPickerSheetState extends State { onTap: () => Navigator.pop(context, NoteLocation.all), ), _row( - icon: '📥', + icon: 'income', name: '未分类', count: widget.tree.uncategorizedCount, depth: 0, @@ -153,7 +154,7 @@ class _FolderPickerSheetState extends State { mainAxisSize: MainAxisSize.min, children: [ _row( - icon: (open && hasChildren) ? '📂' : '📁', + icon: (open && hasChildren) ? 'folder-open' : 'folder', name: node.name, count: node.noteCount, depth: depth, @@ -221,7 +222,7 @@ class _FolderPickerSheetState extends State { ), child: Row( children: [ - Text(icon, style: const TextStyle(fontSize: 15)), + CuteIcon(icon, size: 17), const SizedBox(width: 10), Expanded( child: Text( diff --git a/lib/pages/note/note_detail_page.dart b/lib/pages/note/note_detail_page.dart index 1cdd8d2..98b08e1 100644 --- a/lib/pages/note/note_detail_page.dart +++ b/lib/pages/note/note_detail_page.dart @@ -231,7 +231,7 @@ class NoteDetailPage extends ConsumerWidget { borderRadius: BorderRadius.circular(10), ), child: const Text( - '🖼 图片加载失败', + '图片加载失败', style: TextStyle(fontSize: 13, color: Color(0xFFBCAAA4)), ), ), @@ -277,7 +277,7 @@ class NoteDetailPage extends ConsumerWidget { onPressed: () { Clipboard.setData(ClipboardData(text: resolveAssetUrl(href))); Navigator.pop(sheetContext); - showToast(context, '✅ 已复制链接'); + showToast(context, '已复制链接', icon: 'check'); }, style: FilledButton.styleFrom(backgroundColor: const Color(0xFFC8956E)), child: const Text('复制'), @@ -358,7 +358,7 @@ class NoteDetailPage extends ConsumerWidget { await ref.read(noteApiProvider).update(note.id, pinned: !note.pinned); ref.invalidate(noteDetailProvider(noteId)); 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) { if (context.mounted) showToast(context, e.message, error: true); } diff --git a/lib/pages/note/note_edit_page.dart b/lib/pages/note/note_edit_page.dart index 8c64abc..02fc627 100644 --- a/lib/pages/note/note_edit_page.dart +++ b/lib/pages/note/note_edit_page.dart @@ -106,7 +106,7 @@ class _NoteEditPageState extends ConsumerState { } if (!mounted) return; _saved = true; - showToast(context, '✅ 已保存'); + showToast(context, '已保存', icon: 'check'); Navigator.of(context).pop(true); } on ApiException catch (e) { if (mounted) showToast(context, e.message, error: true); @@ -246,7 +246,7 @@ class _NoteEditPageState extends ConsumerState { final content = _contentController.text; if (content.trim().isEmpty) { return const Center( - child: EmptyView(icon: '📄', title: '还没有内容', tip: '切回编辑模式写点什么吧'), + child: EmptyView(icon: 'file', title: '还没有内容', tip: '切回编辑模式写点什么吧'), ); } diff --git a/lib/pages/note/note_list_page.dart b/lib/pages/note/note_list_page.dart index 737a188..63e0159 100644 --- a/lib/pages/note/note_list_page.dart +++ b/lib/pages/note/note_list_page.dart @@ -9,6 +9,7 @@ import '../../utils/toast.dart'; import '../../widgets/state_views.dart'; import 'folder_picker_sheet.dart'; import 'note_detail_page.dart'; +import '../../widgets/cute_icon.dart'; /// 笔记列表页 /// @@ -308,7 +309,7 @@ class _NoteListPageState extends ConsumerState { children: [ SizedBox(height: MediaQuery.of(context).size.height * 0.18), EmptyView( - icon: state.hasFilter ? '🔍' : '📝', + icon: state.hasFilter ? 'search' : 'note', title: state.hasFilter ? '没有匹配的笔记' : '还没有笔记', tip: state.hasFilter ? '换个关键词或标签试试' @@ -383,7 +384,7 @@ class _NoteListPageState extends ConsumerState { Row( children: [ if (note.pinned) ...[ - const Text('📌', style: TextStyle(fontSize: 13)), + const CuteIcon('pin', size: 14), const SizedBox(width: 4), ], Expanded( @@ -471,7 +472,7 @@ class _NoteListPageState extends ConsumerState { await ref.read(noteListProvider.notifier).refresh(); ref.invalidate(noteTagsProvider); ref.invalidate(noteFolderTreeProvider); // 目录上的笔记数变了 - if (mounted) showToast(context, '✅ 已记录'); + if (mounted) showToast(context, '已记录', icon: 'check'); } on ApiException catch (e) { if (mounted) showToast(context, e.message, error: true); } catch (_) { diff --git a/lib/providers/ai_provider.dart b/lib/providers/ai_provider.dart index 0f2dcaf..d91410b 100644 --- a/lib/providers/ai_provider.dart +++ b/lib/providers/ai_provider.dart @@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../api/ai_api.dart'; import '../core/network/api_exception.dart'; import '../models/ai.dart'; +import 'bill_refresh.dart'; +import 'note_provider.dart'; /// 后端 AI 是否配好了(没配 key 时 App 显示提示,而不是让用户白问一句) final aiStatusProvider = FutureProvider.autoDispose((ref) { @@ -65,12 +67,45 @@ class AssistantState { static const _sentinel = Object(); } +/// 会让服务端数据发生变化的工具。**名字要和后端 AiToolService 保持一致** —— +/// 改名了这里就会静默失效(不报错,只是数据不刷新)。 +const _mutatingTools = { + 'create_bill': _Mutated.bill, + 'create_note': _Mutated.note, +}; + +enum _Mutated { bill, note } + class AssistantNotifier extends Notifier { + /// 本次 App 运行期间是否已经自动恢复过。 + /// 用它拦住「用户主动开了新对话,切个 Tab 回来旧对话又被拉回来」。 + bool _autoRestored = false; + @override AssistantState build() => const AssistantState(); - /// 新对话:清空界面,但**不清 sessionId 为 null 之前的会话**(后端那边还在,能从历史里翻出来) + /// 打开助手页时调用:界面还是空的就把最近一次对话展开。 + /// + /// 为什么放在页面里而不是 provider 的 build():build() 里不能同步改 state, + /// 而这个恢复动作是异步的、而且只该在这个页面被打开时发生。 + Future 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() { + _autoRestored = true; state = const AssistantState(); } @@ -119,6 +154,8 @@ class AssistantNotifier extends Notifier { // SSE 的收尾阶段出错(服务端异步派发、代理截断)很常见, // 弹个红条出来只会让人以为回答没拿到。 var sawDone = false; + // 这次对话动过哪些数据 + final mutated = <_Mutated>{}; try { await for (final event in ref .read(aiApiProvider) @@ -129,6 +166,8 @@ class AssistantNotifier extends Notifier { case AiEventType.delta: _appendDelta(event.content ?? ''); case AiEventType.tool: + final what = _mutatingTools[event.toolName]; + if (what != null) mutated.add(what); state = state.copyWith(activeTool: event.toolName); case AiEventType.done: // 新会话时后端在这一刻才把 ID 告诉我们,必须记下来, @@ -150,6 +189,28 @@ class AssistantNotifier extends Notifier { } _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) { diff --git a/lib/utils/toast.dart b/lib/utils/toast.dart index b0d7398..9b56a7b 100644 --- a/lib/utils/toast.dart +++ b/lib/utils/toast.dart @@ -1,13 +1,27 @@ import 'package:flutter/material.dart'; import '../core/navigation/navigator_key.dart'; +import '../widgets/cute_icon.dart'; /// 统一提示封装(对齐 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).showSnackBar( 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), behavior: SnackBarBehavior.floating, duration: const Duration(seconds: 2), @@ -16,7 +30,7 @@ void showToast(BuildContext context, String message, {bool error = false}) { } /// 全局提示:供没有 BuildContext 的地方调用(网络层、Provider) -void showGlobalToast(String message, {bool error = false}) { +void showGlobalToast(String message, {bool error = false, String? icon}) { final context = navigatorKey.currentContext; - if (context != null) showToast(context, message, error: error); + if (context != null) showToast(context, message, error: error, icon: icon); } diff --git a/lib/widgets/category_picker_grid.dart b/lib/widgets/category_picker_grid.dart index 77ea6e3..54274da 100644 --- a/lib/widgets/category_picker_grid.dart +++ b/lib/widgets/category_picker_grid.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../models/category.dart'; +import 'cute_icon.dart'; /// 分类 emoji 宫格(手动记账页与账单编辑页共用) /// @@ -67,7 +68,7 @@ class CategoryPickerGrid extends StatelessWidget { ), ), alignment: Alignment.center, - child: Text(category.icon, style: const TextStyle(fontSize: 24)), + child: CuteIcon(category.icon, size: 26), ), const SizedBox(height: 4), Text( diff --git a/lib/widgets/cute_icon.dart b/lib/widgets/cute_icon.dart new file mode 100644 index 0000000..07f16ea --- /dev/null +++ b/lib/widgets/cute_icon.dart @@ -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 = { + // 记账分类 + '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 = { + '🍔': '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'; +} diff --git a/lib/widgets/state_views.dart b/lib/widgets/state_views.dart index 42974c3..2dc0b02 100644 --- a/lib/widgets/state_views.dart +++ b/lib/widgets/state_views.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'cute_icon.dart'; + /// 加载态 class LoadingView extends StatelessWidget { const LoadingView({super.key, this.tip = '加载中...', this.padding = const EdgeInsets.all(40)}); @@ -43,8 +45,8 @@ class ErrorView extends StatelessWidget { ), child: Column( children: [ - const Text('😵', style: TextStyle(fontSize: 30)), - const SizedBox(height: 8), + const CuteIcon('error', size: 32), + const SizedBox(height: 10), Text( message, textAlign: TextAlign.center, @@ -69,10 +71,13 @@ class ErrorView extends StatelessWidget { } /// 空状态 +/// +/// [icon] 传图标名(见 [cuteIconNames]);为了兼容以前传 emoji 的调用点, +/// 走的是 [resolveIconName],emoji 和未知值都能落到一个兜底图标上。 class EmptyView extends StatelessWidget { const EmptyView({ super.key, - this.icon = '📭', + this.icon = 'inbox', required this.title, this.tip, this.padding = const EdgeInsets.symmetric(vertical: 40), @@ -89,8 +94,8 @@ class EmptyView extends StatelessWidget { padding: padding, child: Column( children: [ - Text(icon, style: const TextStyle(fontSize: 44)), - const SizedBox(height: 10), + CuteIcon(icon, size: 46), + const SizedBox(height: 12), Text(title, style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037))), if (tip != null) ...[ const SizedBox(height: 6), diff --git a/pubspec.lock b/pubspec.lock index 7bcda96..5ba21bf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -238,6 +238,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted 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: dependency: "direct dev" description: flutter @@ -440,6 +448,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted 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: dependency: transitive description: @@ -464,6 +480,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted 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: dependency: transitive description: @@ -629,6 +653,30 @@ packages: url: "https://pub.flutter-io.cn" source: hosted 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: dependency: transitive description: @@ -661,6 +709,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 442c5d9..c9353d8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,7 @@ dependencies: intl: ^0.20.0 fl_chart: ^1.2.0 flutter_markdown_plus: ^1.0.12 + flutter_svg: ^2.3.0 dev_dependencies: flutter_test: @@ -57,6 +58,9 @@ flutter: assets: - assets/images/ + # 双色图标集,由 tools/make_icons.py 生成(改形状/配色改脚本再重跑, + # 不要手工编辑 assets/icons/ 下的文件) + - assets/icons/ # To add assets to your application, add an assets section, like this: # assets: diff --git a/test/ai_refresh_test.dart b/test/ai_refresh_test.dart new file mode 100644 index 0000000..b80ee9a --- /dev/null +++ b/test/ai_refresh_test.dart @@ -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 _events; + + @override + Stream chat({int? sessionId, required String content}) async* { + for (final e in _events) { + yield e; + } + } + + @override + Future> 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 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.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.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.delayed(Duration.zero); + + expect(builds(), 1, reason: '只读工具不该让 App 白刷一遍数据'); + }); +} diff --git a/test/bill_logic_test.dart b/test/bill_logic_test.dart index f8d7a93..92731ab 100644 --- a/test/bill_logic_test.dart +++ b/test/bill_logic_test.dart @@ -130,7 +130,8 @@ void main() { expect(b.amount, 35.5); expect(b.billDate, '2026-09-21'); expect(b.categoryName, '未分类'); - expect(b.categoryIcon, '📦'); + // 图标兜底值是「图标名」不再是 emoji(2026-09-24 换成自绘图标集) + expect(b.categoryIcon, 'other'); expect(b.type, 1); // 缺省按支出 }); diff --git a/test/cute_icon_test.dart b/test/cute_icon_test.dart new file mode 100644 index 0000000..7d0c634 --- /dev/null +++ b/test/cute_icon_test.dart @@ -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 = {}; + 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() + .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 = []; + 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')}', + ); + }); +} diff --git a/tools/_icons_preview.html b/tools/_icons_preview.html new file mode 100644 index 0000000..1641870 --- /dev/null +++ b/tools/_icons_preview.html @@ -0,0 +1,86 @@ + +图标预览 + +

强宝小助手 · 图标集

+

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

+

记账分类

+
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
+
+

放大检查(72px)

+
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
+
\ No newline at end of file diff --git a/tools/make_icons.py b/tools/make_icons.py new file mode 100644 index 0000000..e6163a7 --- /dev/null +++ b/tools/make_icons.py @@ -0,0 +1,398 @@ +# -*- coding: utf-8 -*- +""" +生成 App 的双色图标集(SVG)。 + +为什么要有这个脚本: + 图标是「设计系统」的一部分,不是一堆散文件。把形状和配色集中写在这里, + 改动(换圆角、调粗细、换某个颜色的深浅)只需要改一个地方再重跑, + 不用手工去改 30 个 SVG。 + +设计规范: + · 画布 24×24,内容控制在 3~21 之间留出视觉边距 + · 线条粗细统一 2.0,端点和连接处全部圆角(这是「可爱」的关键) + · 双色:浅色填充 + 深色描边(类似 Phosphor Duotone 的做法) + · 配色以品牌焦糖色 #C8956E 为锚,压低饱和度,保证整体暖调统一 + +产物: + assets/icons/.svg 提交进仓库,由 flutter_svg 渲染 + <后台>/static/admin/icons/.svg 同一套图标给网页后台用 + <后台>/static/admin/icons.js 图标名清单 + emoji 兼容映射 + tools/_icons_preview.html 预览页(浏览器打开检查,不提交) + +用法: + python tools/make_icons.py # 只生成 App 的图标 + python tools/make_icons.py --preview # 额外生成预览页 + python tools/make_icons.py --sync-web # 同时同步给网页后台(改了图标就带上) +""" +from __future__ import annotations + +import argparse +import pathlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +OUT_DIR = ROOT / "assets" / "icons" +PREVIEW = ROOT / "tools" / "_icons_preview.html" + +# 网页后台也要用同一套图标(否则分类图标从 emoji 换成图标名之后, +# 后台会把 "food" 当文字显示出来)。这里是后台的静态资源目录。 +WEB_DIR = ROOT.parent / "AI-accounting-soft" / "src" / "main" / "resources" / "static" / "admin" + +STROKE = 2.0 + +# ---------------------------------------------------------------- 形状原语 +# 所有图标都由这几个原语拼出来 —— 这是「风格统一」的机制保证: +# 圆角半径、线宽、端点全是同一套参数,不会出现某个图标特别突兀。 + + +def rr(x: float, y: float, w: float, h: float, r: float) -> str: + """圆角矩形""" + return ( + f"M{x + r} {y}h{w - 2 * r}a{r} {r} 0 0 1 {r} {r}v{h - 2 * r}" + f"a{r} {r} 0 0 1 -{r} {r}h-{w - 2 * r}a{r} {r} 0 0 1 -{r} -{r}" + f"v-{h - 2 * r}a{r} {r} 0 0 1 {r} -{r}z" + ) + + +def circle(cx: float, cy: float, r: float) -> str: + return f"M{cx - r} {cy}a{r} {r} 0 1 0 {2 * r} 0a{r} {r} 0 1 0 -{2 * r} 0z" + + +def bowl() -> str: + """半圆碗(上沿平、下半圆)""" + return "M4 11h16a8 8 0 0 1-16 0z" + + +def sparkle() -> str: + """四角星光 —— 用曲线收腰,直线版会像菱形""" + return ( + "M12 2.5C12.6 7.8 16.2 11.4 21.5 12 16.2 12.6 12.6 16.2 12 21.5" + "5.8 16.2 9.4 12.6 4.2 12 9.4 11.4 12.6 7.8 12 2.5z" + ) + + +# ---------------------------------------------------------------- 配色 +# (描边色, 填充色)。以焦糖 #C8956E 为锚,整体暖调、低饱和。 +C_BRAND = "#C8956E" +C_BRAND_L = "#F6E8DB" +C_TEXT = "#8D6E63" # UI 图标默认色(次级文字色) +C_TEXT_L = "#F1E7E0" + +PALETTE = { + "food": ("#E2914B", "#FBE7D0"), # 橙 + "transport": ("#5B8DB8", "#DCE8F2"), # 灰蓝 + "shopping": ("#D97C93", "#F7E2E8"), # 玫红 + "entertainment": ("#9B7BC4", "#EBE3F5"), # 紫 + "medical": ("#E0725C", "#FAE2DD"), # 珊瑚红 + "education": ("#6B8E4E", "#E4EDDA"), # 橄榄绿 + "housing": ("#B5794F", "#F0E2D6"), # 棕 + "utilities": ("#D9A93C", "#F8ECD2"), # 琥珀 + "phone": ("#4FA0B0", "#DBEDF1"), # 青 + "other": ("#9A8C80", "#EBE5E0"), # 灰棕 + "salary": (C_BRAND, C_BRAND_L), # 品牌焦糖 + "bonus": ("#D99C3C", "#F9EDD5"), # 金 + "invest": ("#4FA98A", "#DBEFE8"), # 青绿 + "parttime": ("#8A7BB8", "#E8E3F4"), # 蓝紫 + "bill": (C_BRAND, C_BRAND_L), + "note": (C_BRAND, C_BRAND_L), + "assistant": ("#D9A93C", "#F8ECD2"), +} + +# 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' ' + for d, filled in paths + ) + return ( + f'\n' + f'{body}\n\n' + ) + + +def build() -> None: + OUT_DIR.mkdir(parents=True, exist_ok=True) + for name, (key, paths) in ICONS.items(): + (OUT_DIR / f"{name}.svg").write_text(render(name, key, paths), encoding="utf-8") + print(f"-> {OUT_DIR} ({len(ICONS)} 个图标)") + + +def preview() -> None: + """生成预览页 —— SVG 好不好看必须用眼睛验,光看代码看不出来""" + groups = [("记账分类", list(PALETTE.keys())[:14]), + ("模块 / 界面", [n for n in ICONS if n not in list(PALETTE.keys())[:14]])] + html = [""" +图标预览 + +

强宝小助手 · 图标集

+

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

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

{title}

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

放大检查(72px)

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