feat: AI 意图路由/草稿存储 + Prompts 模块化 + UI 视觉统一 + AI 同意门 + 资源更新

后端:
- 新增 ai_intent_router 意图路由
- 新增 AiEntryDraft 草稿存储 + EF 迁移
- 新增 Prompts 模块化(global/modules/rag/router)
- AI Agent handlers 扩展

前端:
- 新增 AI 同意门 (ai_consent_gate/provider/details_page)
- HealthMetricVisuals 视觉统一
- 设备扫描/管理页面优化
- agent 插图替换 + 趋势指标图标

配置:
- DeepSeek 模型改 deepseek-v4-flash
- .gitignore 加 artifacts/audit
- build.gradle.kts 签名配置调整
This commit is contained in:
MingNian
2026-07-28 15:03:38 +08:00
parent 25a4078688
commit b4bc15b9b9
88 changed files with 5527 additions and 1240 deletions

View File

@@ -3,9 +3,9 @@ import 'dart:math';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../core/app_colors.dart';
import '../../core/app_design_tokens.dart';
import '../../core/app_module_visuals.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
@@ -27,11 +27,11 @@ const Set<String> supportedTrendMetricTypes = {
class _TrendColors {
_TrendColors._();
static const bloodPressure = Color(0xFFD45B68);
static const heartRate = Color(0xFFD97757);
static const glucose = Color(0xFF4F6EF7);
static const spo2 = Color(0xFF168F7A);
static const weight = Color(0xFF7559C7);
static const bloodPressure = HealthMetricVisuals.bloodPressureColor;
static const heartRate = HealthMetricVisuals.heartRateColor;
static const glucose = HealthMetricVisuals.glucoseColor;
static const spo2 = HealthMetricVisuals.spo2Color;
static const weight = HealthMetricVisuals.weightColor;
static const systolic = bloodPressure;
static const diastolic = Color(0xFF3B82F6);
}
@@ -132,31 +132,31 @@ class _TrendPageState extends ConsumerState<TrendPage> {
'key': 'blood_pressure',
'label': '血压',
'color': _TrendColors.bloodPressure,
'icon': LucideIcons.gauge,
'icon': HealthMetricVisuals.bloodPressureIcon,
},
{
'key': 'heart_rate',
'label': '心率',
'color': _TrendColors.heartRate,
'icon': LucideIcons.heartPulse,
'icon': HealthMetricVisuals.heartRateIcon,
},
{
'key': 'glucose',
'label': '血糖',
'color': _TrendColors.glucose,
'icon': LucideIcons.droplet,
'icon': HealthMetricVisuals.glucoseIcon,
},
{
'key': 'spo2',
'label': '血氧',
'color': _TrendColors.spo2,
'icon': LucideIcons.wind,
'icon': HealthMetricVisuals.spo2Icon,
},
{
'key': 'weight',
'label': '体重',
'color': _TrendColors.weight,
'icon': LucideIcons.scale,
'icon': HealthMetricVisuals.weightIcon,
},
];
@@ -176,6 +176,14 @@ class _TrendPageState extends ConsumerState<TrendPage> {
'weight': '体重',
};
static const _latestCardArt = {
'blood_pressure': 'assets/branding/trend_metric_blood_pressure.png',
'heart_rate': 'assets/branding/trend_metric_heart_rate.png',
'glucose': 'assets/branding/trend_metric_glucose.png',
'spo2': 'assets/branding/trend_metric_spo2.png',
'weight': 'assets/branding/trend_metric_weight.png',
};
String get _unit => _units[_selected] ?? '';
String get _name => _names[_selected] ?? '';
bool get _isBP => _selected == 'blood_pressure';
@@ -433,19 +441,9 @@ class _TrendPageState extends ConsumerState<TrendPage> {
padding: const EdgeInsets.all(14),
child: Column(
children: [
_buildMetricTabs(),
_buildMetricOverview(),
const SizedBox(height: 16),
if (_latestRecord != null) ...[
_buildLatestSummary(),
const SizedBox(height: 16),
],
_buildPeriodSelector(),
const SizedBox(height: 12),
_buildChart(),
if (_chartRecords.isNotEmpty) ...[
const SizedBox(height: 12),
_buildStatistics(),
],
_buildTrendInsightPanel(),
const SizedBox(height: 24),
_buildHistory(),
const SizedBox(height: 80),
@@ -456,47 +454,57 @@ class _TrendPageState extends ConsumerState<TrendPage> {
);
}
// ---- 指标选择标签 ----
Widget _buildMetricTabs() {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
// ---- 顶部指标概览,同时作为切换入口 ----
Widget _buildMetricOverview() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.pillBorder,
border: Border.all(color: AppColors.divider.withValues(alpha: 0.7)),
),
child: Row(
children: _metrics.map((m) {
final key = m['key'] as String;
final color = m['color'] as Color;
final sel = _selected == key;
return GestureDetector(
onTap: () => _switchMetric(key),
child: Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: sel ? AppColors.primarySoft : Colors.white,
borderRadius: AppRadius.xlBorder,
border: Border.all(
color: sel ? AppColors.primary : AppColors.borderLight,
return Expanded(
child: InkWell(
onTap: () => _switchMetric(key),
borderRadius: AppRadius.pillBorder,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: sel ? color : Colors.transparent,
borderRadius: AppRadius.pillBorder,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
m['icon'] as IconData,
size: 17,
color: sel ? AppColors.primaryDark : color,
),
const SizedBox(width: 6),
Text(
m['label'] as String,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: sel
? AppColors.primaryDark
: AppColors.textSecondary,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
m['icon'] as IconData,
size: 16,
color: sel ? Colors.white : color,
),
),
],
const SizedBox(width: 4),
Flexible(
child: Text(
m['label'] as String,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: sel ? Colors.white : AppColors.textSecondary,
),
),
),
],
),
),
),
);
@@ -510,95 +518,167 @@ class _TrendPageState extends ConsumerState<TrendPage> {
final date = record['date'] as DateTime;
final status = trendStatusLabel(record, _selected);
final abnormal = status.isNotEmpty;
return Container(
width: double.infinity,
padding: AppSpacing.panel,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.lgBorder,
),
child: Row(
final art = _latestCardArt[_selected] ?? _latestCardArt['heart_rate']!;
return SizedBox(
height: 214,
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: _color.withValues(alpha: 0.10),
borderRadius: AppRadius.mdBorder,
Positioned(
left: 0,
right: -48,
top: -12,
bottom: -12,
child: Image.asset(
art,
fit: BoxFit.cover,
alignment: Alignment.centerRight,
),
child: Icon(_getMetricIcon(_selected), color: _color, size: 25),
),
const SizedBox(width: 14),
Expanded(
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Colors.white.withValues(alpha: 0.96),
Colors.white.withValues(alpha: 0.72),
Colors.white.withValues(alpha: 0.04),
],
stops: const [0, 0.42, 0.78],
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(6, 8, 178, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'最新$_name',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 4),
Wrap(
crossAxisAlignment: WrapCrossAlignment.end,
spacing: 6,
Row(
children: [
Text(
_displayValue(record),
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
height: 1.05,
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: _color.withValues(alpha: 0.12),
borderRadius: AppRadius.mdBorder,
),
child: Icon(
_getMetricIcon(_selected),
color: _color,
size: 23,
),
),
Padding(
padding: const EdgeInsets.only(bottom: 3),
child: Text(
_unit,
style: const TextStyle(
fontSize: 15,
color: AppColors.textHint,
),
const SizedBox(width: 12),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'最新$_name',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 3),
Text(
_formatRecordTime(date),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
color: AppColors.textHint,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
const SizedBox(height: 18),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.end,
spacing: 7,
runSpacing: 4,
children: [
Text(
_displayValue(record),
style: const TextStyle(
fontSize: 44,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
height: 0.98,
),
),
],
),
),
const SizedBox(height: 8),
_StatusBadge(
label: abnormal ? status : '正常',
abnormal: abnormal,
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
abnormal ? status : '正常',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: abnormal ? AppColors.errorText : AppColors.successText,
),
),
const SizedBox(height: 6),
Text(
_formatRecordTime(date),
style: const TextStyle(fontSize: 13, color: AppColors.textHint),
),
],
Positioned(
left: 6,
right: 178,
bottom: 8,
child: _buildPeriodSelector(),
),
],
),
);
}
Widget _buildTrendInsightPanel() {
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.cardBorder,
boxShadow: AppShadows.panel,
),
child: Column(
children: [
if (_latestRecord != null) ...[
_buildLatestSummary(),
const SizedBox(height: 4),
],
if (_latestRecord == null) ...[
_buildPeriodSelector(),
const SizedBox(height: 14),
],
_buildChart(),
if (_chartRecords.isNotEmpty) ...[
const SizedBox(height: 4),
_buildStatistics(),
],
],
),
);
}
Widget _buildPeriodSelector() {
return Container(
padding: const EdgeInsets.all(3),
constraints: const BoxConstraints(maxWidth: 310),
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.mdBorder,
color: _color.withValues(alpha: 0.06),
borderRadius: AppRadius.lgBorder,
border: Border.all(color: _color.withValues(alpha: 0.14)),
),
child: Row(
children: [
@@ -611,21 +691,21 @@ class _TrendPageState extends ConsumerState<TrendPage> {
}),
borderRadius: AppRadius.smBorder,
child: Container(
height: 38,
height: 30,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _periodDays == days
? AppColors.primaryLight
? _color.withValues(alpha: 0.92)
: Colors.transparent,
borderRadius: AppRadius.smBorder,
),
child: Text(
'$days天',
style: TextStyle(
fontSize: 15,
fontSize: 12,
fontWeight: FontWeight.w700,
color: _periodDays == days
? AppColors.primaryDark
? Colors.white
: AppColors.textSecondary,
),
),
@@ -645,6 +725,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
.toList();
String average;
String highest;
String lowest;
if (_isBP) {
final systolic = records
.map((record) => (record['systolic'] as num?)?.toDouble())
@@ -657,29 +738,42 @@ class _TrendPageState extends ConsumerState<TrendPage> {
average = systolic.isEmpty || diastolic.isEmpty
? '--'
: '${(systolic.reduce((a, b) => a + b) / systolic.length).round()}/${(diastolic.reduce((a, b) => a + b) / diastolic.length).round()}';
final highestRecord = records
.where((record) {
return record['systolic'] is num && record['diastolic'] is num;
})
.fold<Map<String, dynamic>?>(null, (current, record) {
if (current == null) return record;
final currentValue = (current['systolic'] as num).toDouble();
final recordValue = (record['systolic'] as num).toDouble();
return recordValue > currentValue ? record : current;
});
final bpRecords = records.where((record) {
return record['systolic'] is num && record['diastolic'] is num;
});
final highestRecord = bpRecords.fold<Map<String, dynamic>?>(null, (
current,
record,
) {
if (current == null) return record;
final currentValue = (current['systolic'] as num).toDouble();
final recordValue = (record['systolic'] as num).toDouble();
return recordValue > currentValue ? record : current;
});
final lowestRecord = bpRecords.fold<Map<String, dynamic>?>(null, (
current,
record,
) {
if (current == null) return record;
final currentValue = (current['systolic'] as num).toDouble();
final recordValue = (record['systolic'] as num).toDouble();
return recordValue < currentValue ? record : current;
});
highest = highestRecord == null ? '--' : _displayValue(highestRecord);
lowest = lowestRecord == null ? '--' : _displayValue(lowestRecord);
} else {
average = values.isEmpty
? '--'
: formatTrendNumber(values.reduce((a, b) => a + b) / values.length);
highest = values.isEmpty ? '--' : formatTrendNumber(values.reduce(max));
lowest = values.isEmpty ? '--' : formatTrendNumber(values.reduce(min));
}
return Container(
padding: const EdgeInsets.symmetric(vertical: 14),
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.lgBorder,
color: _color.withValues(alpha: 0.045),
borderRadius: AppRadius.xlBorder,
),
child: Row(
children: [
@@ -687,7 +781,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
const SizedBox(height: 46, child: VerticalDivider(width: 1)),
_StatisticItem(label: '最高', value: highest, unit: _unit),
const SizedBox(height: 46, child: VerticalDivider(width: 1)),
_StatisticItem(label: '记录', value: '${records.length}', unit: ''),
_StatisticItem(label: '最低', value: lowest, unit: _unit),
],
),
);
@@ -796,16 +890,30 @@ class _TrendPageState extends ConsumerState<TrendPage> {
}
// X 轴刻度间隔:日标签短,可以排密一点
final xInterval = spots.length > 60
? 5.0
: spots.length > 30
? 2.0
: 1.0;
const xInterval = 1.0;
final dayStartIndices = <int>[];
for (var i = 0; i < records.length; i++) {
if (i == 0 ||
!_isSameChartDay(
records[i - 1]['date'] as DateTime,
records[i]['date'] as DateTime,
)) {
dayStartIndices.add(i);
}
}
final dayLabelStep = dayStartIndices.length > 7
? (dayStartIndices.length / 7).ceil()
: 1;
final visibleDayLabels = <int>{
for (var i = 0; i < dayStartIndices.length; i += dayLabelStep)
dayStartIndices[i],
if (dayStartIndices.isNotEmpty) dayStartIndices.last,
};
return Container(
padding: const EdgeInsets.fromLTRB(8, 20, 16, 12),
padding: const EdgeInsets.fromLTRB(4, 10, 12, 6),
decoration: BoxDecoration(
color: Colors.white,
color: Colors.transparent,
borderRadius: AppRadius.lgBorder,
),
child: Column(
@@ -853,7 +961,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
child: LayoutBuilder(
builder: (context, constraints) {
final chartWidth = constraints.maxWidth;
final paintWidth = chartWidth - 40.0; // 减左侧轴
final paintWidth = chartWidth - 32.0; // 减左侧轴
final paintHeight = 200.0 - 36.0; // 减底部轴
// 计算选中点的像素位置
Offset? tooltipPos;
@@ -868,7 +976,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
: (r['value'] as num?)?.toDouble();
if (v != null && spots.length > 1) {
final px =
40.0 +
32.0 +
(_selectedIdx! / (spots.length - 1)) * paintWidth;
final py = paintHeight * (1 - (v - minV) / (maxV - minV));
final d = r['date'] as DateTime;
@@ -904,7 +1012,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
reservedSize: 32,
interval: yStep,
getTitlesWidget: (v, meta) => Padding(
padding: const EdgeInsets.only(right: 6),
@@ -929,6 +1037,9 @@ class _TrendPageState extends ConsumerState<TrendPage> {
if (idx < 0 || idx >= records.length) {
return const SizedBox.shrink();
}
if (!visibleDayLabels.contains(idx)) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
@@ -953,9 +1064,10 @@ class _TrendPageState extends ConsumerState<TrendPage> {
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: false,
isCurved: true,
curveSmoothness: 0.28,
color: _color,
barWidth: 2.5,
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: spots.length <= 30,
@@ -978,14 +1090,25 @@ class _TrendPageState extends ConsumerState<TrendPage> {
);
},
),
belowBarData: BarAreaData(show: false),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
_color.withValues(alpha: 0.18),
_color.withValues(alpha: 0.02),
],
),
),
),
if (_isBP && diastolicSpots.isNotEmpty)
LineChartBarData(
spots: diastolicSpots,
isCurved: false,
isCurved: true,
curveSmoothness: 0.28,
color: _TrendColors.diastolic,
barWidth: 2.5,
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: diastolicSpots.length <= 30,
@@ -999,7 +1122,12 @@ class _TrendPageState extends ConsumerState<TrendPage> {
strokeColor: _TrendColors.diastolic,
),
),
belowBarData: BarAreaData(show: false),
belowBarData: BarAreaData(
show: true,
color: _TrendColors.diastolic.withValues(
alpha: 0.05,
),
),
),
],
lineTouchData: LineTouchData(
@@ -1087,18 +1215,15 @@ class _TrendPageState extends ConsumerState<TrendPage> {
String _chartAxisLabel(List<Map<String, dynamic>> records, int index) {
final date = records[index]['date'] as DateTime;
final sameDayCount = records.where((record) {
final other = record['date'] as DateTime;
return other.year == date.year &&
other.month == date.month &&
other.day == date.day;
}).length;
if (sameDayCount > 1) {
return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
}
return '${date.month}-${date.day}';
}
bool _isSameChartDay(DateTime first, DateTime second) {
return first.year == second.year &&
first.month == second.month &&
first.day == second.day;
}
// ---- 历史记录列表 ----
Widget _buildHistory() {
if (_filtered.isEmpty) return const SizedBox.shrink();
@@ -1113,7 +1238,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
),
const Spacer(),
Text(
'最近${min(30, _filtered.length)}',
'${_filtered.length}',
style: const TextStyle(
fontSize: 16,
color: AppColors.textSecondary,
@@ -1127,6 +1252,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.xlBorder,
boxShadow: AppShadows.soft,
),
child: Column(
children: [
@@ -1230,40 +1356,47 @@ class _TrendPageState extends ConsumerState<TrendPage> {
),
const SizedBox(width: 12),
Expanded(
child: Text(
_formatRecordTime(date),
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
fontWeight: FontWeight.w500,
),
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'$display $_unit',
style: TextStyle(
fontSize: 19,
fontWeight: FontWeight.w700,
color: abnormal
? AppColors.errorText
: AppColors.textPrimary,
),
),
if (status.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
status,
style: const TextStyle(
fontSize: 12,
color: AppColors.errorText,
fontWeight: FontWeight.w600,
child: Row(
children: [
Flexible(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
_formatRecordTime(date),
maxLines: 1,
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(width: 6),
_StatusBadge(
label: abnormal ? status : '正常',
abnormal: abnormal,
),
],
],
),
),
const SizedBox(width: 8),
SizedBox(
width: 116,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Text(
'$display $_unit',
maxLines: 1,
style: const TextStyle(
fontSize: 21,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
),
),
],
),
@@ -1359,6 +1492,43 @@ class _StatisticItem extends StatelessWidget {
}
}
class _StatusBadge extends StatelessWidget {
final String label;
final bool abnormal;
const _StatusBadge({required this.label, required this.abnormal});
@override
Widget build(BuildContext context) {
final color = abnormal ? AppColors.warningText : AppColors.successText;
final background = abnormal
? AppColors.warningLight
: AppColors.successLight;
return Container(
constraints: const BoxConstraints(maxWidth: 78),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: background,
borderRadius: AppRadius.pillBorder,
border: Border.all(color: color.withValues(alpha: 0.18)),
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
label,
maxLines: 1,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: color,
height: 1,
),
),
),
);
}
}
class _ChartLegend extends StatelessWidget {
final Color color;
final String label;