diff --git a/.gitignore b/.gitignore index 0109cf6..13600ff 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,8 @@ frontend/android/app/release/ release/ backups/ filing/ +.backup-before-update-*/ +.deploy/ *.apk *.sql *.pem @@ -48,6 +50,11 @@ frontend/android/app/libs/push/*.aar # Local tool metadata .agents/ .codex/ +.codex-build/ +.codex-*-a/ +.codex-*-b/ +.codex-*.patch +backend/.codex-tools/ .git-local/ .idea/ .vscode/ @@ -55,3 +62,6 @@ frontend/android/app/libs/push/*.aar *.log *.tmp *.swp + +# Generated Admin Web static assets +backend/MiaoJiZhang.Api/wwwroot/assets/* diff --git a/docs/DESIGN.md b/docs/DESIGN.md index e5a0ac5..8c7bad9 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1,6 +1,8 @@ # 喵记账 · AI 记账 APP 设计文档 -> 状态:UI/UX 设计阶段 · 更新于 2026-07-18 +> 状态:设计规范已落地,持续跟随实现迭代 · 更新于 2026-08-21 + +实现进度、已完成模块和当前风险见 [`STATUS.md`](STATUS.md)。本文保留产品决策、视觉规范和后台边界。 ## 1. 产品概述 diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index 242bbac..c6d497e 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -8,11 +8,11 @@ ### 后端版本号 - 文件:`backend/MiaoJiZhang.Api/Program.cs` -- 变量:`apiVersion = "20260718-1600"`(修改此行即可) +- 配置:`Build:Version`(未注入时默认为 `dev`) - 显示位置: - `GET /api/ping` 返回 JSON `{ "version": "...", "built": "..." }` - `GET /api/version` 同上 -- 更新方法:修改 `apiVersion` 字符串,重启后端 +- 更新方法:通过环境变量或部署配置注入 `Build__Version`,重启后端 ### Flutter App 版本号 - 文件:`frontend/lib/shared/version.dart` @@ -22,9 +22,9 @@ - 登录页底部 - 「我的」页底部 - 构建命令: - ```bash + ```powershell flutter clean - flutter build apk --release --dart-define=APP_VERSION=20260718-1600 + flutter build apk --release --dart-define=APP_VERSION=20260821-135 ``` ⚠️ **必须加 `--dart-define`,否则 App 显示 `vdev`** @@ -33,8 +33,7 @@ - 更新方法:重新构建并完整替换后端静态资源目录。 ```bash cd admin-web && npm run build - find ../backend/MiaoJiZhang.Api/wwwroot -mindepth 1 -delete - cp -a dist/. ../backend/MiaoJiZhang.Api/wwwroot/ + Copy-Item -Recurse -Force dist\* ..\backend\MiaoJiZhang.Api\wwwroot\ ``` --- @@ -58,11 +57,10 @@ dotnet run --project MiaoJiZhang.Api 临时设置 `Admin__CookieSecure=false`。 ### 2. Admin Web -```bash +```powershell cd admin-web npm run build -find ../backend/MiaoJiZhang.Api/wwwroot -mindepth 1 -delete -cp -a dist/. ../backend/MiaoJiZhang.Api/wwwroot/ +Copy-Item -Recurse -Force dist\* ..\backend\MiaoJiZhang.Api\wwwroot\ ``` 浏览器打开 `http://localhost:5000/` 或 `http://{电脑IP}:5000/`。如界面未更新请 **Ctrl+Shift+R** 强制刷新。 @@ -71,7 +69,7 @@ cp -a dist/. ../backend/MiaoJiZhang.Api/wwwroot/ cd frontend flutter clean flutter pub get -flutter build apk --release --dart-define=APP_VERSION=20260718-1600 +flutter build apk --release --dart-define=APP_VERSION=20260821-135 adb install -r build/app/outputs/flutter-apk/app-release.apk ``` @@ -111,10 +109,10 @@ jizhang/ │ └── features/ # 业务页面 ├── admin-web/ # Vue3 + Ant Design 后台 │ └── src/ -│ ├── App.vue # ★ 侧边栏版本号 +│ ├── App.vue # Admin 壳与导航 │ └── views/ # 管理页面 -├── docs/ # 项目说明、设计规范、开发说明 -└── design/ # UI 稿、UX 原型、配色探索 +├── design/ # UI 稿、UX 原型、配色探索 +└── docs/ # 项目状态、设计、开发与客户端对接文档 ``` --- @@ -136,5 +134,6 @@ jizhang/ | 修改了代码但 App 不变 | 没有 clean 构建,或没加 dart-define | `flutter clean && flutter build apk --release --dart-define=APP_VERSION=...` | | 修改了代码但后台不变 | 后端还在跑旧进程 | 先 `Stop-Process -Name dotnet -Force` 再 `dotnet run` | | Admin Web 界面不变 | 浏览器缓存 | Ctrl+Shift+R 强制刷新,或用无痕模式打开 | +| 数据库结构不一致 | 未执行最新 EF Migration | `dotnet ef database update` 后再启动服务 | | Windows 文件路径错误 | 中文路径编码 | 用 python3 读文件时加 `encoding='utf-8'` | | sed 破坏代码 | git-bash 的 sed 不兼容 | 禁止用 sed 改 dart/vue 源码,用 Write/Edit 工具 | diff --git a/docs/PROJECT.md b/docs/PROJECT.md index d1b230b..f29dbeb 100644 --- a/docs/PROJECT.md +++ b/docs/PROJECT.md @@ -1,6 +1,8 @@ # 喵记账 · 项目骨架 -> Flutter 客户端 + .NET 9 后端 · 搭建于 2026-07-18 +> Flutter 客户端 + .NET 9 后端 · 当前状态更新于 2026-08-21 + +当前实现状态与待办以 [`STATUS.md`](STATUS.md) 为准;本文保留项目入口、运行方式和接口索引。 ## 目录结构 @@ -50,7 +52,7 @@ jizhang/ 2. 改 `backend/MiaoJiZhang.Api/appsettings.json` 里的连接串(`your_password`) 3. 改 `Jwt:Secret` 为 ≥32 字符的随机串 4. `cd backend && dotnet run --project MiaoJiZhang.Api` - - 开发期 `Program.cs` 会 `EnsureCreated` 建表 + 跑种子数据 + - 数据库结构通过 `MiaoJiZhang.Infrastructure/Persistence/Migrations` 维护;本地首次启动前执行 `dotnet ef database update` - OpenAPI 文档:`http://localhost:5000/openapi/v1.json` 5. 测试注册: ```bash @@ -93,15 +95,18 @@ flutter run 9. ~~性格设置页~~ ✅(PUT /api/users/me/companion + CompanionPage,形象/性格/三滑杆) 10. ~~分类管理~~ ✅(POST/DELETE /api/categories + CategoryManagePage,自定义分类增删) 11. ~~语音/OCR 流程~~ ✅(POST /api/parse + ParseSheet 确认入账;ASR/OCR SDK 接入后自动填充文本) -12. ~~LLM 接入层~~ ✅(ILlmClient 抽象 + NullLlmClient 回退规则版;对接真实 LLM 时实现该接口即可) +12. ~~LLM 接入层~~ ✅(已有客户端抽象、权限/额度和回退路径;仍需真实环境联调) + +13. ~~Admin Web 后台~~ ✅(配置、分类、AI 形象/性格、表情包、用户、推送、审计与管理员账号页面已存在) +14. ~~EF Core Migration~~ ✅(已有多次迁移;发布前仍需执行全新库和升级库验证) ## 遗留(需要外部资源/发布前处理) - ASR(语音转文字)与拍照 OCR 的 SDK 选型接入(ParseSheet 已留好入口,接入后把识别文本传入即可) - 真实 LLM 对接(实现 ILlmClient;性格 Prompt 已在 AiPersona 表可后台调) - SVG 图标(category_icon.dart / stickerStyle 集中管理,直接替换映射即可) -- Admin Web 后台(`docs/DESIGN.md` 第 8 节;实体 AppConfig/AiPersona/AiAvatar/Sticker 已建好) -- EF Migration 替代 EnsureCreated;Jwt Secret 换生产值;baseUrl 环境化 +- 真实 LLM、OCR/ASR、截图识别和推送供应商的生产配置与联调 +- JWT Secret、管理员引导变量、数据库连接串、API Base URL 和更新下载地址环境化 ## 已完成的接口清单 @@ -129,6 +134,6 @@ flutter run ## 技术债 / 注意 -- 后端用 `EnsureCreated` 跳过 Migration,便于快速起盘;正式环境前要切到 `dotnet ef migrations add Init` -- `appsettings.json` 的 `Jwt:Secret` 是占位值,**生产必须换** -- 前端 `main_shell.dart` 的 5 个 branch 暂用 `Placeholder()`,下一步逐个填真实页面 +- `Build:Version` 未注入时后端显示 `dev`;发布构建必须显式注入版本。 +- `appsettings.json` 的 JWT、数据库和外部服务配置不能直接用于生产。 +- Admin Web 的 `wwwroot/assets` 是构建生成物;本地构建后不要把临时 hashed 文件混入提交。 diff --git a/docs/STATUS.md b/docs/STATUS.md new file mode 100644 index 0000000..f2f7063 --- /dev/null +++ b/docs/STATUS.md @@ -0,0 +1,71 @@ +# 记之 · 当前项目状态 + +> 更新时间:2026-08-21 + +## 总览 + +项目已经从 UI/接口骨架进入「客户端、后台和服务端能力联调」阶段。当前主干包含 Flutter 客户端、.NET 9 API、Vue 3 管理后台、数据库迁移和自动化测试;`main` 与 `origin/main` 当前指向同一提交 `8828851`。 + +## 已完成模块 + +### 后端 API + +- 用户注册、登录、JWT、用户资料、引导和双模式切换。 +- 账本、系统/自定义分类、手动记账、月账单、统计、搜索和软删除回收站。 +- AI 聊天、意图识别、AI 直接入账、来源追溯、预算、月报、表情包和 AI 伙伴设置。 +- 文本解析入口、截图识别相关接口、识别幂等和 AI 权限/额度控制。 +- 管理后台 API:品牌配置、系统分类、AI 形象/性格、表情包、用户、推送、管理员账号、审计和密码变更。 +- 账户注销清理、数据导出/恢复、同步冲突和访客数据合并相关服务。 +- EF Core/MySQL 迁移已存在,覆盖初始模型、账户注销、分类颜色、AI 权限、额度、推送基础设施和转账/后台安全等变更。 + +### Flutter 客户端 + +- 启动、登录/注册、引导选模式和 AI 伙伴。 +- 普通模式首页、账本切换、手动记账、账单详情/编辑、聊天和全 AI 模式。 +- 统计、AI 月报、预算、搜索、分类管理、伙伴设置、回收站、数据管理和推送设置。 +- 语音/OCR 确认页、截图识别设置/批量导入、识别诊断、离线提示、同步冲突和本地数据导出。 +- 客户端更新检查、推送注册和本地数据库/会话恢复。 + +当前 `frontend/pubspec.yaml` 版本为 `1.2.5+135`;发布时仍需使用正式构建参数和发布说明。 + +### Admin Web + +已具备登录、强制改密、仪表盘、系统设置、品牌配置、系统分类、AI 性格、AI 形象、表情包、用户、推送、管理员账号和审计页面。管理员角色权限由路由和后端共同控制。 + +### 测试 + +- 后端:API 集成测试、推送集成测试、推送供应商测试。 +- Flutter:模型、主题/时间、本地导出、本地转移数据库、更新流程、视觉契约和 Widget 测试。 + +## 当前待办 + +### P0:联调与发布阻塞项 + +- 在真实环境验证 LLM、OCR/ASR、截图识别和推送供应商配置。 +- 验证最新 EF Migration 在全新库、升级库和回滚/重试场景下的行为。 +- 将数据库连接串、JWT Secret、管理员引导变量、API Base URL 和更新下载地址全部改为部署环境注入。 +- 完成 Android 内部构建的截图权限、后台识别、证书和网络安全回归。 + +### P1:质量与体验 + +- 对核心流程做端到端回归:注册 → 引导 → 记账 → AI 解析 → 撤销 → 月报/预算。 +- 完善错误提示、离线待同步、同步冲突和外部服务失败时的降级行为。 +- 检查 Admin Web 发布资源生成策略,避免把本地 hashed 静态文件和构建目录带入工作区。 + +### P2:后续演进 + +- 完成正式 SVG/插画资源和更多运营内容配置。 +- 补充后台数据看板、脱敏意图失败样本和内容版本回滚。 +- 完成各平台正式签名、应用商店元数据和更新渠道配置。 + +## 工作区清理记录 + +2026-08-21 已清理未跟踪的完整备份、历史部署、`.codex-tools` 缓存、Codex 临时构建/补丁目录,以及后端 `wwwroot/assets` 的未跟踪生成资源。源码、`docs/`、设计原型、APK、Android 新代码和测试文件未删除。 + +## 建议验证顺序 + +1. 启动 MySQL,执行 `dotnet ef database update`。 +2. 配置后端外部服务和管理员引导变量,运行 API 集成测试。 +3. 构建 Admin Web,验证登录、权限、配置和发布资源。 +4. 运行 Flutter 测试,再构建 `1.2.5+135` 内部包。 +5. 用真实设备验证截图识别、推送、离线同步和更新检查。 diff --git a/frontend/lib/app/app.dart b/frontend/lib/app/app.dart index e28c7cd..1ebda3b 100644 --- a/frontend/lib/app/app.dart +++ b/frontend/lib/app/app.dart @@ -183,6 +183,7 @@ class _MiaoJiAppState extends State with WidgetsBindingObserver { } Future _resumeServices() async { + unawaited(ApiClient.instance.probe()); await _runSafely(RecognitionImportService.configureNativeContext); await _runSafely(RecognitionImportService.importAutomatic); await _runSafely(PushService.instance.refresh); diff --git a/frontend/lib/features/add/add_page.dart b/frontend/lib/features/add/add_page.dart index 046311a..cfc0eb1 100644 --- a/frontend/lib/features/add/add_page.dart +++ b/frontend/lib/features/add/add_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:miaoji_zhang/shared/api/api_client.dart'; @@ -7,6 +9,7 @@ import 'package:miaoji_zhang/shared/theme/app_theme.dart'; import 'package:miaoji_zhang/shared/widgets/app_icons.dart'; import 'package:miaoji_zhang/shared/widgets/category_icon.dart'; import 'package:miaoji_zhang/shared/widgets/app_controls.dart'; +import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart'; import 'package:miaoji_zhang/shared/services/shanghai_time.dart'; class AddPage extends StatefulWidget { @@ -36,6 +39,7 @@ class _AddPageState extends State { DateTime _occurredAt = ShanghaiTime.now; bool _saving = false; bool _loadingCategories = true; + int _loadRevision = 0; String get _categoryType => _tab == 'transfer' ? _transferDirection == 'in' ? 'income' @@ -60,7 +64,7 @@ class _AddPageState extends State { @override void initState() { super.initState(); - _loadCategories(); + unawaited(_loadCategories()); } @override @@ -71,38 +75,60 @@ class _AddPageState extends State { } Future _loadCategories() async { - setState(() => _loadingCategories = true); - try { - final results = await Future.wait([ - TxApi.categories('expense'), - TxApi.categories('income'), - ]); - if (!mounted) return; + final revision = ++_loadRevision; + final expense = TxApi.categoriesLocal('expense'); + final income = TxApi.categoriesLocal('income'); + if (mounted) { setState(() { - _categoriesByType['expense'] = results[0]; - _categoriesByType['income'] = results[1]; - if (_selectedByType['expense'] == null && results[0].isNotEmpty) { - _selectedByType['expense'] = results[0].first; - } - if (_selectedByType['income'] == null && results[1].isNotEmpty) { - _selectedByType['income'] = results[1].first; - } - _selectedByType['transfer_out'] ??= results[0].isEmpty - ? null - : results[0].first; - _selectedByType['transfer_in'] ??= results[1].isEmpty - ? null - : results[1].first; + _applyCategories(expense, income); + _loadingCategories = false; }); - } catch (error) { - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(apiErrorMessage(error)))); - } - } finally { - if (mounted) setState(() => _loadingCategories = false); } + await Future.wait([ + TxApi.categoriesRemote('expense').then((values) { + if (!mounted || revision != _loadRevision) return; + setState(() { + _applyCategories(values, _categoriesByType['income'] ?? const []); + }); + }, onError: (_) {}), + TxApi.categoriesRemote('income').then((values) { + if (!mounted || revision != _loadRevision) return; + setState(() { + _applyCategories(_categoriesByType['expense'] ?? const [], values); + }); + }, onError: (_) {}), + ]); + } + + void _applyCategories(List expense, List income) { + _categoriesByType['expense'] = expense; + _categoriesByType['income'] = income; + _selectedByType['expense'] = _preserveSelection( + _selectedByType['expense'], + expense, + ); + _selectedByType['income'] = _preserveSelection( + _selectedByType['income'], + income, + ); + _selectedByType['transfer_out'] = _preserveSelection( + _selectedByType['transfer_out'], + expense, + ); + _selectedByType['transfer_in'] = _preserveSelection( + _selectedByType['transfer_in'], + income, + ); + } + + CategoryItem? _preserveSelection( + CategoryItem? selected, + List values, + ) { + if (values.isEmpty) return null; + if (selected == null) return values.first; + return values.where((item) => item.id == selected.id).firstOrNull ?? + values.first; } void _switchTab(String tab) { @@ -239,6 +265,7 @@ class _AddPageState extends State { ), onPressed: () => context.pop(), ), + actions: [BackendStatusIcon(onRetry: _loadCategories)], ), body: SafeArea( child: LayoutBuilder( diff --git a/frontend/lib/features/home/pages/home_page.dart b/frontend/lib/features/home/pages/home_page.dart index 7f2b391..1ab7718 100644 --- a/frontend/lib/features/home/pages/home_page.dart +++ b/frontend/lib/features/home/pages/home_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:miaoji_zhang/shared/api/api_client.dart'; @@ -10,6 +12,7 @@ import 'package:miaoji_zhang/shared/theme/app_theme.dart'; import 'package:miaoji_zhang/shared/widgets/category_icon.dart'; import 'package:miaoji_zhang/shared/widgets/app_icons.dart'; import 'package:miaoji_zhang/shared/widgets/async_error_view.dart'; +import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart'; import 'package:miaoji_zhang/features/home/pages/tx_detail_page.dart'; import 'package:miaoji_zhang/features/home/pages/ledger_sheet.dart'; import 'package:miaoji_zhang/shared/services/shanghai_time.dart'; @@ -28,6 +31,7 @@ class HomePageState extends State { bool _loading = true; String? _error; late DateTime _month; + int _loadRevision = 0; @override void initState() { @@ -36,7 +40,7 @@ class HomePageState extends State { PublicConfigApi.companionNotifier.addListener(_refreshCompanion); TransactionEvents.revision.addListener(_refreshTransactions); CurrentLedgerStore.instance.addListener(_refreshLedger); - refresh(); + unawaited(refresh()); } void _refreshCompanion() { @@ -60,33 +64,56 @@ class HomePageState extends State { } Future refresh() async { + final revision = ++_loadRevision; setState(() { _loading = true; _error = null; }); try { - await CurrentLedgerStore.instance.ensureLoaded(); - final results = await Future.wait([ - TxApi.month(_month.year, _month.month), - BudgetApi.get(_month.year, _month.month), - ]); - if (mounted) { + await CurrentLedgerStore.instance.loadCached(); + final localSummary = TxApi.monthLocal(_month.year, _month.month); + final localBudgets = BudgetApi.getLocal(_month.year, _month.month); + if (mounted && revision == _loadRevision) { setState(() { - _summary = results[0] as MonthSummary; - _budgets = results[1] as BudgetsData; + _summary = localSummary; + _budgets = localBudgets; }); } } catch (error) { - try { - final summary = await TxApi.month(_month.year, _month.month); - if (mounted) setState(() => _summary = summary); - } catch (fallbackError) { - if (mounted) { - setState(() => _error = apiErrorMessage(fallbackError)); - } + if (mounted && revision == _loadRevision) { + setState(() { + _error = apiErrorMessage(error); + _loading = false; + }); } - } finally { - if (mounted) setState(() => _loading = false); + return; + } + + await Future.wait([ + () async { + try { + await CurrentLedgerStore.instance.refreshRemote(); + } catch (_) {} + }(), + () async { + try { + final summary = await TxApi.monthRemote(_month.year, _month.month); + if (mounted && revision == _loadRevision) { + setState(() => _summary = summary); + } + } catch (_) {} + }(), + () async { + try { + final budgets = await BudgetApi.getRemote(_month.year, _month.month); + if (mounted && revision == _loadRevision) { + setState(() => _budgets = budgets); + } + } catch (_) {} + }(), + ]); + if (mounted && revision == _loadRevision) { + setState(() => _loading = false); } } @@ -175,6 +202,7 @@ class HomePageState extends State { ), ), Spacer(), + BackendStatusIcon(onRetry: refresh), IconButton( icon: AppIcons.icon( AppIcons.search, diff --git a/frontend/lib/features/settings/category_manage_page.dart b/frontend/lib/features/settings/category_manage_page.dart index 95b296e..caaf531 100644 --- a/frontend/lib/features/settings/category_manage_page.dart +++ b/frontend/lib/features/settings/category_manage_page.dart @@ -4,6 +4,7 @@ import 'package:miaoji_zhang/shared/api/business_api.dart'; import 'package:miaoji_zhang/shared/theme/app_theme.dart'; import 'package:miaoji_zhang/shared/widgets/app_controls.dart'; import 'package:miaoji_zhang/shared/widgets/app_icons.dart'; +import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart'; import 'package:miaoji_zhang/shared/widgets/category_icon.dart'; class CategoryManagePage extends StatefulWidget { @@ -20,6 +21,7 @@ class _CategoryManagePageState extends State { bool _reordering = false; bool _savingOrder = false; bool _orderDirty = false; + int _loadRevision = 0; List get _custom => _cats.where((category) => category.isCustom).toList(); @@ -31,19 +33,31 @@ class _CategoryManagePageState extends State { } Future _load() async { + final revision = ++_loadRevision; setState(() => _loading = true); try { - final cats = await TxApi.categories(_type); - if (mounted) { + final type = _type; + final cached = TxApi.categoriesLocal(type); + if (mounted && revision == _loadRevision) { setState(() { - _cats = cats; + _cats = cached; _orderDirty = false; + _loading = false; }); } + final remote = await TxApi.categoriesRemote(type); + if (mounted && revision == _loadRevision && type == _type) { + setState(() => _cats = remote); + } } catch (error) { - if (mounted) _showError(error); + if (mounted && revision == _loadRevision && _loading) { + setState(() => _loading = false); + _showError(error); + } } finally { - if (mounted) setState(() => _loading = false); + if (mounted && revision == _loadRevision && _loading) { + setState(() => _loading = false); + } } } @@ -424,6 +438,7 @@ class _CategoryManagePageState extends State { appBar: AppBar( title: Text('分类管理'), actions: [ + BackendStatusIcon(onRetry: _load), if (_custom.length > 1) TextButton( onPressed: _savingOrder ? null : _toggleReorder, diff --git a/frontend/lib/features/settings/companion_page.dart b/frontend/lib/features/settings/companion_page.dart index 2a9ee50..c589546 100644 --- a/frontend/lib/features/settings/companion_page.dart +++ b/frontend/lib/features/settings/companion_page.dart @@ -5,6 +5,7 @@ import 'package:miaoji_zhang/shared/api/config_api.dart'; import 'package:miaoji_zhang/shared/theme/app_theme.dart'; import 'package:miaoji_zhang/shared/widgets/app_controls.dart'; import 'package:miaoji_zhang/shared/widgets/app_icons.dart'; +import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart'; /// AI 性格设置页(P5):形象/性格从后台 API 动态拉取 class CompanionPage extends StatefulWidget { @@ -16,7 +17,9 @@ class CompanionPage extends StatefulWidget { class _CompanionPageState extends State { String _avatar = 'cat', _persona = 'sassy_cat'; double _roast = 60, _sticker = 70, _proactive = 40; - bool _saving = false, _loaded = false; + bool _saving = false; + bool _refreshing = false; + int _loadRevision = 0; List _avatars = []; List _personas = []; @@ -40,27 +43,52 @@ class _CompanionPageState extends State { } Future _load() async { - try { - final p = await AuthApi.me(); - if (mounted && p.aiCompanion != null) - setState(() { - _avatar = p.aiCompanion!.avatarKey; - _persona = p.aiCompanion!.personaKey; - _roast = p.aiCompanion!.roastLevel.toDouble(); - _sticker = p.aiCompanion!.stickerFrequency.toDouble(); - _proactive = p.aiCompanion!.proactiveLevel.toDouble(); - }); - } catch (_) {} - try { - final av = await PublicConfigApi.avatars(); - final ps = await PublicConfigApi.personas(); - if (mounted) - setState(() { - _avatars = av; - _personas = ps; - }); - } catch (_) {} - if (mounted) setState(() => _loaded = true); + final revision = ++_loadRevision; + setState(() => _refreshing = true); + + final cached = await Future.wait([ + AuthApi.cachedCompanion(), + PublicConfigApi.cachedAvatars(), + PublicConfigApi.cachedPersonas(), + ]); + if (!mounted || revision != _loadRevision) return; + _applyCompanion(cached[0] as AiCompanion?); + setState(() { + _avatars = cached[1] as List; + _personas = cached[2] as List; + }); + + await Future.wait([ + AuthApi.me(forceRemote: true).then((profile) { + if (mounted && revision == _loadRevision) { + _applyCompanion(profile.aiCompanion); + } + }, onError: (_) {}), + PublicConfigApi.avatars().then((values) { + if (mounted && revision == _loadRevision) { + setState(() => _avatars = values); + } + }, onError: (_) {}), + PublicConfigApi.personas().then((values) { + if (mounted && revision == _loadRevision) { + setState(() => _personas = values); + } + }, onError: (_) {}), + ]); + if (mounted && revision == _loadRevision) { + setState(() => _refreshing = false); + } + } + + void _applyCompanion(AiCompanion? companion) { + if (companion == null || !mounted) return; + setState(() { + _avatar = companion.avatarKey; + _persona = companion.personaKey; + _roast = companion.roastLevel.toDouble(); + _sticker = companion.stickerFrequency.toDouble(); + _proactive = companion.proactiveLevel.toDouble(); + }); } Future _save() async { @@ -80,26 +108,20 @@ class _CompanionPageState extends State { context, ).showSnackBar(const SnackBar(content: Text('已保存'))); } catch (e) { - if (mounted) + if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(apiErrorMessage(e)))); + } } finally { - if (mounted) setState(() => _saving = false); + if (mounted) { + setState(() => _saving = false); + } } } @override Widget build(BuildContext context) { - if (!_loaded) - return const Scaffold( - body: Center( - child: CircularProgressIndicator( - color: AppTheme.primary, - strokeWidth: 2, - ), - ), - ); final avatars = _avatars.isNotEmpty ? _avatars .map( @@ -122,7 +144,16 @@ class _CompanionPageState extends State { : _fallbackPersonas; return Scaffold( - appBar: AppBar(title: Text('AI 性格设置')), + appBar: AppBar( + title: Text('AI 性格设置'), + actions: [BackendStatusIcon(onRetry: _load)], + bottom: _refreshing + ? const PreferredSize( + preferredSize: Size.fromHeight(2), + child: LinearProgressIndicator(minHeight: 2), + ) + : null, + ), body: ListView( padding: const EdgeInsets.all(16), children: [ @@ -236,19 +267,24 @@ class _CompanionPageState extends State { ), ), SizedBox(height: 16), - ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: AppTheme.ai), - onPressed: _saving ? null : _save, - child: _saving - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : Text('保存设置'), + ValueListenableBuilder( + valueListenable: ApiClient.availability, + builder: (context, availability, _) => ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: AppTheme.ai), + onPressed: availability == BackendAvailability.online && !_saving + ? _save + : null, + child: _saving + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text('保存设置'), + ), ), ], ), diff --git a/frontend/lib/features/settings/recycle_bin_page.dart b/frontend/lib/features/settings/recycle_bin_page.dart index 0eb1d13..279687d 100644 --- a/frontend/lib/features/settings/recycle_bin_page.dart +++ b/frontend/lib/features/settings/recycle_bin_page.dart @@ -6,6 +6,7 @@ import 'package:miaoji_zhang/shared/services/transaction_events.dart'; import 'package:miaoji_zhang/shared/theme/app_theme.dart'; import 'package:miaoji_zhang/shared/widgets/category_icon.dart'; import 'package:miaoji_zhang/shared/widgets/app_controls.dart'; +import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart'; class RecycleBinPage extends StatefulWidget { const RecycleBinPage({super.key}); @@ -17,6 +18,7 @@ class RecycleBinPage extends StatefulWidget { class _RecycleBinPageState extends State { List _items = const []; bool _loading = true; + int _loadRevision = 0; @override void initState() { @@ -25,15 +27,30 @@ class _RecycleBinPageState extends State { } Future _load() async { + final revision = ++_loadRevision; setState(() => _loading = true); try { - await CurrentLedgerStore.instance.ensureLoaded(); - final items = await TxApi.recycleBin(); - if (mounted) setState(() => _items = items); + await CurrentLedgerStore.instance.loadCached(); + final cached = TxApi.recycleBinLocal(); + if (mounted && revision == _loadRevision) { + setState(() { + _items = cached; + _loading = false; + }); + } + final remote = await TxApi.recycleBinRemote(); + if (mounted && revision == _loadRevision) { + setState(() => _items = remote); + } } catch (error) { - if (mounted) _showError(error); + if (mounted && revision == _loadRevision && _loading) { + setState(() => _loading = false); + _showError(error); + } } finally { - if (mounted) setState(() => _loading = false); + if (mounted && revision == _loadRevision && _loading) { + setState(() => _loading = false); + } } } @@ -48,6 +65,7 @@ class _RecycleBinPageState extends State { } Future _permanentDelete(TxItem item) async { + if (ApiClient.availability.value != BackendAvailability.online) return; final confirmed = await _confirm('永久删除', '永久删除后无法恢复,聊天中的账单卡片会显示为已删除。'); if (!confirmed) return; try { @@ -59,7 +77,10 @@ class _RecycleBinPageState extends State { } Future _clear() async { - if (_items.isEmpty) return; + if (_items.isEmpty || + ApiClient.availability.value != BackendAvailability.online) { + return; + } final confirmed = await _confirm('清空回收站', '将永久删除当前账本回收站中的全部账单,无法恢复。'); if (!confirmed) return; try { @@ -79,12 +100,14 @@ class _RecycleBinPageState extends State { ); Future _showActions(TxItem item) async { + final online = ApiClient.availability.value == BackendAvailability.online; final action = await showJzOptionSheet( context, title: item.note ?? item.categoryName, - options: const [ - JzOption(value: 'restore', label: '恢复账单'), - JzOption(value: 'delete', label: '永久删除', subtitle: '删除后无法恢复'), + options: [ + const JzOption(value: 'restore', label: '恢复账单'), + if (online) + const JzOption(value: 'delete', label: '永久删除', subtitle: '删除后无法恢复'), ], ); if (action == 'restore') await _restore(item); @@ -103,9 +126,16 @@ class _RecycleBinPageState extends State { appBar: AppBar( title: Text('${CurrentLedgerStore.instance.currentName} · 回收站'), actions: [ - TextButton( - onPressed: _items.isEmpty ? null : _clear, - child: Text('清空'), + BackendStatusIcon(onRetry: _load), + ValueListenableBuilder( + valueListenable: ApiClient.availability, + builder: (context, availability, _) => TextButton( + onPressed: + _items.isEmpty || availability != BackendAvailability.online + ? null + : _clear, + child: Text('清空'), + ), ), ], ), diff --git a/frontend/lib/features/stats/report_page.dart b/frontend/lib/features/stats/report_page.dart index edf54fc..1547c69 100644 --- a/frontend/lib/features/stats/report_page.dart +++ b/frontend/lib/features/stats/report_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:miaoji_zhang/shared/api/api_client.dart'; import 'package:miaoji_zhang/shared/api/business_api.dart'; @@ -6,6 +8,7 @@ import 'package:miaoji_zhang/shared/services/current_ledger_store.dart'; import 'package:miaoji_zhang/shared/theme/app_theme.dart'; import 'package:miaoji_zhang/shared/widgets/app_icons.dart'; import 'package:miaoji_zhang/shared/widgets/async_error_view.dart'; +import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart'; import 'package:miaoji_zhang/shared/widgets/category_icon.dart'; import 'package:miaoji_zhang/shared/services/shanghai_time.dart'; import 'package:miaoji_zhang/shared/services/session_store.dart'; @@ -25,13 +28,14 @@ class _ReportPageState extends State { String? _error; DateTime _anchor = ShanghaiTime.now; _ReportKind _kind = _ReportKind.monthly; + int _loadRevision = 0; @override void initState() { super.initState(); PublicConfigApi.companionNotifier.addListener(_refreshCompanion); CurrentLedgerStore.instance.addListener(_load); - _load(); + unawaited(_load()); } @override @@ -46,24 +50,33 @@ class _ReportPageState extends State { } Future _load() async { + final revision = ++_loadRevision; setState(() { _loading = true; _error = null; }); try { - final report = switch (_kind) { - _ReportKind.weekly => await ReportApi.weekly(_anchor), - _ReportKind.monthly => await ReportApi.monthlyPeriod( - _anchor.year, - _anchor.month, - ), - _ReportKind.yearly => await ReportApi.yearly(_anchor.year), + final period = switch (_kind) { + _ReportKind.weekly => 'week', + _ReportKind.monthly => 'month', + _ReportKind.yearly => 'year', }; - if (mounted) setState(() => _report = report); + final local = ReportApi.local(period, _anchor); + if (mounted && revision == _loadRevision) { + setState(() => _report = local); + } + final remote = await ReportApi.remote(period, _anchor); + if (mounted && revision == _loadRevision) { + setState(() => _report = remote); + } } catch (error) { - if (mounted) setState(() => _error = apiErrorMessage(error)); + if (mounted && revision == _loadRevision && _report == null) { + setState(() => _error = apiErrorMessage(error)); + } } finally { - if (mounted) setState(() => _loading = false); + if (mounted && revision == _loadRevision) { + setState(() => _loading = false); + } } } @@ -108,6 +121,7 @@ class _ReportPageState extends State { return Scaffold( appBar: AppBar( title: Text(SessionStore.instance.aiEnabled ? 'AI 报告' : '报告'), + actions: [BackendStatusIcon(onRetry: _load)], ), body: Column( children: [ diff --git a/frontend/lib/features/stats/stats_page.dart b/frontend/lib/features/stats/stats_page.dart index 5480af5..e26e2cf 100644 --- a/frontend/lib/features/stats/stats_page.dart +++ b/frontend/lib/features/stats/stats_page.dart @@ -1,4 +1,5 @@ import 'dart:math' as math; +import 'dart:async'; import 'package:miaoji_zhang/shared/services/shanghai_time.dart'; import 'package:flutter/material.dart'; @@ -8,6 +9,7 @@ import 'package:miaoji_zhang/shared/services/current_ledger_store.dart'; import 'package:miaoji_zhang/shared/theme/app_theme.dart'; import 'package:miaoji_zhang/shared/widgets/app_controls.dart'; import 'package:miaoji_zhang/shared/widgets/async_error_view.dart'; +import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart'; import 'package:miaoji_zhang/shared/widgets/category_icon.dart'; class StatsPage extends StatefulWidget { @@ -23,12 +25,13 @@ class _StatsPageState extends State { PeriodStats? _stats; String? _error; bool _loading = true; + int _loadRevision = 0; @override void initState() { super.initState(); CurrentLedgerStore.instance.addListener(_load); - _load(); + unawaited(_load()); } @override @@ -38,6 +41,7 @@ class _StatsPageState extends State { } Future _load() async { + final revision = ++_loadRevision; if (mounted) { setState(() { _loading = true; @@ -45,12 +49,25 @@ class _StatsPageState extends State { }); } try { - final stats = await TxApi.periodStats(_period, _anchor); - if (mounted) setState(() => _stats = stats); + final local = TxApi.periodStatsLocal(_period, _anchor); + if (mounted && revision == _loadRevision) { + setState(() => _stats = local); + } } catch (error) { - if (mounted) setState(() => _error = apiErrorMessage(error)); + if (mounted && revision == _loadRevision) { + setState(() => _error = apiErrorMessage(error)); + } + } + try { + final remote = await TxApi.periodStatsRemote(_period, _anchor); + if (mounted && revision == _loadRevision) { + setState(() => _stats = remote); + } + } catch (_) { } finally { - if (mounted) setState(() => _loading = false); + if (mounted && revision == _loadRevision) { + setState(() => _loading = false); + } } } @@ -93,7 +110,10 @@ class _StatsPageState extends State { Widget build(BuildContext context) { final stats = _stats; return Scaffold( - appBar: AppBar(title: Text('统计')), + appBar: AppBar( + title: Text('统计'), + actions: [BackendStatusIcon(onRetry: _load)], + ), body: Column( children: [ Padding( @@ -105,7 +125,7 @@ class _StatsPageState extends State { JzOption(value: 'month', label: '月'), JzOption(value: 'year', label: '年'), ], - onChanged: _loading ? null : _selectPeriod, + onChanged: _selectPeriod, ), ), Expanded( @@ -171,7 +191,7 @@ class _StatsPageState extends State { children: [ IconButton( tooltip: '上一个周期', - onPressed: _loading ? null : () => _shift(-1), + onPressed: () => _shift(-1), icon: Icon(Icons.chevron_left_rounded), ), SizedBox( @@ -184,9 +204,7 @@ class _StatsPageState extends State { ), IconButton( tooltip: '下一个周期', - onPressed: _loading || _isAfterCurrentPeriod(nextAnchor) - ? null - : () => _shift(1), + onPressed: _isAfterCurrentPeriod(nextAnchor) ? null : () => _shift(1), icon: Icon(Icons.chevron_right_rounded), ), ], diff --git a/frontend/lib/shared/api/api_client.dart b/frontend/lib/shared/api/api_client.dart index e03ed03..d002bcb 100644 --- a/frontend/lib/shared/api/api_client.dart +++ b/frontend/lib/shared/api/api_client.dart @@ -4,6 +4,8 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:miaoji_zhang/shared/api/backend_identity.dart'; import 'package:miaoji_zhang/shared/services/session_store.dart'; +enum BackendAvailability { unknown, online, offline } + class ApiClient { ApiClient._(); static final ApiClient instance = ApiClient._(); @@ -12,6 +14,9 @@ class ApiClient { static const _legacyTokenKey = 'auth_token'; static final _tokenKey = 'auth_token_${BackendIdentity.scope}'; static final sessionExpired = ValueNotifier(0); + static final availability = ValueNotifier( + BackendAvailability.unknown, + ); static bool _handlingUnauthorized = false; static const String baseUrl = BackendIdentity.baseUrl; @@ -31,6 +36,10 @@ class ApiClient { ) ..interceptors.add( InterceptorsWrapper( + onResponse: (response, handler) { + availability.value = BackendAvailability.online; + handler.next(response); + }, onRequest: (options, handler) async { final token = await _storage.read(key: _tokenKey); if (token != null) { @@ -39,6 +48,11 @@ class ApiClient { handler.next(options); }, onError: (error, handler) async { + if (isConnectivityError(error)) { + availability.value = BackendAvailability.offline; + } else if (error.response != null) { + availability.value = BackendAvailability.online; + } final unauthorized = error.response?.statusCode == 401; final data = error.response?.data; final aiDenied = @@ -74,6 +88,18 @@ class ApiClient { } Future readToken() => _storage.read(key: _tokenKey); + + Future probe() async { + try { + await dio.get( + '/api/public/brand', + options: Options(receiveTimeout: const Duration(seconds: 10)), + ); + } catch (_) { + // The interceptor owns the reachability state transition. + } + } + Future clearToken() async { await _storage.delete(key: _tokenKey); await _storage.delete(key: _legacyTokenKey); @@ -91,8 +117,9 @@ String apiErrorMessage(Object e) { if (e is StateError) return e.message; if (e is DioException) { final data = e.response?.data; - if (data is Map && data['message'] != null) + if (data is Map && data['message'] != null) { return data['message'] as String; + } if (e.type == DioExceptionType.connectionTimeout || e.type == DioExceptionType.connectionError || e.type == DioExceptionType.receiveTimeout) { diff --git a/frontend/lib/shared/api/auth_api.dart b/frontend/lib/shared/api/auth_api.dart index f012355..01cf5bc 100644 --- a/frontend/lib/shared/api/auth_api.dart +++ b/frontend/lib/shared/api/auth_api.dart @@ -1,5 +1,9 @@ +import 'dart:convert'; + import 'package:dio/dio.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:miaoji_zhang/shared/api/api_client.dart'; +import 'package:miaoji_zhang/shared/api/backend_identity.dart'; import 'package:miaoji_zhang/shared/services/current_ledger_store.dart'; import 'package:miaoji_zhang/shared/services/local_export_service.dart'; import 'package:miaoji_zhang/shared/services/session_store.dart'; @@ -19,6 +23,15 @@ class AiCompanion { roastLevel = json['roastLevel'] as int, stickerFrequency = json['stickerFrequency'] as int, proactiveLevel = json['proactiveLevel'] as int; + + Map toJson() => { + 'avatarKey': avatarKey, + 'personaKey': personaKey, + 'customName': customName, + 'roastLevel': roastLevel, + 'stickerFrequency': stickerFrequency, + 'proactiveLevel': proactiveLevel, + }; } class UserProfile { @@ -49,8 +62,9 @@ class UserProfile { required this.nickname, required this.appMode, required this.onboardingDone, + this.aiCompanion, this.aiEnabled = false, - }) : aiCompanion = null; + }); } class AuthLoginResult { @@ -120,6 +134,20 @@ class AuthApi { } } + static Future cachedCompanion() async { + final userId = SessionStore.instance.userId; + if (userId == null) return null; + final value = (await SharedPreferences.getInstance()).getString( + _companionCacheKey(userId), + ); + if (value == null) return null; + try { + return AiCompanion.fromJson(jsonDecode(value) as Map); + } catch (_) { + return null; + } + } + static Future completeOnboarding({ required String appMode, required String avatarKey, @@ -272,13 +300,24 @@ class AuthApi { ); } - static Future _cacheProfile(UserProfile profile) => - SessionStore.instance.activateAccount( - userId: profile.userId, - username: profile.username, - nickname: profile.nickname, - appMode: profile.appMode, - onboardingDone: profile.onboardingDone, - aiEnabled: profile.aiEnabled, + static Future _cacheProfile(UserProfile profile) async { + await SessionStore.instance.activateAccount( + userId: profile.userId, + username: profile.username, + nickname: profile.nickname, + appMode: profile.appMode, + onboardingDone: profile.onboardingDone, + aiEnabled: profile.aiEnabled, + ); + final companion = profile.aiCompanion; + if (companion != null) { + await (await SharedPreferences.getInstance()).setString( + _companionCacheKey(profile.userId), + jsonEncode(companion.toJson()), ); + } + } + + static String _companionCacheKey(int userId) => + 'ai_companion_${BackendIdentity.scope}_$userId'; } diff --git a/frontend/lib/shared/api/business_api.dart b/frontend/lib/shared/api/business_api.dart index 6709595..b25355f 100644 --- a/frontend/lib/shared/api/business_api.dart +++ b/frontend/lib/shared/api/business_api.dart @@ -244,6 +244,20 @@ class TxApi { static bool get _queueOfflineChanges => SessionStore.instance.isAccount && SessionStore.instance.cloudSyncEnabled; + static MonthSummary monthLocal(int year, int month) => MonthSummary.fromJson( + LocalDatabase.instance.monthSummary(year, month, _ledgerId), + ); + + static Future monthRemote(int year, int month) async { + final response = await _dio.get( + '/api/transactions/month', + queryParameters: {'year': year, 'month': month, 'ledgerId': _ledgerId}, + ); + final json = response.data as Map; + LocalDatabase.instance.cacheMonthSummary(json); + return MonthSummary.fromJson(json); + } + static Future month(int year, int month) async { final session = SessionStore.instance; if (session.shouldUseLocalOnly) { @@ -330,6 +344,29 @@ class TxApi { } } + static PeriodStats periodStatsLocal(String period, DateTime anchor) => + PeriodStats.fromJson( + LocalDatabase.instance.periodStats(period, anchor, _ledgerId), + ); + + static Future periodStatsRemote( + String period, + DateTime anchor, + ) async { + final response = await _dio.get( + '/api/transactions/stats/period', + queryParameters: { + 'period': period, + 'anchor': + '${anchor.year.toString().padLeft(4, '0')}-' + '${anchor.month.toString().padLeft(2, '0')}-' + '${anchor.day.toString().padLeft(2, '0')}', + 'ledgerId': _ledgerId, + }, + ); + return PeriodStats.fromJson(response.data as Map); + } + static Future> categories(String type) async { final session = SessionStore.instance; if (session.shouldUseLocalOnly) { @@ -357,6 +394,24 @@ class TxApi { } } + static List categoriesLocal(String type) => LocalDatabase + .instance + .categories(type) + .map(CategoryItem.fromJson) + .toList(); + + static Future> categoriesRemote(String type) async { + final response = await _dio.get( + '/api/categories', + queryParameters: {'type': type}, + ); + final values = response.data as List; + LocalDatabase.instance.cacheCategories(values, replaceType: type); + return values + .map((item) => CategoryItem.fromJson(item as Map)) + .toList(); + } + static Future create({ required int categoryId, required String type, @@ -532,7 +587,9 @@ class TxApi { includeDeleted: true, )?['updatedAt']; final session = SessionStore.instance; - if (session.shouldUseLocalOnly || id < 0) { + if (session.shouldUseLocalOnly || + ApiClient.availability.value == BackendAvailability.offline || + id < 0) { LocalDatabase.instance.softDeleteTransaction(id); if (_queueOfflineChanges) { LocalDatabase.instance.enqueueSync('transaction', id, 'delete', { @@ -613,7 +670,8 @@ class TxApi { static Future> recycleBin() async { final session = SessionStore.instance; - if (session.shouldUseLocalOnly) { + if (session.shouldUseLocalOnly || + ApiClient.availability.value == BackendAvailability.offline) { return LocalDatabase.instance .recycleBin(_ledgerId) .map(TxItem.fromJson) @@ -638,13 +696,32 @@ class TxApi { } } + static List recycleBinLocal() => LocalDatabase.instance + .recycleBin(_ledgerId) + .map(TxItem.fromJson) + .toList(); + + static Future> recycleBinRemote() async { + final response = await _dio.get( + '/api/transactions/recycle-bin', + queryParameters: {'ledgerId': _ledgerId}, + ); + final values = response.data as List; + LocalDatabase.instance.cacheTransactions(values); + return values + .map((item) => TxItem.fromJson(item as Map)) + .toList(); + } + static Future restore(int id) async { final baseUpdatedAt = LocalDatabase.instance.transaction( id, includeDeleted: true, )?['updatedAt']; final session = SessionStore.instance; - if (session.shouldUseLocalOnly || id < 0) { + if (session.shouldUseLocalOnly || + ApiClient.availability.value == BackendAvailability.offline || + id < 0) { final local = LocalDatabase.instance.restoreTransaction(id); if (_queueOfflineChanges) { LocalDatabase.instance.enqueueSync('transaction', id, 'restore', { @@ -984,6 +1061,18 @@ class BudgetApi { static final _dio = ApiClient.instance.dio; static int get _ledgerId => CurrentLedgerStore.instance.currentId ?? 1; + static BudgetsData getLocal(int year, int month) => BudgetsData.fromJson( + LocalDatabase.instance.budgets(year, month, _ledgerId), + ); + + static Future getRemote(int year, int month) async { + final response = await _dio.get( + '/api/budgets', + queryParameters: {'year': year, 'month': month, 'ledgerId': _ledgerId}, + ); + return BudgetsData.fromJson(response.data as Map); + } + static Future get(int year, int month) async { final session = SessionStore.instance; if (session.shouldUseLocalOnly) { @@ -1294,6 +1383,29 @@ class ReportApi { static final _dio = ApiClient.instance.dio; static int get _ledgerId => CurrentLedgerStore.instance.currentId ?? 1; + static PeriodReport local(String period, DateTime anchor) => + _localPeriod(period, anchor); + + static Future remote(String period, DateTime anchor) async { + if (period == 'month') { + return PeriodReport.fromMonthly(await monthly(anchor.year, anchor.month)); + } + final path = period == 'week' + ? '/api/reports/weekly' + : '/api/reports/yearly'; + final query = period == 'week' + ? { + 'date': + '${anchor.year.toString().padLeft(4, '0')}-' + '${anchor.month.toString().padLeft(2, '0')}-' + '${anchor.day.toString().padLeft(2, '0')}', + 'ledgerId': _ledgerId, + } + : {'year': anchor.year, 'ledgerId': _ledgerId}; + final response = await _dio.get(path, queryParameters: query); + return PeriodReport.fromJson(response.data as Map); + } + static Future weekly(DateTime date) async { if (SessionStore.instance.shouldUseLocalOnly) { return _localPeriod('week', date); @@ -1595,7 +1707,8 @@ class CategoryApi { 'type': type, }; final session = SessionStore.instance; - if (session.shouldUseLocalOnly) { + if (session.shouldUseLocalOnly || + ApiClient.availability.value == BackendAvailability.offline) { final local = LocalDatabase.instance.createCategory( name, iconKey, @@ -1649,7 +1762,9 @@ class CategoryApi { 'sortOrder': sortOrder, }; final session = SessionStore.instance; - if (session.shouldUseLocalOnly || id < 0) { + if (session.shouldUseLocalOnly || + ApiClient.availability.value == BackendAvailability.offline || + id < 0) { final local = LocalDatabase.instance.updateCategory( id, name, @@ -1662,16 +1777,31 @@ class CategoryApi { } return CategoryItem.fromJson(local); } - final response = await _dio.put('/api/categories/$id', data: payload); - final json = response.data as Map; - LocalDatabase.instance.cacheCategories([json]); - return CategoryItem.fromJson(json); + try { + final response = await _dio.put('/api/categories/$id', data: payload); + final json = response.data as Map; + LocalDatabase.instance.cacheCategories([json]); + return CategoryItem.fromJson(json); + } catch (error) { + if (!isConnectivityError(error) || !session.isAccount) rethrow; + final local = LocalDatabase.instance.updateCategory( + id, + name, + iconKey, + colorKey, + sortOrder, + ); + LocalDatabase.instance.enqueueSync('category', id, 'update', payload); + return CategoryItem.fromJson(local); + } } static Future reorder(String type, List categoryIds) async { LocalDatabase.instance.reorderCategories(type, categoryIds); final session = SessionStore.instance; - if (session.shouldUseLocalOnly || categoryIds.any((id) => id < 0)) { + if (session.shouldUseLocalOnly || + ApiClient.availability.value == BackendAvailability.offline || + categoryIds.any((id) => id < 0)) { if (session.isAccount && session.cloudSyncEnabled) { LocalDatabase.instance.enqueueSync('category', 0, 'reorder', { 'type': type, @@ -1680,15 +1810,25 @@ class CategoryApi { } return; } - await _dio.put( - '/api/categories/reorder', - data: {'type': type, 'categoryIds': categoryIds}, - ); + try { + await _dio.put( + '/api/categories/reorder', + data: {'type': type, 'categoryIds': categoryIds}, + ); + } catch (error) { + if (!isConnectivityError(error) || !session.isAccount) rethrow; + LocalDatabase.instance.enqueueSync('category', 0, 'reorder', { + 'type': type, + 'categoryIds': categoryIds, + }); + } } static Future delete(int id) async { final session = SessionStore.instance; - if (session.shouldUseLocalOnly || id < 0) { + if (session.shouldUseLocalOnly || + ApiClient.availability.value == BackendAvailability.offline || + id < 0) { LocalDatabase.instance.deleteCategory(id); if (session.isAccount && session.cloudSyncEnabled) { LocalDatabase.instance.enqueueSync('category', id, 'delete', { @@ -1697,7 +1837,13 @@ class CategoryApi { } return; } - await _dio.delete('/api/categories/$id'); - LocalDatabase.instance.deleteCategory(id); + try { + await _dio.delete('/api/categories/$id'); + LocalDatabase.instance.deleteCategory(id); + } catch (error) { + if (!isConnectivityError(error) || !session.isAccount) rethrow; + LocalDatabase.instance.deleteCategory(id); + LocalDatabase.instance.enqueueSync('category', id, 'delete', {'id': id}); + } } } diff --git a/frontend/lib/shared/api/config_api.dart b/frontend/lib/shared/api/config_api.dart index 2a98446..57596d6 100644 --- a/frontend/lib/shared/api/config_api.dart +++ b/frontend/lib/shared/api/config_api.dart @@ -1,5 +1,9 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:miaoji_zhang/shared/api/api_client.dart'; +import 'package:miaoji_zhang/shared/api/backend_identity.dart'; import 'package:miaoji_zhang/shared/services/session_store.dart'; class BrandConfig { @@ -41,6 +45,13 @@ class AvatarItem { defaultName = j['defaultName'] as String, speechTic = j['speechTic'] as String? ?? '', imageUrl = j['imageUrl'] as String?; + + Map toJson() => { + 'key': key, + 'defaultName': defaultName, + 'speechTic': speechTic, + 'imageUrl': imageUrl, + }; } class PersonaItem { @@ -50,6 +61,13 @@ class PersonaItem { name = j['name'] as String, description = j['description'] as String? ?? '', sampleLine = j['sampleLine'] as String? ?? ''; + + Map toJson() => { + 'key': key, + 'name': name, + 'description': description, + 'sampleLine': sampleLine, + }; } class CompanionDisplay { @@ -145,16 +163,59 @@ class PublicConfigApi { static Future> avatars() async { final res = await _dio.get('/api/public/avatars'); - return (res.data as List) + final values = (res.data as List) .map((e) => AvatarItem.fromJson(e as Map)) .toList(); + await _cacheCatalog( + _avatarCacheKey, + values.map((e) => e.toJson()).toList(), + ); + return values; } static Future> personas() async { final res = await _dio.get('/api/public/personas'); - return (res.data as List) + final values = (res.data as List) .map((e) => PersonaItem.fromJson(e as Map)) .toList(); + await _cacheCatalog( + _personaCacheKey, + values.map((e) => e.toJson()).toList(), + ); + return values; + } + + static Future> cachedAvatars() async => + (await _cachedCatalog(_avatarCacheKey)).map(AvatarItem.fromJson).toList(); + + static Future> cachedPersonas() async => + (await _cachedCatalog( + _personaCacheKey, + )).map(PersonaItem.fromJson).toList(); + + static String get _avatarCacheKey => + 'public_avatars_${BackendIdentity.scope}'; + static String get _personaCacheKey => + 'public_personas_${BackendIdentity.scope}'; + + static Future _cacheCatalog( + String key, + List> values, + ) async { + await (await SharedPreferences.getInstance()).setString( + key, + jsonEncode(values), + ); + } + + static Future>> _cachedCatalog(String key) async { + final value = (await SharedPreferences.getInstance()).getString(key); + if (value == null) return const []; + try { + return (jsonDecode(value) as List).cast>().toList(); + } catch (_) { + return const []; + } } static String? _normalizeName(String? value) { diff --git a/frontend/lib/shared/services/current_ledger_store.dart b/frontend/lib/shared/services/current_ledger_store.dart index 73465e0..5404ed8 100644 --- a/frontend/lib/shared/services/current_ledger_store.dart +++ b/frontend/lib/shared/services/current_ledger_store.dart @@ -52,6 +52,17 @@ class CurrentLedgerStore extends ChangeNotifier { return _loading ??= _loadCached().whenComplete(() => _loading = null); } + Future refreshRemote() async { + if (!SessionStore.instance.isAccount || + SessionStore.instance.shouldUseLocalOnly) { + return; + } + final response = await _dio.get('/api/ledgers'); + final values = response.data as List; + LocalDatabase.instance.cacheLedgers(values); + _apply(values); + } + Future _loadCached() async { _apply(LocalDatabase.instance.ledgers()); } @@ -78,11 +89,24 @@ class CurrentLedgerStore extends ChangeNotifier { final items = values .map((item) => LedgerInfo.fromJson(item as Map)) .toList(); - _ledgers = items; - _current = + final current = items.where((item) => item.isDefault).firstOrNull ?? (items.isEmpty ? null : items.first); - notifyListeners(); + final changed = + _current?.id != current?.id || + _ledgers.length != items.length || + Iterable.generate(items.length).any((index) { + final before = _ledgers[index]; + final after = items[index]; + return before.id != after.id || + before.name != after.name || + before.iconKey != after.iconKey || + before.isDefault != after.isDefault || + before.transactionCount != after.transactionCount; + }); + _ledgers = items; + _current = current; + if (changed) notifyListeners(); } Future select(int id) async { diff --git a/frontend/lib/shared/widgets/backend_status_icon.dart b/frontend/lib/shared/widgets/backend_status_icon.dart new file mode 100644 index 0000000..8829c10 --- /dev/null +++ b/frontend/lib/shared/widgets/backend_status_icon.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:miaoji_zhang/shared/api/api_client.dart'; +import 'package:miaoji_zhang/shared/theme/app_theme.dart'; +import 'package:miaoji_zhang/shared/widgets/app_icons.dart'; + +class BackendStatusIcon extends StatelessWidget { + final Future Function() onRetry; + + const BackendStatusIcon({super.key, required this.onRetry}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: ApiClient.availability, + builder: (context, availability, _) { + if (availability != BackendAvailability.offline) { + return const SizedBox.shrink(); + } + return IconButton( + tooltip: '当前显示本地数据,点击重试', + onPressed: onRetry, + icon: AppIcons.icon( + AppIcons.offline, + size: 18, + color: AppTheme.orange, + ), + ); + }, + ); + } +} diff --git a/frontend/test/offline_local_first_test.dart b/frontend/test/offline_local_first_test.dart new file mode 100644 index 0000000..fb1485b --- /dev/null +++ b/frontend/test/offline_local_first_test.dart @@ -0,0 +1,149 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:miaoji_zhang/shared/api/api_client.dart'; +import 'package:miaoji_zhang/shared/api/auth_api.dart'; +import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart'; + +void main() { + tearDown(() { + ApiClient.availability.value = BackendAvailability.unknown; + }); + + testWidgets('离线状态只显示可点击且可访问的云朵重试图标', (tester) async { + var retries = 0; + ApiClient.availability.value = BackendAvailability.offline; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar( + actions: [ + BackendStatusIcon( + onRetry: () async { + retries++; + }, + ), + ], + ), + ), + ), + ); + + final retry = find.byTooltip('当前显示本地数据,点击重试'); + expect(retry, findsOneWidget); + await tester.tap(retry); + await tester.pump(); + expect(retries, 1); + + ApiClient.availability.value = BackendAvailability.online; + await tester.pump(); + expect(retry, findsNothing); + }); + + test('连接、发送和接收失败都会被识别为后端不可达', () { + DioException failure(DioExceptionType type) => DioException( + requestOptions: RequestOptions(path: '/test'), + type: type, + ); + + expect( + isConnectivityError(failure(DioExceptionType.connectionError)), + isTrue, + ); + expect( + isConnectivityError(failure(DioExceptionType.connectionTimeout)), + isTrue, + ); + expect(isConnectivityError(failure(DioExceptionType.sendTimeout)), isTrue); + expect( + isConnectivityError(failure(DioExceptionType.receiveTimeout)), + isTrue, + ); + expect(isConnectivityError(failure(DioExceptionType.badResponse)), isFalse); + }); + + test('AI 伙伴缓存 DTO 可无损往返', () { + final source = { + 'avatarKey': 'cat', + 'personaKey': 'gentle', + 'customName': '小记', + 'roastLevel': 20, + 'stickerFrequency': 40, + 'proactiveLevel': 60, + }; + final companion = AiCompanion.fromJson(source); + + expect(companion.toJson(), source); + }); + + test('七个页面保持本地首屏、后台刷新、竞态保护和统一离线入口', () { + final contracts = >{ + 'lib/features/home/pages/home_page.dart': [ + 'monthLocal', + 'monthRemote', + 'BudgetApi.getLocal', + 'BudgetApi.getRemote', + 'refreshRemote', + ], + 'lib/features/add/add_page.dart': [ + "categoriesLocal('expense')", + "categoriesRemote('expense')", + "categoriesRemote('income')", + ], + 'lib/features/stats/stats_page.dart': [ + 'periodStatsLocal', + 'periodStatsRemote', + ], + 'lib/features/stats/report_page.dart': [ + 'ReportApi.local', + 'ReportApi.remote', + ], + 'lib/features/settings/category_manage_page.dart': [ + 'categoriesLocal', + 'categoriesRemote', + ], + 'lib/features/settings/recycle_bin_page.dart': [ + 'recycleBinLocal', + 'recycleBinRemote', + ], + 'lib/features/settings/companion_page.dart': [ + 'cachedCompanion', + 'cachedAvatars', + 'cachedPersonas', + 'forceRemote: true', + ], + }; + + for (final entry in contracts.entries) { + final source = File(entry.key).readAsStringSync(); + expect(source, contains('_loadRevision'), reason: entry.key); + expect(source, contains('BackendStatusIcon'), reason: entry.key); + for (final token in entry.value) { + expect(source, contains(token), reason: '${entry.key}: $token'); + } + } + + final companion = File( + 'lib/features/settings/companion_page.dart', + ).readAsStringSync(); + expect(companion, isNot(contains('if (!_loaded)'))); + expect(companion, contains('BackendAvailability.online')); + }); + + test('AI 缓存按后端和账号隔离,回收站破坏性操作只允许在线执行', () { + final auth = File('lib/shared/api/auth_api.dart').readAsStringSync(); + final config = File('lib/shared/api/config_api.dart').readAsStringSync(); + final recycle = File( + 'lib/features/settings/recycle_bin_page.dart', + ).readAsStringSync(); + + expect(auth, contains("'ai_companion_\${BackendIdentity.scope}_\$userId'")); + expect(config, contains("'public_avatars_\${BackendIdentity.scope}'")); + expect(config, contains("'public_personas_\${BackendIdentity.scope}'")); + expect(recycle, contains('availability != BackendAvailability.online')); + expect(recycle, contains('if (online)')); + }); +}