feat: 应用内通知系统 + 结构化手术史/用药等相关改动

- 新增用户通知 outbox 流水线(EfUserNotificationPipeline)与后台投递 worker
- 通知中心页面及前端通知服务接入
- 健康指标异常、用药/运动提醒等事件统一产出站内通知
- 健康档案结构化手术史、用药提醒扫描、医生/用户端点等配套调整
- AppDbContext 注册通知相关实体
This commit is contained in:
MingNian
2026-06-21 21:06:29 +08:00
parent b57d0d16f4
commit 13714d9ed8
34 changed files with 1541 additions and 200 deletions

View File

@@ -3,8 +3,21 @@ import 'dart:io';
import 'package:dio/dio.dart';
import 'local_database.dart';
/// API 基础地址
const String baseUrl = 'http://localhost:5000'; // adb reverse 或同WiFi直连时改为PC的IP
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
const String baseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://localhost:5000',
);
class ApiException implements Exception {
final int? code;
final String message;
const ApiException(this.message, {this.code});
@override
String toString() => message;
}
/// Dio HTTP 客户端封装——带 token 注入、401 自动刷新
class ApiClient {
@@ -40,22 +53,22 @@ class ApiClient {
/// 带 token 的 GET 请求
Future<Response> get(String path, {Map<String, dynamic>? queryParameters}) async {
return _dio.get(path, queryParameters: queryParameters);
return _request(_dio.get(path, queryParameters: queryParameters));
}
/// 带 token 的 POST 请求
Future<Response> post(String path, {dynamic data}) async {
return _dio.post(path, data: data);
return _request(_dio.post(path, data: data));
}
/// 带 token 的 PUT 请求
Future<Response> put(String path, {dynamic data}) async {
return _dio.put(path, data: data);
return _request(_dio.put(path, data: data));
}
/// 带 token 的 DELETE 请求
Future<Response> delete(String path) async {
return _dio.delete(path);
return _request(_dio.delete(path));
}
/// 上传文件multipart返回文件 URL
@@ -63,7 +76,7 @@ class ApiClient {
final formData = FormData.fromMap({
fieldName: await MultipartFile.fromFile(file.path, filename: file.path.split('/').last),
});
final res = await _dio.post(path, data: formData);
final res = await _request(_dio.post(path, data: formData));
final data = res.data;
if (data is Map) {
final topUrl = data['url']?.toString();
@@ -82,6 +95,46 @@ class ApiClient {
}
return null;
}
Response _ensureBusinessSuccess(Response response) {
final body = response.data;
if (body is Map && body.containsKey('code')) {
final rawCode = body['code'];
final code = rawCode is int ? rawCode : int.tryParse('$rawCode');
if (code != null && code != 0) {
throw ApiException(
body['message']?.toString().trim().isNotEmpty == true
? body['message'].toString()
: '请求失败',
code: code,
);
}
}
return response;
}
Future<Response> _request(Future<Response> request) async {
try {
return _ensureBusinessSuccess(await request);
} on DioException catch (error) {
final body = error.response?.data;
final message = body is Map && body['message']?.toString().trim().isNotEmpty == true
? body['message'].toString()
: error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout
? '请求超时,请稍后重试'
: error.type == DioExceptionType.connectionError
? '无法连接服务器,请检查网络或后端地址'
: '请求失败,请稍后重试';
final rawCode = body is Map ? body['code'] : null;
throw ApiException(
message,
code: rawCode is int
? rawCode
: int.tryParse('$rawCode') ?? error.response?.statusCode,
);
}
}
}
/// 认证拦截器:自动注入 token + 401 刷新

View File

@@ -12,6 +12,7 @@ import '../pages/report/ai_analysis_page.dart';
import '../pages/consultation/consultation_pages.dart';
import '../pages/settings/settings_pages.dart';
import '../pages/settings/notification_prefs_page.dart';
import '../pages/notifications/notification_center_page.dart';
import '../pages/profile/profile_page.dart';
import '../pages/diet/diet_capture_page.dart';
import '../pages/device/device_scan_page.dart';
@@ -97,6 +98,8 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
return const SettingsPage();
case 'notificationPrefs':
return const NotificationPrefsPage();
case 'notifications':
return const NotificationCenterPage();
case 'staticText':
return StaticTextPage(type: params['type']!);
case 'dietDetail':