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
+5 -4
View File
@@ -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<ManualEntryTab> {
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<ManualEntryTab> {
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<ManualEntryTab> {
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 (_) {
+3 -2
View File
@@ -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<void> _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))),
],
+13 -1
View File
@@ -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<AssistantPage> {
'我的笔记里有哪些关于 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<AssistantPage> {
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(
+5 -4
View File
@@ -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<BillEditPage> {
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<BillEditPage> {
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<BillEditPage> {
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);
+2 -1
View File
@@ -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)),
+2 -1
View File
@@ -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(
+5 -2
View File
@@ -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(
@@ -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(
+3 -2
View File
@@ -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,
+44 -143
View File
@@ -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<LoginPage>
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<FormState>();
late final AnimationController _floatController = AnimationController(
@@ -53,7 +55,6 @@ class _LoginPageState extends ConsumerState<LoginPage>
_floatController.dispose();
_usernameController.dispose();
_passwordController.dispose();
_nicknameController.dispose();
super.dispose();
}
@@ -66,43 +67,24 @@ class _LoginPageState extends ConsumerState<LoginPage>
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<LoginPage>
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<LoginPage>
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<LoginPage>
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<LoginPage>
),
child: Row(
children: [
Text(icon, style: const TextStyle(fontSize: 18)),
CuteIcon(icon, size: 20),
const SizedBox(width: 10),
Expanded(
child: TextField(
+8 -5
View File
@@ -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 (_) {
+5 -4
View File
@@ -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<FolderPickerSheet> {
children: [
if (widget.showAll)
_row(
icon: '📚',
icon: 'education',
name: '全部',
count: widget.tree.totalCount,
depth: 0,
@@ -122,7 +123,7 @@ class _FolderPickerSheetState extends State<FolderPickerSheet> {
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<FolderPickerSheet> {
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<FolderPickerSheet> {
),
child: Row(
children: [
Text(icon, style: const TextStyle(fontSize: 15)),
CuteIcon(icon, size: 17),
const SizedBox(width: 10),
Expanded(
child: Text(
+3 -3
View File
@@ -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);
}
+2 -2
View File
@@ -106,7 +106,7 @@ class _NoteEditPageState extends ConsumerState<NoteEditPage> {
}
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<NoteEditPage> {
final content = _contentController.text;
if (content.trim().isEmpty) {
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 'folder_picker_sheet.dart';
import 'note_detail_page.dart';
import '../../widgets/cute_icon.dart';
/// 笔记列表页
///
@@ -308,7 +309,7 @@ class _NoteListPageState extends ConsumerState<NoteListPage> {
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<NoteListPage> {
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<NoteListPage> {
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 (_) {