feat: UI 系统中心化 + 趋势图重构 + 法律文档 H5 上线

- UI 系统: 新增 design_tokens / module_visuals / app_buttons / app_status_badge / app_toast / ai_content 六个共享模块; 28 个页面接入, SnackBar 全部替换为 AppToast
- 趋势图: 直线折线 + 渐变填充 + 阴影; 去网格线; X 轴日+月份分隔; Y 轴整数刻度最多 4 个; 点击数据点/录入记录显示上方浮层, 选中点变实心
- 法律文档 H5: 后端 wwwroot 托管隐私政策/服务协议/关于/个人信息收集清单/第三方 SDK 清单 + 索引页; Program.cs 启用 UseDefaultFiles + UseStaticFiles
- 其他: auth_provider 登录流程调整; api_client IP 适配; 主页智能体栏间距优化
This commit is contained in:
MingNian
2026-07-08 21:25:07 +08:00
parent 7a93237069
commit 1c020b8ae5
45 changed files with 3480 additions and 1000 deletions

View File

@@ -6,7 +6,7 @@ import 'local_database.dart';
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
const String baseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://192.168.10.139:5000',
defaultValue: 'http://10.4.212.224:5000',
);
class ApiException implements Exception {
@@ -19,21 +19,46 @@ class ApiException implements Exception {
String toString() => message;
}
typedef AuthExpiredListener = void Function();
class AuthExpiredNotifier {
final List<AuthExpiredListener> _listeners = [];
AuthExpiredListener addListener(AuthExpiredListener listener) {
_listeners.add(listener);
return () => _listeners.remove(listener);
}
void notify() {
for (final listener in List<AuthExpiredListener>.from(_listeners)) {
listener();
}
}
}
/// Dio HTTP 客户端封装——带 token 注入、401 自动刷新
class ApiClient {
final Dio _dio;
final LocalDatabase _db;
final AuthExpiredNotifier? _authExpiredNotifier;
ApiClient({required LocalDatabase db})
: _db = db,
_dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 60),
headers: {'Content-Type': 'application/json'},
)) {
ApiClient({
required LocalDatabase db,
AuthExpiredNotifier? authExpiredNotifier,
}) : _db = db,
_authExpiredNotifier = authExpiredNotifier,
_dio = Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 60),
headers: {'Content-Type': 'application/json'},
),
) {
_dio.interceptors.add(_AuthInterceptor(this));
_dio.interceptors.add(LogInterceptor(requestBody: false, responseBody: false));
_dio.interceptors.add(
LogInterceptor(requestBody: false, responseBody: false),
);
}
Dio get dio => _dio;
@@ -51,8 +76,15 @@ class ApiClient {
await _db.delete('refresh_token');
}
void notifyAuthExpired() {
_authExpiredNotifier?.notify();
}
/// 带 token 的 GET 请求
Future<Response> get(String path, {Map<String, dynamic>? queryParameters}) async {
Future<Response> get(
String path, {
Map<String, dynamic>? queryParameters,
}) async {
return _request(_dio.get(path, queryParameters: queryParameters));
}
@@ -72,9 +104,16 @@ class ApiClient {
}
/// 上传文件multipart返回文件 URL
Future<String?> uploadFile(String path, File file, {String fieldName = 'file'}) async {
Future<String?> uploadFile(
String path,
File file, {
String fieldName = 'file',
}) async {
final formData = FormData.fromMap({
fieldName: await MultipartFile.fromFile(file.path, filename: file.path.split('/').last),
fieldName: await MultipartFile.fromFile(
file.path,
filename: file.path.split('/').last,
),
});
final res = await _request(_dio.post(path, data: formData));
final data = res.data;
@@ -118,14 +157,15 @@ class ApiClient {
return _ensureBusinessSuccess(await request);
} on DioException catch (error) {
final body = error.response?.data;
final message = body is Map && body['message']?.toString().trim().isNotEmpty == true
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
? '无法连接服务器,请检查网络或后端地址'
: '请求失败,请稍后重试';
error.type == DioExceptionType.receiveTimeout
? '请求超时,请稍后重试'
: error.type == DioExceptionType.connectionError
? '无法连接服务器,请检查网络或后端地址'
: '请求失败,请稍后重试';
final rawCode = body is Map ? body['code'] : null;
throw ApiException(
message,
@@ -144,7 +184,10 @@ class _AuthInterceptor extends Interceptor {
_AuthInterceptor(this._client);
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
void onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
if (!options.path.contains('/auth/')) {
final token = await _client.accessToken;
if (token != null) {
@@ -160,20 +203,26 @@ class _AuthInterceptor extends Interceptor {
final refresh = await _client.refreshToken;
if (refresh != null) {
try {
final response = await Dio(BaseOptions(baseUrl: baseUrl))
.post('/api/auth/refresh', data: {'refreshToken': refresh});
final response = await Dio(
BaseOptions(baseUrl: baseUrl),
).post('/api/auth/refresh', data: {'refreshToken': refresh});
final data = response.data['data'];
if (data != null) {
await _client.saveTokens(data['accessToken'], data['refreshToken']);
final opts = err.requestOptions;
final token = data['accessToken'];
opts.headers['Authorization'] = 'Bearer $token';
final retryResponse = await Dio(BaseOptions(baseUrl: baseUrl)).fetch(opts);
final retryResponse = await Dio(
BaseOptions(baseUrl: baseUrl),
).fetch(opts);
return handler.resolve(retryResponse);
}
} catch (e) { log('[ApiClient] token刷新失败: $e'); }
} catch (e) {
log('[ApiClient] token刷新失败: $e');
}
}
await _client.clearTokens();
_client.notifyAuthExpired();
}
handler.next(err);
}

View File

@@ -20,6 +20,37 @@ class AppColors {
static const Color meadowAccent = Color(0xFFBAE6FD);
static const Color sageAccent = Color(0xFFE9D5FF);
static const Color health = Color(0xFF3B82F6);
static const Color healthLight = Color(0xFFEFF6FF);
static const Color healthBorder = Color(0xFFBFDBFE);
static const Color medication = Color(0xFF8B5CF6);
static const Color medicationLight = Color(0xFFF5F3FF);
static const Color medicationBorder = Color(0xFFDDD6FE);
static const Color exercise = Color(0xFF10B981);
static const Color exerciseLight = Color(0xFFECFDF5);
static const Color exerciseBorder = Color(0xFFA7F3D0);
static const Color report = Color(0xFF2563EB);
static const Color reportLight = Color(0xFFEEF2FF);
static const Color reportBorder = Color(0xFFC7D2FE);
static const Color diet = Color(0xFFF97316);
static const Color dietLight = Color(0xFFFFF7ED);
static const Color dietBorder = Color(0xFFFED7AA);
static const Color device = Color(0xFF06B6D4);
static const Color deviceLight = Color(0xFFECFEFF);
static const Color deviceBorder = Color(0xFFA5F3FC);
static const Color notification = Color(0xFF7C5CFF);
static const Color notificationLight = Color(0xFFF5F3FF);
static const Color notificationBorder = Color(0xFFDDD6FE);
static const Color doctor = Color(0xFF0F766E);
static const Color doctorLight = Color(0xFFF0FDFA);
static const Color doctorBorder = Color(0xFF99F6E4);
static const Color calendar = Color(0xFF6366F1);
static const Color calendarLight = Color(0xFFEEF2FF);
static const Color calendarBorder = Color(0xFFC7D2FE);
static const Color followup = Color(0xFFDB2777);
static const Color followupLight = Color(0xFFFDF2F8);
static const Color followupBorder = Color(0xFFFBCFE8);
static const Color background = Color(0xFFF8FAFF);
static const Color backgroundSoft = Color(0xFFFFFFFF);
static const Color cardBackground = Color(0xFFFFFFFF);
@@ -131,6 +162,66 @@ class AppColors {
colors: [Color(0xFF60A5FA), Color(0xFF8B5CF6)],
);
static const LinearGradient healthGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF60A5FA), Color(0xFF2563EB)],
);
static const LinearGradient medicationGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFA78BFA), Color(0xFF7C3AED)],
);
static const LinearGradient exerciseGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF34D399), Color(0xFF059669)],
);
static const LinearGradient reportGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF60A5FA), Color(0xFF2563EB)],
);
static const LinearGradient dietGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFB923C), Color(0xFFF97316)],
);
static const LinearGradient deviceGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF22D3EE), Color(0xFF0891B2)],
);
static const LinearGradient notificationGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF8B5CF6), Color(0xFF6366F1)],
);
static const LinearGradient doctorCareGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF14B8A6), Color(0xFF0F766E)],
);
static const LinearGradient calendarGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF818CF8), Color(0xFF4F46E5)],
);
static const LinearGradient followupGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFF472B6), Color(0xFFDB2777)],
);
static List<BoxShadow> get cardShadow => [
BoxShadow(
color: const Color(0xFF101828).withValues(alpha: 0.07),

View File

@@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import 'app_colors.dart';
class AppSpacing {
AppSpacing._();
static const double xs = 4;
static const double sm = 8;
static const double md = 12;
static const double lg = 16;
static const double xl = 20;
static const double xxl = 24;
static const EdgeInsets page = EdgeInsets.fromLTRB(16, 12, 16, 24);
static const EdgeInsets pageWithFab = EdgeInsets.fromLTRB(16, 12, 16, 88);
static const EdgeInsets panel = EdgeInsets.all(16);
static const EdgeInsets listItem = EdgeInsets.all(16);
static const EdgeInsets sheet = EdgeInsets.fromLTRB(20, 18, 20, 24);
static const EdgeInsets drawer = EdgeInsets.symmetric(horizontal: 12);
}
class AppRadius {
AppRadius._();
static const double xs = 6;
static const double sm = 10;
static const double md = 14;
static const double lg = 16;
static const double xl = 20;
static const double card = 24;
static const double pill = 999;
static BorderRadius get xsBorder => BorderRadius.circular(xs);
static BorderRadius get smBorder => BorderRadius.circular(sm);
static BorderRadius get mdBorder => BorderRadius.circular(md);
static BorderRadius get lgBorder => BorderRadius.circular(lg);
static BorderRadius get xlBorder => BorderRadius.circular(xl);
static BorderRadius get cardBorder => BorderRadius.circular(card);
static BorderRadius get pillBorder => BorderRadius.circular(pill);
}
class AppShadows {
AppShadows._();
static List<BoxShadow> get none => const [];
static List<BoxShadow> get soft => [
BoxShadow(
color: const Color(0xFF101828).withValues(alpha: 0.045),
blurRadius: 14,
offset: const Offset(0, 6),
),
];
static List<BoxShadow> get panel => [
BoxShadow(
color: const Color(0xFF101828).withValues(alpha: 0.07),
blurRadius: 22,
offset: const Offset(0, 10),
),
];
static List<BoxShadow> get floating => [
BoxShadow(
color: const Color(0xFF101828).withValues(alpha: 0.14),
blurRadius: 28,
offset: const Offset(0, 14),
),
];
}
class AppTextStyles {
AppTextStyles._();
static const TextStyle appBarTitle = TextStyle(
fontSize: 19,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
);
static const TextStyle summaryTitle = TextStyle(
fontSize: 20,
fontWeight: FontWeight.w900,
color: AppColors.textPrimary,
height: 1.2,
);
static const TextStyle summarySubtitle = TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
height: 1.35,
);
static const TextStyle sectionTitle = TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
);
static const TextStyle listTitle = TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
height: 1.22,
);
static const TextStyle primaryListTitle = TextStyle(
fontSize: 19,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
height: 1.22,
);
static const TextStyle listSubtitle = TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
height: 1.35,
);
static const TextStyle formLabel = TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.textSecondary,
);
static const TextStyle formValue = TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
);
static const TextStyle tag = TextStyle(
fontSize: 13,
fontWeight: FontWeight.w800,
color: AppColors.textSecondary,
height: 1.15,
);
static const TextStyle button = TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
);
static const TextStyle miniButton = TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
);
static const TextStyle chatBody = TextStyle(
fontSize: 18,
height: 1.5,
color: AppColors.textPrimary,
);
static const TextStyle chatTitle = TextStyle(
fontSize: 20,
height: 1.35,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
);
static const TextStyle aiNote = TextStyle(
fontSize: 13,
color: AppColors.textHint,
height: 1.25,
);
}

View File

@@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'app_colors.dart';
enum AppModule {
ai,
health,
medication,
exercise,
report,
diet,
device,
notification,
doctor,
calendar,
followup,
}
class AppModuleVisual {
final AppModule module;
final String label;
final IconData icon;
final Color color;
final Color lightColor;
final Color borderColor;
final LinearGradient gradient;
const AppModuleVisual({
required this.module,
required this.label,
required this.icon,
required this.color,
required this.lightColor,
required this.borderColor,
required this.gradient,
});
}
class AppModuleVisuals {
AppModuleVisuals._();
static const ai = AppModuleVisual(
module: AppModule.ai,
label: 'AI',
icon: LucideIcons.bot,
color: AppColors.primary,
lightColor: AppColors.primarySoft,
borderColor: AppColors.primaryLight,
gradient: AppColors.primaryGradient,
);
static const health = AppModuleVisual(
module: AppModule.health,
label: '健康',
icon: LucideIcons.heartPulse,
color: AppColors.health,
lightColor: AppColors.healthLight,
borderColor: AppColors.healthBorder,
gradient: AppColors.healthGradient,
);
static const medication = AppModuleVisual(
module: AppModule.medication,
label: '用药',
icon: LucideIcons.pill,
color: AppColors.medication,
lightColor: AppColors.medicationLight,
borderColor: AppColors.medicationBorder,
gradient: AppColors.medicationGradient,
);
static const exercise = AppModuleVisual(
module: AppModule.exercise,
label: '运动',
icon: LucideIcons.footprints,
color: AppColors.exercise,
lightColor: AppColors.exerciseLight,
borderColor: AppColors.exerciseBorder,
gradient: AppColors.exerciseGradient,
);
static const report = AppModuleVisual(
module: AppModule.report,
label: '报告',
icon: LucideIcons.fileText,
color: AppColors.report,
lightColor: AppColors.reportLight,
borderColor: AppColors.reportBorder,
gradient: AppColors.reportGradient,
);
static const diet = AppModuleVisual(
module: AppModule.diet,
label: '饮食',
icon: LucideIcons.utensils,
color: AppColors.diet,
lightColor: AppColors.dietLight,
borderColor: AppColors.dietBorder,
gradient: AppColors.dietGradient,
);
static const device = AppModuleVisual(
module: AppModule.device,
label: '设备',
icon: LucideIcons.bluetooth,
color: AppColors.device,
lightColor: AppColors.deviceLight,
borderColor: AppColors.deviceBorder,
gradient: AppColors.deviceGradient,
);
static const notification = AppModuleVisual(
module: AppModule.notification,
label: '通知',
icon: LucideIcons.bell,
color: AppColors.notification,
lightColor: AppColors.notificationLight,
borderColor: AppColors.notificationBorder,
gradient: AppColors.notificationGradient,
);
static const doctor = AppModuleVisual(
module: AppModule.doctor,
label: '医生',
icon: LucideIcons.stethoscope,
color: AppColors.doctor,
lightColor: AppColors.doctorLight,
borderColor: AppColors.doctorBorder,
gradient: AppColors.doctorCareGradient,
);
static const calendar = AppModuleVisual(
module: AppModule.calendar,
label: '日历',
icon: LucideIcons.calendarDays,
color: AppColors.calendar,
lightColor: AppColors.calendarLight,
borderColor: AppColors.calendarBorder,
gradient: AppColors.calendarGradient,
);
static const followup = AppModuleVisual(
module: AppModule.followup,
label: '随访',
icon: LucideIcons.calendarCheck,
color: AppColors.followup,
lightColor: AppColors.followupLight,
borderColor: AppColors.followupBorder,
gradient: AppColors.followupGradient,
);
static const values = <AppModule, AppModuleVisual>{
AppModule.ai: ai,
AppModule.health: health,
AppModule.medication: medication,
AppModule.exercise: exercise,
AppModule.report: report,
AppModule.diet: diet,
AppModule.device: device,
AppModule.notification: notification,
AppModule.doctor: doctor,
AppModule.calendar: calendar,
AppModule.followup: followup,
};
static AppModuleVisual of(AppModule module) => values[module] ?? ai;
}

View File

@@ -28,6 +28,18 @@ import '../pages/doctor/doctor_profile_page.dart';
import '../pages/admin/admin_home_page.dart';
import '../pages/admin/admin_add_doctor_page.dart';
import '../providers/auth_provider.dart' show userRoleProvider;
import '../widgets/app_error_state.dart';
Widget _missingParamPage() {
return const Scaffold(
body: AppErrorState(title: '页面参数错误', subtitle: '缺少打开页面所需的信息,请返回后重试'),
);
}
String? _requiredParam(Map<String, String> params, String key) {
final value = params[key]?.trim();
return value == null || value.isEmpty ? null : value;
}
/// 根据路由信息返回对应页面
Widget buildPage(RouteInfo route, WidgetRef ref) {
@@ -59,7 +71,8 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
case 'reports':
return const ReportListPage();
case 'aiAnalysis':
return AiAnalysisPage(id: params['id']!);
final id = _requiredParam(params, 'id');
return id == null ? _missingParamPage() : AiAnalysisPage(id: id);
case 'reportOriginal':
return ReportOriginalPage(
url: params['url'] ?? '',
@@ -82,13 +95,17 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
case 'doctorSettings':
return const DoctorSettingsPage();
case 'doctorPatientDetail':
return DoctorPatientDetailPage(id: params['id']!);
final id = _requiredParam(params, 'id');
return id == null ? _missingParamPage() : DoctorPatientDetailPage(id: id);
case 'doctorChat':
return DoctorChatPage(id: params['id']!);
final id = _requiredParam(params, 'id');
return id == null ? _missingParamPage() : DoctorChatPage(id: id);
case 'doctorReportDetail':
return DoctorReportDetailPage(id: params['id']!);
final id = _requiredParam(params, 'id');
return id == null ? _missingParamPage() : DoctorReportDetailPage(id: id);
case 'doctorFollowUpEdit':
return DoctorFollowUpEditPage(id: params['id']!);
final id = _requiredParam(params, 'id');
return id == null ? _missingParamPage() : DoctorFollowUpEditPage(id: id);
case 'devices':
return const DeviceManagementPage();
case 'deviceScan':
@@ -106,9 +123,11 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
case 'conversationHistory':
return const ConversationHistoryPage();
case 'staticText':
return StaticTextPage(type: params['type']!);
final type = _requiredParam(params, 'type');
return type == null ? _missingParamPage() : StaticTextPage(type: type);
case 'dietDetail':
return DietRecordDetailPage(id: params['id']!);
final id = _requiredParam(params, 'id');
return id == null ? _missingParamPage() : DietRecordDetailPage(id: id);
default:
return const LoginPage();
}