feat: AI 对话附件上下文解析 + 历史会话归档 + 多页面 UI 重构
- 后端: 新增 AttachmentContextBuilder 解析图片/PDF 摘要并拼入 LLM 上下文; ai_chat_endpoints 扩展附件接口; 新增 ReportAnalysisService - 前端: 新增历史会话页与 conversation_history_provider; chat 链路支持附件展示与回放 - UI: 重构 medication_checkin / notification_center / profile / health_drawer 等多页面 - 配置: api_client baseUrl 适配当前 WiFi IP
This commit is contained in:
@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import '../../../core/app_colors.dart';
|
||||
import '../../../core/app_theme.dart';
|
||||
import '../../../core/api_client.dart' show baseUrl;
|
||||
import '../../../core/navigation_provider.dart';
|
||||
import '../../../providers/chat_provider.dart';
|
||||
import '../../../providers/data_providers.dart';
|
||||
@@ -88,10 +89,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'开始和小脉健康对话吧',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
Text('开始和小脉健康对话吧', style: Theme.of(context).textTheme.bodyMedium),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'记录健康数据,获取专业建议',
|
||||
@@ -138,7 +136,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
msg.content.trim().isEmpty) {
|
||||
return _buildThinkingBubble(context, chatState.thinkingText);
|
||||
}
|
||||
return _buildTextBubble(context, msg, chatState);
|
||||
return _buildTextBubble(context, ref, msg, chatState);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +152,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
) {
|
||||
final info = _agentInfo(agent);
|
||||
final actions = agent.actions;
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final screenWidth = MediaQuery.sizeOf(context).width;
|
||||
final agentColors = _agentColors(agent);
|
||||
final artworkPath = _agentArtworkPath(agent);
|
||||
|
||||
@@ -467,7 +465,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
) {
|
||||
final meta = msg.metadata ?? <String, dynamic>{};
|
||||
final backendType = meta['type'] as String? ?? '';
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final screenWidth = MediaQuery.sizeOf(context).width;
|
||||
|
||||
// 识别是哪种数据类型
|
||||
final isMedication =
|
||||
@@ -501,23 +499,17 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
final time = meta['time'] as String? ?? '';
|
||||
if (time.isNotEmpty) {
|
||||
final timeValue = meta['服药时间'] as String? ?? time;
|
||||
fields.add(
|
||||
_ConfirmField(label: '服药时间', value: timeValue),
|
||||
);
|
||||
fields.add(_ConfirmField(label: '服药时间', value: timeValue));
|
||||
}
|
||||
final frequency = meta['frequency'] as String? ?? '';
|
||||
if (frequency.isNotEmpty) {
|
||||
final freqValue = meta['频率'] as String? ?? _freqLabel(frequency);
|
||||
fields.add(
|
||||
_ConfirmField(label: '频率', value: freqValue),
|
||||
);
|
||||
fields.add(_ConfirmField(label: '频率', value: freqValue));
|
||||
}
|
||||
final duration = meta['duration_days'] as int?;
|
||||
if (duration != null && duration > 0) {
|
||||
final durationValue = meta['服用天数'] as String? ?? '$duration 天';
|
||||
fields.add(
|
||||
_ConfirmField(label: '服用天数', value: durationValue),
|
||||
);
|
||||
fields.add(_ConfirmField(label: '服用天数', value: durationValue));
|
||||
}
|
||||
} else if (isExercise) {
|
||||
// 运动计划 — 主展示区大号显示运动名,字段列时长/频率/天数
|
||||
@@ -558,17 +550,13 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
}
|
||||
final intensity = (meta['intensity'] ?? meta['运动强度'] ?? '').toString();
|
||||
if (intensity.isNotEmpty) {
|
||||
fields.add(
|
||||
_ConfirmField(label: '运动强度', value: intensity),
|
||||
);
|
||||
fields.add(_ConfirmField(label: '运动强度', value: intensity));
|
||||
}
|
||||
final calories =
|
||||
(meta['calories'] ?? meta['calorie'] ?? meta['消耗热量'] ?? '')
|
||||
.toString();
|
||||
if (calories.isNotEmpty) {
|
||||
fields.add(
|
||||
_ConfirmField(label: '消耗热量', value: '$calories kcal'),
|
||||
);
|
||||
fields.add(_ConfirmField(label: '消耗热量', value: '$calories kcal'));
|
||||
}
|
||||
} else {
|
||||
// 健康指标 — 主展示区已显示指标+数值+单位,详情只列额外信息
|
||||
@@ -580,15 +568,11 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
|
||||
if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt);
|
||||
final timeValue = meta['记录时间'] as String? ?? recordTime;
|
||||
fields.add(
|
||||
_ConfirmField(label: '记录时间', value: timeValue),
|
||||
);
|
||||
fields.add(_ConfirmField(label: '记录时间', value: timeValue));
|
||||
final note = meta['note'] as String? ?? '';
|
||||
if (note.isNotEmpty) {
|
||||
final noteValue = meta['备注'] as String? ?? note;
|
||||
fields.add(
|
||||
_ConfirmField(label: '备注', value: noteValue),
|
||||
);
|
||||
fields.add(_ConfirmField(label: '备注', value: noteValue));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -911,9 +895,9 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
.read(chatProvider.notifier)
|
||||
.confirmMessage(msg.id);
|
||||
if (error != null && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(error)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(error)));
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
@@ -999,16 +983,15 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||
padding: const EdgeInsets.all(1.4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardBackground,
|
||||
gradient: AppColors.actionOutlineGradient,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(4),
|
||||
topRight: Radius.circular(20),
|
||||
bottomLeft: Radius.circular(20),
|
||||
bottomRight: Radius.circular(20),
|
||||
),
|
||||
border: Border.all(color: AppColors.border, width: 1.5),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppTheme.primary.withAlpha(12),
|
||||
@@ -1017,28 +1000,40 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 26,
|
||||
height: 26,
|
||||
padding: const EdgeInsets.all(5),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryLight,
|
||||
borderRadius: BorderRadius.circular(13),
|
||||
),
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2.2,
|
||||
color: AppTheme.primary,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.cardBackground,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(3),
|
||||
topRight: Radius.circular(18.6),
|
||||
bottomLeft: Radius.circular(18.6),
|
||||
bottomRight: Radius.circular(18.6),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
thinkingText?.isNotEmpty == true ? thinkingText! : '正在分析...',
|
||||
style: const TextStyle(fontSize: 17, color: AppColors.textHint),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 26,
|
||||
height: 26,
|
||||
padding: const EdgeInsets.all(5),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryLight,
|
||||
borderRadius: BorderRadius.circular(13),
|
||||
),
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2.2,
|
||||
color: AppTheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
thinkingText?.isNotEmpty == true ? thinkingText! : '正在分析...',
|
||||
style: const TextStyle(fontSize: 17, color: AppColors.textHint),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1046,153 +1041,228 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
|
||||
Widget _buildTextBubble(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ChatMessage msg,
|
||||
ChatState? chatState,
|
||||
) {
|
||||
final isUser = msg.isUser;
|
||||
final imageUrl = msg.metadata?['imageUrl'] as String?;
|
||||
final localPath = msg.metadata?['localImagePath'] as String?;
|
||||
final pdfFileName =
|
||||
msg.metadata?['pdfFileName']?.toString() ??
|
||||
msg.metadata?['fileName']?.toString();
|
||||
final hasImage = imageUrl != null || localPath != null;
|
||||
final hasPdf = pdfFileName != null && pdfFileName.isNotEmpty;
|
||||
|
||||
return Align(
|
||||
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.82,
|
||||
maxWidth: MediaQuery.sizeOf(context).width * 0.82,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser ? Colors.white : AppColors.cardBackground,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(isUser ? 20 : 4),
|
||||
topRight: Radius.circular(isUser ? 4 : 20),
|
||||
bottomLeft: const Radius.circular(20),
|
||||
bottomRight: const Radius.circular(20),
|
||||
),
|
||||
border: Border.all(
|
||||
color: isUser ? const Color(0xFFE8E4FF) : AppColors.border,
|
||||
width: isUser ? 1 : 1.5,
|
||||
),
|
||||
boxShadow: isUser
|
||||
? []
|
||||
: [
|
||||
padding: EdgeInsets.all(isUser ? 0 : 1.4),
|
||||
decoration: isUser
|
||||
? null
|
||||
: BoxDecoration(
|
||||
gradient: AppColors.actionOutlineGradient,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(4),
|
||||
topRight: Radius.circular(20),
|
||||
bottomLeft: Radius.circular(20),
|
||||
bottomRight: Radius.circular(20),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppTheme.primary.withAlpha(12),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 文字内容
|
||||
if (isUser)
|
||||
SelectableText(
|
||||
msg.content,
|
||||
style: const TextStyle(
|
||||
fontSize: 19,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.5,
|
||||
),
|
||||
)
|
||||
else
|
||||
MarkdownBody(
|
||||
data: _cleanAiText(msg.content),
|
||||
selectable: true,
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
p: const TextStyle(
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser ? AppTheme.primary : AppColors.cardBackground,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(isUser ? 18.6 : 3),
|
||||
topRight: Radius.circular(isUser ? 3 : 18.6),
|
||||
bottomLeft: const Radius.circular(18.6),
|
||||
bottomRight: const Radius.circular(18.6),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 文字内容
|
||||
if (isUser)
|
||||
SelectableText(
|
||||
msg.content,
|
||||
style: const TextStyle(
|
||||
fontSize: 19,
|
||||
color: AppColors.textPrimary,
|
||||
color: Colors.white,
|
||||
height: 1.5,
|
||||
),
|
||||
strong: const TextStyle(
|
||||
fontSize: 19,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
h1: const TextStyle(
|
||||
fontSize: 21,
|
||||
color: AppColors.textPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
h2: const TextStyle(
|
||||
fontSize: 20,
|
||||
color: AppColors.textPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 图片缩略图(在文字下方)
|
||||
if (hasImage)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: GestureDetector(
|
||||
onTap: () => _showFullImage(context, localPath ?? imageUrl),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 160,
|
||||
maxHeight: 120,
|
||||
),
|
||||
child: localPath != null
|
||||
? Image.file(File(localPath), fit: BoxFit.cover)
|
||||
: imageUrl != null
|
||||
? Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, e, s) => Container(
|
||||
width: 80,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.backgroundSecondary,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.image,
|
||||
size: 28,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
)
|
||||
else
|
||||
MarkdownBody(
|
||||
data: _cleanAiText(msg.content),
|
||||
selectable: true,
|
||||
onTapLink: (text, href, title) =>
|
||||
_handleMarkdownLink(context, ref, href),
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
p: const TextStyle(
|
||||
fontSize: 19,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.5,
|
||||
),
|
||||
a: const TextStyle(
|
||||
fontSize: 19,
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: AppColors.primary,
|
||||
),
|
||||
strong: const TextStyle(
|
||||
fontSize: 19,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
h1: const TextStyle(
|
||||
fontSize: 22,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.35,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
h2: const TextStyle(
|
||||
fontSize: 21,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.4,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
h3: const TextStyle(
|
||||
fontSize: 20,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.4,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
listBullet: const TextStyle(
|
||||
fontSize: 24,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.2,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
listBulletPadding: const EdgeInsets.only(right: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (!isUser && msg.content.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundColor: AppTheme.primaryLight,
|
||||
child: Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 17,
|
||||
color: AppTheme.primary,
|
||||
// 图片缩略图(在文字下方)
|
||||
if (hasImage)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: GestureDetector(
|
||||
onTap: () => _showFullImage(context, localPath ?? imageUrl),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 160,
|
||||
maxHeight: 120,
|
||||
),
|
||||
child: localPath != null
|
||||
? Image.file(File(localPath), fit: BoxFit.cover)
|
||||
: imageUrl != null
|
||||
? Image.network(
|
||||
_mediaUrl(imageUrl),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, e, s) => Container(
|
||||
width: 80,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.backgroundSecondary,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.image,
|
||||
size: 28,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const Text(
|
||||
'小脉健康',
|
||||
style: TextStyle(fontSize: 15, color: AppColors.textHint),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Text(
|
||||
'仅供参考',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.textHint),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
if (hasPdf)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser
|
||||
? Colors.white.withValues(alpha: 0.16)
|
||||
: AppColors.backgroundSecondary,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.picture_as_pdf_outlined,
|
||||
size: 18,
|
||||
color: isUser ? Colors.white : AppColors.error,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
pdfFileName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isUser
|
||||
? Colors.white
|
||||
: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (!isUser && msg.content.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundColor: AppTheme.primaryLight,
|
||||
child: Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 17,
|
||||
color: AppTheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const Text(
|
||||
'内容由 AI 生成',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1204,6 +1274,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
|
||||
static void _showFullImage(BuildContext context, String? path) {
|
||||
if (path == null) return;
|
||||
final resolvedPath = _mediaUrl(path);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => Dialog(
|
||||
@@ -1216,8 +1287,10 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InteractiveViewer(
|
||||
child: path.startsWith('http')
|
||||
? Image.network(path, fit: BoxFit.contain)
|
||||
: Image.file(File(path), fit: BoxFit.contain),
|
||||
? Image.network(resolvedPath, fit: BoxFit.contain)
|
||||
: resolvedPath.startsWith('http')
|
||||
? Image.network(resolvedPath, fit: BoxFit.contain)
|
||||
: Image.file(File(resolvedPath), fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
@@ -1241,6 +1314,12 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
static String _mediaUrl(String path) {
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) return path;
|
||||
if (path.startsWith('/uploads/')) return '$baseUrl$path';
|
||||
return path;
|
||||
}
|
||||
|
||||
/// 清理 AI 返回文本中的异常占位符(Markdown 格式交给 MarkdownBody 渲染)
|
||||
static String _cleanAiText(String text) {
|
||||
var t = text;
|
||||
@@ -1255,6 +1334,37 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
return t.trim();
|
||||
}
|
||||
|
||||
/// 处理 AI 回复里的 markdown 链接点击:
|
||||
/// - app://diet → 触发拍照/相册选择,跳到饮食拍照流程
|
||||
/// - app://report → 跳到报告列表(用户可在那里上传新报告)
|
||||
/// - app://device → 跳到蓝牙设备页
|
||||
/// - 其他 app://xxx → 当作 route name 直接跳
|
||||
static void _handleMarkdownLink(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String? href,
|
||||
) {
|
||||
if (href == null || href.isEmpty) return;
|
||||
final uri = Uri.tryParse(href);
|
||||
if (uri == null) return;
|
||||
|
||||
if (uri.scheme == 'app') {
|
||||
switch (uri.host) {
|
||||
case 'diet':
|
||||
ref.read(chatProvider.notifier).triggerAgent(ActiveAgent.diet, '拍饮食');
|
||||
break;
|
||||
case 'report':
|
||||
pushRoute(ref, 'reports');
|
||||
break;
|
||||
case 'device':
|
||||
pushRoute(ref, 'devices');
|
||||
break;
|
||||
default:
|
||||
if (uri.host.isNotEmpty) pushRoute(ref, uri.host);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _freqLabel(String freq) {
|
||||
switch (freq.toLowerCase()) {
|
||||
case 'daily':
|
||||
@@ -1493,8 +1603,11 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
}
|
||||
if (hr is Map && hr['value'] is num) {
|
||||
final v = (hr['value'] as num).toDouble();
|
||||
if (v > 100) abnormals.add('心率 ${v.toStringAsFixed(0)} 偏快');
|
||||
else if (v < 60) abnormals.add('心率 ${v.toStringAsFixed(0)} 偏慢');
|
||||
if (v > 100) {
|
||||
abnormals.add('心率 ${v.toStringAsFixed(0)} 偏快');
|
||||
} else if (v < 60) {
|
||||
abnormals.add('心率 ${v.toStringAsFixed(0)} 偏慢');
|
||||
}
|
||||
}
|
||||
if (bs is Map && bs['value'] is num) {
|
||||
final v = (bs['value'] as num).toDouble();
|
||||
@@ -1554,7 +1667,8 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
if (plan != null) {
|
||||
final items =
|
||||
(plan['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
final today = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
|
||||
final today =
|
||||
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
|
||||
final todayItem = items.cast<Map<String, dynamic>?>().firstWhere(
|
||||
(i) => i?['scheduledDate']?.toString() == today,
|
||||
orElse: () => null,
|
||||
@@ -1631,7 +1745,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
}
|
||||
|
||||
// ── 3. 用药打卡 ──
|
||||
// 只显示已过期(漏服)的药;用颜色区分严重程度;没漏服则整行不显示
|
||||
// 漏服时突出提醒;未到服药时间时也保留一行轻提示,避免今日健康缺少用药状态。
|
||||
const medIconColor = Color(0xFFEC4899);
|
||||
const medIconBg = Color(0xFFFCE7F3);
|
||||
reminders.whenOrNull(
|
||||
@@ -1639,7 +1753,21 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
final overdueMeds = meds
|
||||
.where((m) => m['status'] == 'overdue')
|
||||
.toList();
|
||||
if (overdueMeds.isEmpty) return;
|
||||
if (overdueMeds.isEmpty) {
|
||||
tasks.add(
|
||||
_taskRow(
|
||||
context,
|
||||
Icons.medication_rounded,
|
||||
'用药',
|
||||
trailing: meds.isEmpty ? '暂无用药安排' : '暂时不用吃药',
|
||||
status: 'done',
|
||||
iconColor: medIconColor,
|
||||
iconBg: medIconBg,
|
||||
onTap: () => pushRoute(ref, 'medCheckIn'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 按过期时长排序——先把最严重的放前面
|
||||
final now = DateTime.now();
|
||||
@@ -1658,8 +1786,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
? 0
|
||||
: now.difference(scheduled).inMinutes / 60.0;
|
||||
return (m, overdueHours);
|
||||
}).toList()
|
||||
..sort((a, b) => b.$2.compareTo(a.$2));
|
||||
}).toList()..sort((a, b) => b.$2.compareTo(a.$2));
|
||||
|
||||
// 取最严重那一项作为代表,其他用"等"省略
|
||||
final first = withDelta.first;
|
||||
@@ -1969,10 +2096,7 @@ class _AgentAction {
|
||||
class _ConfirmField {
|
||||
final String label;
|
||||
final String value;
|
||||
const _ConfirmField({
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
const _ConfirmField({required this.label, required this.value});
|
||||
}
|
||||
|
||||
final _agentActions = <ActiveAgent, List<_AgentAction>>{
|
||||
|
||||
Reference in New Issue
Block a user