feat: 问诊对话+建档引导+报告+日历+趋势页重写+视觉统一
- 问诊对话页: 新建consultation_provider, 完整AI分身聊天UI - 首次建档引导: OnboardingPrompt+ManageArchiveTool+前端卡片 - 报告模块: POST端点 + report_agent_handler接VLM - 健康日历: GET /api/calendar端点 + 前端接API - 趋势页重写: 删除Mock数据 + 真实API + 手动录入 - 饮食保存: submit按钮接后端API - 侧边栏: 健康概览横排 + 统一紫色配色 - 运动计划: 修复JSON反序列化(record→手动解析) - VLM: prompt优化 + 模型切qwen3-vl-plus + 1280px压缩 - UI颜色: 加深主色 8B9CF7→6C5CE7 - 个人信息页精简 + 健康档案可编辑重设计 - 保洁: 删除无用agent_bar + 删除意见反馈/关于我们 - 新增AI提示词文档
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/navigation_provider.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import '../providers/data_providers.dart';
|
||||
|
||||
/// 饮食记录列表
|
||||
@@ -354,92 +355,135 @@ class _FollowUpItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 健康档案
|
||||
class HealthArchivePage extends ConsumerWidget {
|
||||
/// 健康档案(可编辑)
|
||||
class HealthArchivePage extends ConsumerStatefulWidget {
|
||||
const HealthArchivePage({super.key});
|
||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
||||
final service = ref.watch(userServiceProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('健康档案')),
|
||||
body: FutureBuilder<Map<String, dynamic>?>(
|
||||
future: service.getHealthArchive(),
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator());
|
||||
final data = snap.data;
|
||||
if (data == null || data.isEmpty) return _empty(context, '暂无健康档案', '可通过 AI 对话或手动填写');
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_Section(title: '基本信息', children: [
|
||||
_Field('诊断', data['diagnosis']), _Field('手术类型', data['surgeryType']),
|
||||
_Field('手术日期', data['surgeryDate']),
|
||||
]),
|
||||
_Section(title: '病史与限制', children: [
|
||||
_Field('过敏史', _listStr(data['allergies'])),
|
||||
_Field('饮食限制', _listStr(data['dietRestrictions'])),
|
||||
_Field('慢性病史', _listStr(data['chronicDiseases'])),
|
||||
_Field('家族病史', data['familyHistory']),
|
||||
]),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
@override ConsumerState<HealthArchivePage> createState() => _HealthArchivePageState();
|
||||
}
|
||||
|
||||
class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
|
||||
final _nameCtrl = TextEditingController();
|
||||
final _genderCtrl = TextEditingController();
|
||||
final _birthCtrl = TextEditingController();
|
||||
final _diagnosisCtrl = TextEditingController();
|
||||
final _surgeryCtrl = TextEditingController();
|
||||
final _surgeryDateCtrl = TextEditingController();
|
||||
final _allergiesCtrl = TextEditingController();
|
||||
final _chronicCtrl = TextEditingController();
|
||||
final _dietCtrl = TextEditingController();
|
||||
final _familyCtrl = TextEditingController();
|
||||
bool _loading = true;
|
||||
|
||||
@override void initState() { super.initState(); _load(); }
|
||||
@override void dispose() {
|
||||
_nameCtrl.dispose(); _genderCtrl.dispose(); _birthCtrl.dispose();
|
||||
_diagnosisCtrl.dispose(); _surgeryCtrl.dispose(); _surgeryDateCtrl.dispose();
|
||||
_allergiesCtrl.dispose(); _chronicCtrl.dispose(); _dietCtrl.dispose();
|
||||
_familyCtrl.dispose(); super.dispose();
|
||||
}
|
||||
String _listStr(dynamic list) => list is List ? list.join('、') : '--';
|
||||
}
|
||||
|
||||
class _Section extends StatelessWidget {
|
||||
final String title; final List<Widget> children;
|
||||
const _Section({required this.title, required this.children});
|
||||
@override Widget build(BuildContext context) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Padding(padding: const EdgeInsets.only(bottom: 8, top: 16), child: Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)))),
|
||||
...children,
|
||||
]);
|
||||
}
|
||||
Future<void> _load() async {
|
||||
final srv = ref.read(userServiceProvider);
|
||||
try {
|
||||
final p = await srv.getProfile();
|
||||
if (p != null && mounted) {
|
||||
_nameCtrl.text = p['name'] ?? '';
|
||||
_genderCtrl.text = p['gender'] ?? '';
|
||||
_birthCtrl.text = p['birthDate'] ?? '';
|
||||
}
|
||||
final a = await srv.getHealthArchive();
|
||||
if (a != null && mounted) {
|
||||
_diagnosisCtrl.text = a['diagnosis'] ?? '';
|
||||
_surgeryCtrl.text = a['surgeryType'] ?? '';
|
||||
_surgeryDateCtrl.text = a['surgeryDate'] ?? '';
|
||||
_allergiesCtrl.text = (a['allergies'] as List?)?.join('、') ?? '';
|
||||
_chronicCtrl.text = (a['chronicDiseases'] as List?)?.join('、') ?? '';
|
||||
_dietCtrl.text = (a['dietRestrictions'] as List?)?.join('、') ?? '';
|
||||
_familyCtrl.text = a['familyHistory'] ?? '';
|
||||
}
|
||||
if (mounted) setState(() => _loading = false);
|
||||
} catch (_) { if (mounted) setState(() => _loading = false); }
|
||||
}
|
||||
|
||||
class _Field extends StatelessWidget {
|
||||
final String label; final String? value;
|
||||
const _Field(this.label, this.value);
|
||||
@override Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
SizedBox(width: 80, child: Text('$label:', style: const TextStyle(fontSize: 14, color: Color(0xFF666666)))),
|
||||
Expanded(child: Text(value ?? '--', style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/// 编辑资料
|
||||
class EditProfilePage extends ConsumerStatefulWidget {
|
||||
const EditProfilePage({super.key});
|
||||
@override ConsumerState<EditProfilePage> createState() => _EditProfilePageState();
|
||||
}
|
||||
class _EditProfilePageState extends ConsumerState<EditProfilePage> {
|
||||
final _nameCtrl = TextEditingController(); final _genderCtrl = TextEditingController(); final _birthCtrl = TextEditingController();
|
||||
@override void dispose() { _nameCtrl.dispose(); _genderCtrl.dispose(); _birthCtrl.dispose(); super.dispose(); }
|
||||
@override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _load()); }
|
||||
void _load() async {
|
||||
final p = await ref.read(userServiceProvider).getProfile();
|
||||
if (p != null && mounted) {
|
||||
setState(() { _nameCtrl.text = p['name'] ?? ''; _genderCtrl.text = p['gender'] ?? ''; _birthCtrl.text = p['birthDate'] ?? ''; });
|
||||
Future<void> _save() async {
|
||||
final srv = ref.read(userServiceProvider);
|
||||
try {
|
||||
await srv.updateProfile(name: _nameCtrl.text, gender: _genderCtrl.text, birthDate: _birthCtrl.text);
|
||||
await srv.updateHealthArchive({
|
||||
'diagnosis': _diagnosisCtrl.text,
|
||||
'surgeryType': _surgeryCtrl.text,
|
||||
'surgeryDate': _surgeryDateCtrl.text,
|
||||
'allergies': _allergiesCtrl.text.split('、').where((s) => s.isNotEmpty).toList(),
|
||||
'chronicDiseases': _chronicCtrl.text.split('、').where((s) => s.isNotEmpty).toList(),
|
||||
'dietRestrictions': _dietCtrl.text.split('、').where((s) => s.isNotEmpty).toList(),
|
||||
'familyHistory': _familyCtrl.text,
|
||||
});
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已保存'), backgroundColor: Color(0xFF43A047)));
|
||||
} catch (_) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存失败'), backgroundColor: Color(0xFFE53935)));
|
||||
}
|
||||
}
|
||||
Future<void> _save() async {
|
||||
await ref.read(userServiceProvider).updateProfile(name: _nameCtrl.text, gender: _genderCtrl.text, birthDate: _birthCtrl.text);
|
||||
if (mounted) Navigator.pop(context);
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
if (_loading) return Scaffold(appBar: AppBar(title: const Text('健康档案')), body: const Center(child: CircularProgressIndicator(color: Color(0xFF6C5CE7))));
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF8F9FC),
|
||||
appBar: AppBar(title: const Text('健康档案'), actions: [
|
||||
TextButton(onPressed: _save, child: const Text('保存', style: TextStyle(color: Color(0xFF6C5CE7), fontWeight: FontWeight.w600))),
|
||||
]),
|
||||
body: ListView(padding: const EdgeInsets.all(16), children: [
|
||||
_sectionTitle('个人资料'),
|
||||
_editableCard(children: [
|
||||
_field('姓名', _nameCtrl),
|
||||
Row(children: [Expanded(child: _field('性别', _genderCtrl)), const SizedBox(width: 12), Expanded(child: _field('出生日期', _birthCtrl, hint: 'YYYY-MM-DD'))]),
|
||||
]),
|
||||
_sectionTitle('医疗信息'),
|
||||
_editableCard(children: [
|
||||
_field('主要诊断', _diagnosisCtrl, hint: '如: 冠心病'),
|
||||
Row(children: [Expanded(child: _field('手术类型', _surgeryCtrl, hint: '如: PCI支架植入术')), const SizedBox(width: 12), Expanded(child: _field('手术日期', _surgeryDateCtrl, hint: 'YYYY-MM-DD'))]),
|
||||
]),
|
||||
_sectionTitle('病史与限制'),
|
||||
_editableCard(children: [
|
||||
_field('过敏史', _allergiesCtrl, hint: '多个用、分隔 如: 青霉素、海鲜'),
|
||||
_field('慢性病史', _chronicCtrl, hint: '多个用、分隔 如: 高血压、高血脂'),
|
||||
_field('饮食限制', _dietCtrl, hint: '多个用、分隔 如: 低盐、低脂'),
|
||||
_field('家族病史', _familyCtrl, hint: '如: 父亲冠心病'),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _save, child: const Text('保存档案')),),
|
||||
const SizedBox(height: 40),
|
||||
]),
|
||||
);
|
||||
}
|
||||
@override Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('编辑资料')),
|
||||
body: ListView(padding: const EdgeInsets.all(16), children: [
|
||||
TextField(controller: _nameCtrl, decoration: const InputDecoration(labelText: '姓名')),
|
||||
const SizedBox(height: 16), TextField(controller: _genderCtrl, decoration: const InputDecoration(labelText: '性别')),
|
||||
const SizedBox(height: 16), TextField(controller: _birthCtrl, decoration: const InputDecoration(labelText: '出生日期', hintText: 'YYYY-MM-DD')),
|
||||
const SizedBox(height: 32), SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _save, child: const Text('保存'))),
|
||||
|
||||
Widget _sectionTitle(String title) => Padding(
|
||||
padding: const EdgeInsets.only(left: 4, top: 16, bottom: 8),
|
||||
child: Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Color(0xFF6C5CE7))),
|
||||
);
|
||||
|
||||
Widget _editableCard({required List<Widget> children}) => Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [BoxShadow(color: const Color(0xFF6C5CE7).withAlpha(8), blurRadius: 8, offset: const Offset(0, 2))]),
|
||||
child: Column(children: children),
|
||||
);
|
||||
|
||||
Widget _field(String label, TextEditingController ctrl, {String? hint}) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(label, style: const TextStyle(fontSize: 13, color: Color(0xFF888888))),
|
||||
const SizedBox(height: 4),
|
||||
TextField(controller: ctrl, decoration: InputDecoration(hintText: hint, filled: true, fillColor: const Color(0xFFF4F5FA), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none)), style: const TextStyle(fontSize: 15)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/// 编辑资料(已合并到健康档案,保留路由兼容)
|
||||
class EditProfilePage extends ConsumerWidget {
|
||||
final String? id;
|
||||
const EditProfilePage({super.key, this.id});
|
||||
@override Widget build(BuildContext context, WidgetRef ref) => const HealthArchivePage();
|
||||
}
|
||||
|
||||
/// 健康日历
|
||||
class HealthCalendarPage extends ConsumerStatefulWidget {
|
||||
const HealthCalendarPage({super.key});
|
||||
@@ -448,6 +492,29 @@ class HealthCalendarPage extends ConsumerStatefulWidget {
|
||||
|
||||
class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
|
||||
DateTime _currentMonth = DateTime.now();
|
||||
Map<String, List<String>> _events = {};
|
||||
|
||||
@override void initState() {
|
||||
super.initState();
|
||||
_loadMonth();
|
||||
}
|
||||
|
||||
Future<void> _loadMonth() async {
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final res = await api.get('/api/calendar', queryParameters: {
|
||||
'year': _currentMonth.year,
|
||||
'month': _currentMonth.month,
|
||||
});
|
||||
final list = (res.data['data'] as List?) ?? [];
|
||||
final events = <String, List<String>>{};
|
||||
for (final item in list) {
|
||||
final map = item as Map<String, dynamic>;
|
||||
events[map['date'] as String] = List<String>.from(map['events'] ?? []);
|
||||
}
|
||||
if (mounted) setState(() => _events = events);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -468,7 +535,7 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_left, size: 32),
|
||||
onPressed: () => setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1)),
|
||||
onPressed: () { setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1)); _loadMonth(); },
|
||||
),
|
||||
Text(
|
||||
'${_currentMonth.year}年${_currentMonth.month}月',
|
||||
@@ -476,7 +543,7 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_right, size: 32),
|
||||
onPressed: () => setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1)),
|
||||
onPressed: () { setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1)); _loadMonth(); },
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -555,11 +622,8 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
|
||||
}
|
||||
|
||||
List<String> _getEvents(DateTime date) {
|
||||
final events = <String>[];
|
||||
if (date.day == 5 || date.day == 12 || date.day == 19 || date.day == 26) events.add('medication');
|
||||
if (date.day == 8 || date.day == 15 || date.day == 22 || date.day == 29) events.add('exercise');
|
||||
if (date.day == 20) events.add('followup');
|
||||
return events;
|
||||
final key = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
return _events[key] ?? [];
|
||||
}
|
||||
|
||||
Color _getEventColor(String type) {
|
||||
|
||||
Reference in New Issue
Block a user