diff --git a/.codebuddy/memory/2026-09-22.md b/.codebuddy/memory/2026-09-22.md
new file mode 100644
index 0000000..d1357c6
--- /dev/null
+++ b/.codebuddy/memory/2026-09-22.md
@@ -0,0 +1,63 @@
+# 2026-09-22
+
+## 排查:API 环境配置(`lib/core/network/api_env.dart`)
+用户提问:`kProdBaseUrl` / `kLocalBaseUrl` 如何工作、如何保证正式包连真实后端。未改动代码,仅分析并给出改进建议。
+
+### 机制(已确认)
+- `baseUrl` getter 三级优先级:`--dart-define=API_BASE_URL`(`String.fromEnvironment`,编译期)> `!kDebugMode` 时 `kProdBaseUrl` > 默认 `kLocalBaseUrl`。
+- 消费方:`dio_client.dart` 的 `dioProvider` 把 `baseUrl` 塞进 `BaseOptions.baseUrl`;API 层(如 `auth_api.dart`)只用相对路径 `/auth/login`,由 dio 拼接。
+- 正式包的保证来自**编译期常量折叠**:release 下 `kDebugMode == false`,`return kLocalBaseUrl` 成为死代码被 tree-shake,本地地址字符串不会进入产物。
+
+### 发现的 3 个风险点(重要,待处理)
+1. **`--dart-define` 可击穿保证**:其优先级最高且 release 同样生效。`docs/Flutter打包与真机安装指南.md` 第 203 行就给出了 `flutter build apk --release --dart-define=API_BASE_URL=http://192.168.50.182:12345/api`,一旦复制粘贴或 CI 残留,会产出指向局域网的"正式包"。
+2. **profile 模式会连生产**:`kDebugMode` 在 profile 下为 false → 走 `kProdBaseUrl`。`flutter run --profile`(做性能验证时常用)直接打生产库。建议改用 `kReleaseMode` 显式判断。
+3. **配置与文档/注释不一致,且 iOS 会直接失败**:
+ - 代码 `kProdBaseUrl = 'http://103.36.220.231:12345/api'`(**明文 HTTP + 裸 IP + 非标端口**),但同文件注释写"生产环境 HTTPS",`docs/Flutter打包与真机安装指南.md` 第 178 行写的是 `https://accounting.aqroid.cn/api`。
+ - Android 端能跑,是因为 `android/app/src/main/AndroidManifest.xml` 开了 `android:usesCleartextTraffic="true"`(而打包指南第 350 行却说"生产是 HTTPS,其实可以不开")。
+ - **iOS 端 `ios/Runner/Info.plist` 中完全没有 `NSAppTransportSecurity` 配置**,请求该 http 地址会被 ATS 拦截(URLError -1022),iOS 正式包无法联网。
+ - Web 端若部署在 HTTPS 页面下,浏览器会以 mixed content 拦截 http 请求(另有 CORS 问题)。
+ - 硬编码裸 IP:换服务器/加域名/上 HTTPS 都必须重新打包发版。
+
+### 给用户的改进方向(尚未实施)
+- `baseUrl` 判断改为显式 `kReleaseMode`,并处理 profile(如新增 `kProfileBaseUrl` 或按需回退)。
+- 生产改用域名 + HTTPS,同步更新 Android 明文流量开关与 iOS ATS 例外。
+- 多环境改用 `--dart-define-from-file=config/prod.json`,避免长命令行被误复制。
+- 增加运行时自检:release 下若 `API_BASE_URL` 非空则报错/显著日志;启动时打印当前 baseUrl。
+
+---
+
+## 实施:App 名称与图标(已完成)
+
+用户提供品牌原图位置 `D:\nzy\workspace_nzy\AI-accounting-soft-uniApp\static\images\logo.png`(与项目内 `assets/images/logo.png` 同一张),要求改名 + 换图标。
+
+### 关键结论:原 logo 不能作图标
+该图为 1120×928 贴纸海报(熊头 + 3 只狗 + "强宝爱记账"5 个大字),带虚化底纹。实测用洪水填充/PIL 去背景**失败**(底纹色差超出阈值,水印残留),且缩小到 48px 后文字糊成一团。已明确告知用户并改为同画风专用素材。
+
+### 已完成改动
+| 内容 | 文件 | 值 |
+|---|---|---|
+| Android 桌面名 | `android/app/src/main/AndroidManifest.xml` | `android:label="强宝爱记账"` |
+| iOS 桌面名 | `ios/Runner/Info.plist` | `CFBundleDisplayName` = `强宝爱记账` |
+| Web 标签页/主屏名/描述 | `web/index.html` | `
`、`apple-mobile-web-app-title` 均为 `强宝爱记账` |
+| Web PWA | `web/manifest.json` | name=`强宝爱记账`、short_name=`强宝记账`、background_color=`#FFF6EC`、theme_color=`#C8956E` |
+| 图标依赖 | `pubspec.yaml` | 新增 dev 依赖 `flutter_launcher_icons: ^0.14.0`(实际解析到 0.14.4) |
+| 图标配置 | `pubspec.yaml` 末尾 | 新增 `flutter_launcher_icons` 段(image_path + adaptive 双层 + remove_alpha_ios + web) |
+| 文档 | `docs/Flutter打包与真机安装指南.md` 7.3 节 | 重写为实际链路 |
+
+### 新增的文件与链路
+- `tools/make_app_icon.py`:从素材生成图标。功能 = 去右下角 AI 水印(用上方一行像素向下延展填充,适配平滑渐变底)→ 按四角平均色做背景掩码抠出主体 → 居中缩放。产出 `assets/icon/app_icon.png`(RGB 无 alpha)与 `assets/icon/app_icon_fg.png`(RGBA,主体占 60%,供自适应前景层)。支持 `--preview`(输出构图预览)、`--check`(核对色彩模式)、`--src`(换素材)。
+- `assets/icon/_source/raw_bear.png`:最终采用素材(AI 生成的 kawaii 棕熊头,奶油底 #FFF6EC)。
+- `assets/icon/_source/raw_bear_dog.png`:备选素材(熊 + 金毛),待用户选择。
+- 注意:`assets/icon/` 未在 pubspec 声明为 assets,不会打进包。
+
+### 生成与校验结果
+- `flutter pub get` 成功(走 pub.flutter-io.cn 镜像);`dart run flutter_launcher_icons` 成功。
+- Android:5 套 mipmap + `mipmap-anydpi-v26/ic_launcher.xml` + 新建 `res/values/colors.xml` + `drawable-*/ic_launcher_foreground.png`。
+- iOS:`AppIcon.appiconset` 全套 22 张,1024 图确认为 **RGB 无 alpha**(合规)。
+- Web:`web/icons/*` + `web/favicon.png`。
+- `flutter analyze` → No issues found。
+
+### 待用户确认
+- 是否改用备选素材 `raw_bear_dog.png`(熊 + 金毛,更贴近原 logo 的"熊+狗"组合),换法:`python tools/make_app_icon.py --src assets/icon/_source/raw_bear_dog.png && dart run flutter_launcher_icons`。
+- 需重新构建并**卸载后重装**才能看到新名称/图标(启动器有缓存)。
+
diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..f7e8bbf
Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..aaa3545
Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..161f90a
Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..a22cc73
Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..7a54886
Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..c79c58a
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..68fc055
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #FFF6EC
+
\ No newline at end of file
diff --git a/assets/icon/_source/raw_bear.png b/assets/icon/_source/raw_bear.png
new file mode 100644
index 0000000..a6d2f13
Binary files /dev/null and b/assets/icon/_source/raw_bear.png differ
diff --git a/assets/icon/_source/raw_bear_dog.png b/assets/icon/_source/raw_bear_dog.png
new file mode 100644
index 0000000..6b9c6d9
Binary files /dev/null and b/assets/icon/_source/raw_bear_dog.png differ
diff --git a/assets/icon/app_icon.png b/assets/icon/app_icon.png
new file mode 100644
index 0000000..ce20b9c
Binary files /dev/null and b/assets/icon/app_icon.png differ
diff --git a/assets/icon/app_icon_fg.png b/assets/icon/app_icon_fg.png
new file mode 100644
index 0000000..cb005bb
Binary files /dev/null and b/assets/icon/app_icon_fg.png differ
diff --git a/assets/images/logo.png b/assets/images/logo.png
index 3ab05a1..54462d1 100644
Binary files a/assets/images/logo.png and b/assets/images/logo.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png
new file mode 100644
index 0000000..511e8d1
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png
new file mode 100644
index 0000000..aeb5723
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png
new file mode 100644
index 0000000..0bda030
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png
new file mode 100644
index 0000000..d592a8f
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png
new file mode 100644
index 0000000..2f3e6e0
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png
new file mode 100644
index 0000000..122d8ba
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ
diff --git a/lib/api/note_api.dart b/lib/api/note_api.dart
new file mode 100644
index 0000000..d2ef701
--- /dev/null
+++ b/lib/api/note_api.dart
@@ -0,0 +1,171 @@
+import 'package:dio/dio.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../core/network/api_exception.dart';
+import '../core/network/dio_client.dart';
+import '../models/note.dart';
+
+final noteApiProvider = Provider((ref) => NoteApi(ref.watch(dioProvider)));
+
+/// 笔记接口
+class NoteApi {
+ final Dio _dio;
+
+ NoteApi(this._dio);
+
+ /// 分页列表(不含正文)
+ ///
+ /// [scope] all / uncategorized / folder;[folderId] 仅在 scope=folder 时有意义,
+ /// 后端会连同该目录的所有子目录一起查。
+ Future list({
+ int page = 1,
+ int size = 20,
+ String scope = 'all',
+ int? folderId,
+ String sort = 'updated',
+ String? keyword,
+ String? tag,
+ }) async {
+ try {
+ final res = await _dio.get('/notes', queryParameters: {
+ 'page': page,
+ 'size': size,
+ 'scope': scope,
+ 'sort': sort,
+ 'folderId': ?folderId,
+ if (keyword != null && keyword.isNotEmpty) 'keyword': keyword,
+ if (tag != null && tag.isNotEmpty) 'tag': tag,
+ });
+ final data = res.data;
+ if (data is Map) return NotePage.fromJson(data);
+ return NotePage.empty;
+ } on DioException catch (e) {
+ final err = e.error;
+ throw err is ApiException ? err : ApiException(-1, '笔记列表加载失败');
+ }
+ }
+
+ /// 目录树
+ Future folderTree() async {
+ try {
+ final res = await _dio.get('/notes/folders');
+ final data = res.data;
+ if (data is Map) return FolderTree.fromJson(data);
+ return FolderTree.empty;
+ } on DioException catch (e) {
+ final err = e.error;
+ throw err is ApiException ? err : ApiException(-1, '目录加载失败');
+ }
+ }
+
+ /// 把笔记移动到某个目录,[folderId] 为 null 表示移到未分类
+ Future assignFolder(int noteId, int? folderId) async {
+ try {
+ await _dio.put('/notes/$noteId/folder', data: {'folderId': folderId});
+ } on DioException catch (e) {
+ final err = e.error;
+ throw err is ApiException ? err : ApiException(-1, '移动失败');
+ }
+ }
+
+ /// 详情(含正文)
+ Future detail(int id) async {
+ try {
+ final res = await _dio.get('/notes/$id');
+ return NoteDetail.fromJson(_asMap(res.data));
+ } on DioException catch (e) {
+ final err = e.error;
+ throw err is ApiException ? err : ApiException(-1, '笔记加载失败');
+ }
+ }
+
+ /// 新建
+ Future create({
+ required String title,
+ required String content,
+ required String tags,
+ }) async {
+ try {
+ final res = await _dio.post('/notes', data: {
+ 'title': title,
+ 'content': content,
+ 'tags': tags,
+ 'isPinned': 0,
+ });
+ return NoteDetail.fromJson(_asMap(res.data));
+ } on DioException catch (e) {
+ final err = e.error;
+ throw err is ApiException ? err : ApiException(-1, '笔记保存失败');
+ }
+ }
+
+ /// 更新
+ ///
+ /// 后端是部分更新语义:字段传 null 表示不改动,
+ /// 所以这里只塞需要改的字段,不必回传全部。
+ Future update(
+ int id, {
+ String? title,
+ String? content,
+ String? tags,
+ bool? pinned,
+ }) async {
+ try {
+ final res = await _dio.put('/notes/$id', data: {
+ // `?value` 是 Dart 的空感知元素:值为 null 时整个键值对不写入 map,
+ // 正好对应后端"字段为 null 表示不改动"的部分更新语义
+ 'title': ?title,
+ 'content': ?content,
+ 'tags': ?tags,
+ if (pinned != null) 'isPinned': pinned ? 1 : 0,
+ });
+ return NoteDetail.fromJson(_asMap(res.data));
+ } on DioException catch (e) {
+ final err = e.error;
+ throw err is ApiException ? err : ApiException(-1, '笔记保存失败');
+ }
+ }
+
+ /// 删除(后端是逻辑删除)
+ Future delete(int id) async {
+ try {
+ await _dio.delete('/notes/$id');
+ } on DioException catch (e) {
+ final err = e.error;
+ throw err is ApiException ? err : ApiException(-1, '笔记删除失败');
+ }
+ }
+
+ /// 标签列表,按使用次数排序
+ Future> tags() async {
+ try {
+ final res = await _dio.get('/notes/tags');
+ return asStringListFrom(res.data);
+ } on DioException catch (e) {
+ final err = e.error;
+ throw err is ApiException ? err : ApiException(-1, '标签加载失败');
+ }
+ }
+
+ Map _asMap(Object? data) {
+ if (data is Map) return data;
+ if (data is Map && data['data'] is Map) {
+ return Map.from(data['data'] as Map);
+ }
+ return const {};
+ }
+}
+
+/// 标签接口返回的是裸数组,单独处理一下
+List asStringListFrom(Object? data) {
+ if (data is List) {
+ return data.map((e) => e?.toString() ?? '').where((e) => e.isNotEmpty).toList();
+ }
+ if (data is Map && data['data'] is List) {
+ return (data['data'] as List)
+ .map((e) => e?.toString() ?? '')
+ .where((e) => e.isNotEmpty)
+ .toList();
+ }
+ return const [];
+}
diff --git a/lib/core/network/api_env.dart b/lib/core/network/api_env.dart
index d6e1e05..021f2b4 100644
--- a/lib/core/network/api_env.dart
+++ b/lib/core/network/api_env.dart
@@ -29,3 +29,21 @@ String get baseUrl {
if (!kDebugMode) return kProdBaseUrl;
return kLocalBaseUrl;
}
+
+/// 静态资源根地址(去掉 `/api` 后缀)
+///
+/// 笔记附件由后端以 `/uploads/2026/09/22/xxx.png` 这种相对路径返回,
+/// 客户端要拼上 host 才能加载。
+String get assetBaseUrl {
+ const suffix = '/api';
+ return baseUrl.endsWith(suffix)
+ ? baseUrl.substring(0, baseUrl.length - suffix.length)
+ : baseUrl;
+}
+
+/// 把后端返回的资源地址补成绝对地址(已经是 http(s) 的原样返回)
+String resolveAssetUrl(String path) {
+ if (path.isEmpty) return path;
+ if (path.startsWith('http://') || path.startsWith('https://')) return path;
+ return path.startsWith('/') ? '$assetBaseUrl$path' : '$assetBaseUrl/$path';
+}
diff --git a/lib/models/json_util.dart b/lib/models/json_util.dart
index 5a0bf1e..dedd714 100644
--- a/lib/models/json_util.dart
+++ b/lib/models/json_util.dart
@@ -11,6 +11,18 @@ int asInt(Object? value, {int fallback = 0}) {
return fallback;
}
+/// 取可空整数:null / 无法解析都返回 null
+///
+/// 用于 folderId 这类"没有值"本身有业务含义的字段
+/// (null 表示未分类,不能退化成 0)
+int? asNullableInt(Object? value) {
+ if (value == null) return null;
+ if (value is int) return value;
+ if (value is num) return value.toInt();
+ if (value is String) return int.tryParse(value);
+ return null;
+}
+
double asDouble(Object? value, {double fallback = 0}) {
if (value is double) return value;
if (value is num) return value.toDouble();
@@ -34,3 +46,12 @@ List