feat: 医生端完整实现 + 双角色登录 + 健康日历 + 饮食记录重做 + 配色系统重构

后端:
- User表加Role字段 + DoctorProfile表 + 废弃旧Doctor数据
- JWT加Role claim + 注册/登录拆分(/api/auth/register + /api/auth/login)
- 医生端点全量加JWT鉴权+Role校验(15个端点)
- 日历端点改造(用药/运动/随访按日期+星期匹配)
- 饮食记录PUT端点 + 健康记录DELETE端点
- 用户Profile端点返回Role

前端:
- 登录页重做(角色选择卡片 用户紫/医生蓝 + 审核码6666 + 注册返登录)
- App路由分流(userRole→患者端/医生端)
- 医生端: 工作台Dashboard + 患者管理(搜索分页) + 问诊/报告/随访CRUD
- 报告审核: AI预分析+指标表格+严重程度4级+模板多选+评语
- 随访编辑: 患者选择器+日期时间+打通患者端
- 健康日历: 可点击日历+当日安排列表+用药/运动/随访颜色标记
- 饮食记录: 热量趋势7/30天+日历(一周)+当日胶囊详情+侧滑编辑删除
- 健康档案: 日期选择器+手术可添加多条+白底渐变保存按钮
- 配色系统: 清理25个未使用色+新增doctorBlue/drawerGradient/pageGrey
- 患者端侧边栏: 新渐变+白底按钮+紫色汉堡图标
- AI对话: 用户气泡白底黑字+药管家改蓝色
- App启动闪屏: 防登录页闪烁

文档: color_design_system.md(20页面完整配色设计+图标底色对照表)
This commit is contained in:
MingNian
2026-06-14 23:28:14 +08:00
parent d102205b18
commit 01cd132476
35 changed files with 3473 additions and 1095 deletions

View File

@@ -1,12 +1,12 @@
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_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../providers/data_providers.dart';
const _doctorBlue = Color(0xFF0891B2);
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override ConsumerState<LoginPage> createState() => _LoginPageState();
@@ -15,13 +15,20 @@ class LoginPage extends ConsumerStatefulWidget {
class _LoginPageState extends ConsumerState<LoginPage> {
final _phoneCtrl = TextEditingController();
final _codeCtrl = TextEditingController();
final _inviteCtrl = TextEditingController();
String _role = 'User';
bool _showInvite = false;
bool _agreed = false;
bool _sending = false;
int _countdown = 0;
bool _loading = false;
String? _error;
bool _isLogin = true;
String? _successMsg;
@override void dispose() { _phoneCtrl.dispose(); _codeCtrl.dispose(); super.dispose(); }
@override void dispose() { _phoneCtrl.dispose(); _codeCtrl.dispose(); _inviteCtrl.dispose(); super.dispose(); }
Color get _accent => _role == 'Doctor' ? _doctorBlue : AppColors.primary;
Future<void> _sendSms() async {
final phone = _phoneCtrl.text.trim();
@@ -35,201 +42,162 @@ class _LoginPageState extends ConsumerState<LoginPage> {
}
void _startCountdown() async {
for (var i = 60; i > 0; i--) {
await Future.delayed(const Duration(seconds: 1));
if (!mounted) return;
setState(() => _countdown = i - 1);
for (var i = 60; i > 0; i--) { await Future.delayed(const Duration(seconds: 1)); if (mounted) setState(() => _countdown = i - 1); }
}
Future<void> _submit() async {
if (!_agreed) { setState(() => _error = '请阅读并同意服务协议和隐私政策'); return; }
if (_phoneCtrl.text.trim().isEmpty || _codeCtrl.text.trim().isEmpty) { setState(() => _error = '请输入手机号和验证码'); return; }
setState(() { _loading = true; _error = null; _successMsg = null; });
String? err;
if (_isLogin) {
err = await ref.read(authProvider.notifier).login(_phoneCtrl.text.trim(), _codeCtrl.text.trim());
} else {
if (_role == 'Doctor' && _inviteCtrl.text.trim() != '6666') { setState(() { _loading = false; _error = '审核码错误'; }); return; }
err = await ref.read(authProvider.notifier).register(_phoneCtrl.text.trim(), _codeCtrl.text.trim(), _role, inviteCode: _role == 'Doctor' ? _inviteCtrl.text.trim() : null);
}
setState(() => _loading = false);
if (err != null) {
if (err.contains('未注册')) { setState(() { _isLogin = false; }); }
setState(() => _error = err);
return;
}
if (!_isLogin) {
// 注册成功 → 回登录页
setState(() { _isLogin = true; _successMsg = '注册成功,请登录'; _error = null; _phoneCtrl.clear(); _codeCtrl.clear(); _inviteCtrl.clear(); _showInvite = false; });
ref.read(authProvider.notifier).logout();
} else {
_goHome();
}
}
Future<void> _login() async {
if (!_agreed) { setState(() => _error = '请阅读并同意服务协议和隐私政策'); return; }
setState(() { _loading = true; _error = null; });
final err = await ref.read(authProvider.notifier).login(_phoneCtrl.text.trim(), _codeCtrl.text.trim());
setState(() => _loading = false);
if (err != null) { setState(() => _error = err); return; }
void _goHome() {
ref.invalidate(latestHealthProvider);
ref.invalidate(medicationListProvider);
ref.invalidate(medicationReminderProvider);
ref.invalidate(currentExercisePlanProvider);
goRoute(ref, 'home');
final role = ref.read(authProvider).user?.role ?? 'User';
goRoute(ref, role == 'Doctor' ? 'doctorHome' : 'home');
}
@override Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
if (authState.isLoggedIn && !authState.isLoading) {
WidgetsBinding.instance.addPostFrameCallback((_) => goRoute(ref, 'home'));
WidgetsBinding.instance.addPostFrameCallback((_) => _goHome());
}
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFEEF2FF), Color(0xFFF5F3FF)],
),
),
child: SafeArea(child: Center(child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(mainAxisSize: MainAxisSize.min, children: [
const SizedBox(height: 40),
// Logo
Container(
width: 88, height: 88,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(44),
boxShadow: [BoxShadow(color: AppColors.primary.withOpacity(0.15), blurRadius: 20, offset: const Offset(0, 8))],
),
child: const Icon(LucideIcons.heart, size: 40, color: AppColors.primary),
),
const SizedBox(height: 20),
const Text('健康管家', style: TextStyle(fontSize: 28, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
const SizedBox(height: 6),
const Text('你的 AI 心脏健康管家', style: TextStyle(fontSize: 15, color: AppColors.textSecondary)),
const SizedBox(height: 40),
backgroundColor: Colors.white,
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(mainAxisSize: MainAxisSize.min, children: [
const SizedBox(height: 30),
Icon(Icons.favorite, size: 48, color: _accent),
const SizedBox(height: 12),
const Text('健康管家', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
const SizedBox(height: 4),
Text(_isLogin ? '登录你的账号' : '创建新账号', style: const TextStyle(fontSize: 14, color: AppColors.textSecondary)),
const SizedBox(height: 32),
// 手机号
Container(
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.all(1.5),
child: Container(
height: 52,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(9),
),
child: Row(children: [
const Padding(padding: EdgeInsets.only(left: 16), child: Text('+86', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: AppColors.textPrimary))),
Container(width: 1, height: 24, color: AppColors.border, margin: const EdgeInsets.symmetric(horizontal: 12)),
Expanded(
child: TextField(
controller: _phoneCtrl,
keyboardType: TextInputType.phone,
maxLength: 11,
style: const TextStyle(fontSize: 16, color: AppColors.textPrimary),
decoration: const InputDecoration(
filled: false,
hintText: '请输入手机号',
hintStyle: TextStyle(fontSize: 15, color: AppColors.textHint),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
counterText: '',
contentPadding: EdgeInsets.symmetric(vertical: 14),
),
),
if (_successMsg != null)
Padding(padding: const EdgeInsets.only(bottom: 16), child: Container(padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: const Color(0xFFD1FAE5), borderRadius: BorderRadius.circular(10)), child: Row(mainAxisSize: MainAxisSize.min, children: [const Icon(Icons.check_circle, color: Color(0xFF059669), size: 18), const SizedBox(width: 8), Text(_successMsg!, style: const TextStyle(color: Color(0xFF059669), fontSize: 14))]))),
// 角色选择(注册模式)
if (!_isLogin) ...[
Row(children: [
Expanded(child: _RoleCard(selected: _role == 'User', icon: Icons.person, label: '用户', desc: '健康管理', color: AppColors.primary, onTap: () => setState(() { _role = 'User'; _showInvite = false; }))),
const SizedBox(width: 12),
Expanded(child: _RoleCard(selected: _role == 'Doctor', icon: Icons.local_hospital, label: '医生', desc: '接诊管理', color: _doctorBlue, onTap: () => setState(() { _role = 'Doctor'; _showInvite = true; }))),
]),
const SizedBox(height: 20),
],
// 手机号
_TextField(_phoneCtrl, '手机号', TextInputType.phone, 11, _accent),
const SizedBox(height: 12),
// 验证码
Row(children: [
Expanded(child: _TextField(_codeCtrl, '验证码', TextInputType.number, 6, _accent)),
const SizedBox(width: 12),
GestureDetector(
onTap: (_countdown > 0 || _sending) ? null : _sendSms,
child: Container(
width: 110, height: 50, alignment: Alignment.center,
decoration: BoxDecoration(color: _countdown > 0 || _sending ? const Color(0xFFF5F5F5) : _accent, borderRadius: BorderRadius.circular(10)),
child: Text(_sending ? '发送中' : _countdown > 0 ? '${_countdown}s' : '获取验证码', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: (_countdown > 0 || _sending) ? _accent : Colors.white)),
),
),
]),
if (_showInvite && !_isLogin) ...[const SizedBox(height: 12), _TextField(_inviteCtrl, '医生审核码', TextInputType.number, 6, _accent)],
const SizedBox(height: 14),
// 协议
GestureDetector(
onTap: () => setState(() => _agreed = !_agreed),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Container(width: 18, height: 18, margin: const EdgeInsets.only(right: 6), decoration: BoxDecoration(color: _agreed ? _accent : Colors.white, borderRadius: BorderRadius.circular(4), border: Border.all(color: _agreed ? _accent : AppColors.border)), child: _agreed ? const Icon(Icons.check, size: 14, color: Colors.white) : null),
RichText(text: TextSpan(children: [const TextSpan(text: '已阅读并同意', style: TextStyle(fontSize: 13, color: AppColors.textHint)), TextSpan(text: '《服务协议》', style: TextStyle(fontSize: 13, color: _accent)), const TextSpan(text: '', style: TextStyle(fontSize: 13, color: AppColors.textHint)), TextSpan(text: '《隐私政策》', style: TextStyle(fontSize: 13, color: _accent))])),
]),
),
),
const SizedBox(height: 16),
// 验证码
Row(children: [
Expanded(
child: Container(
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.all(1.5),
child: Container(
height: 52,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(9),
),
child: TextField(
controller: _codeCtrl,
keyboardType: TextInputType.number,
maxLength: 6,
style: const TextStyle(fontSize: 16, color: AppColors.textPrimary),
decoration: const InputDecoration(
filled: false,
hintText: '验证码',
hintStyle: TextStyle(fontSize: 15, color: AppColors.textHint),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
counterText: '',
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
),
),
),
),
const SizedBox(width: 12),
if (_error != null) Padding(padding: const EdgeInsets.only(top: 12), child: Text(_error!, style: const TextStyle(color: AppColors.error, fontSize: 14))),
const SizedBox(height: 24),
// 提交按钮
GestureDetector(
onTap: (_countdown > 0 || _sending) ? null : _sendSms,
onTap: _loading ? null : _submit,
child: Container(
width: 120, height: 52,
alignment: Alignment.center,
decoration: BoxDecoration(
color: (_countdown > 0 || _sending) ? Colors.white : AppColors.primary,
borderRadius: BorderRadius.circular(10),
),
child: Text(
_sending ? '发送中' : _countdown > 0 ? '${_countdown}s' : '获取验证码',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500,
color: (_countdown > 0 || _sending) ? AppColors.textHint : Colors.white),
),
width: double.infinity, height: 52, alignment: Alignment.center,
decoration: BoxDecoration(color: _accent, borderRadius: BorderRadius.circular(12)),
child: _loading ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white)) : Text(_isLogin ? '登 录' : '注 册', style: const TextStyle(fontSize: 18, color: Colors.white, fontWeight: FontWeight.w600, letterSpacing: 4)),
),
),
const SizedBox(height: 16),
GestureDetector(
onTap: () => setState(() { _isLogin = !_isLogin; _error = null; _successMsg = null; if (_isLogin) _showInvite = false; }),
child: Text(_isLogin ? '没有账号?去注册' : '已有账号?去登录', style: TextStyle(fontSize: 14, color: _accent)),
),
const SizedBox(height: 30),
]),
const SizedBox(height: 12),
// 协议
GestureDetector(
onTap: () => setState(() => _agreed = !_agreed),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 18, height: 18, margin: const EdgeInsets.only(right: 6),
decoration: BoxDecoration(
color: _agreed ? AppColors.primary : Colors.white,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: _agreed ? AppColors.primary : AppColors.border, width: 1.5),
),
child: _agreed ? const Icon(Icons.check, size: 14, color: Colors.white) : null,
),
RichText(text: TextSpan(children: [
TextSpan(text: '已阅读并同意', style: TextStyle(fontSize: 13, color: AppColors.textHint)),
TextSpan(text: '《服务协议》', style: TextStyle(fontSize: 13, color: AppColors.primary)),
TextSpan(text: '', style: TextStyle(fontSize: 13, color: AppColors.textHint)),
TextSpan(text: '《隐私政策》', style: TextStyle(fontSize: 13, color: AppColors.primary)),
])),
]),
),
if (_error != null)
Padding(padding: const EdgeInsets.only(top: 12), child: Text(_error!, style: const TextStyle(color: AppColors.error, fontSize: 14))),
const SizedBox(height: 32),
// 登录按钮
GestureDetector(
onTap: _loading ? null : _login,
child: Container(
width: double.infinity, height: 52, alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.primary, width: 1.5),
),
child: _loading
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2.5, color: AppColors.primary))
: const Text('登 录', style: TextStyle(fontSize: 18, color: AppColors.primary, fontWeight: FontWeight.w600, letterSpacing: 2)),
),
),
const SizedBox(height: 40),
]),
))),
),
),
),
);
}
}
class _TextField extends StatelessWidget {
final TextEditingController ctrl; final String hint; final TextInputType type; final int maxLen; final Color accent;
const _TextField(this.ctrl, this.hint, this.type, this.maxLen, this.accent);
@override Widget build(BuildContext context) => Container(
height: 50, decoration: BoxDecoration(color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(10)),
child: TextField(
controller: ctrl, keyboardType: type, maxLength: maxLen,
style: const TextStyle(fontSize: 16, color: AppColors.textPrimary),
decoration: InputDecoration(hintText: hint, hintStyle: const TextStyle(fontSize: 15, color: AppColors.textHint), border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, counterText: '', contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13)),
),
);
}
class _RoleCard extends StatelessWidget {
final bool selected; final IconData icon; final String label, desc; final Color color; final VoidCallback onTap;
const _RoleCard({required this.selected, required this.icon, required this.label, required this.desc, required this.color, required this.onTap});
@override Widget build(BuildContext context) => GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
padding: const EdgeInsets.symmetric(vertical: 18),
decoration: BoxDecoration(color: selected ? color : Colors.white, borderRadius: BorderRadius.circular(14), border: Border.all(color: selected ? color : const Color(0xFFE5E7EB), width: 1.5)),
child: Column(children: [Icon(icon, size: 32, color: selected ? Colors.white : AppColors.textHint), const SizedBox(height: 6), Text(label, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: selected ? Colors.white : AppColors.textPrimary)), Text(desc, style: TextStyle(fontSize: 12, color: selected ? Colors.white70 : AppColors.textHint))]),
),
);
}

View File

@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
final _consListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/doctor/consultations');
return (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
});
class DoctorConsultationsPage extends ConsumerWidget {
const DoctorConsultationsPage({super.key});
@override Widget build(BuildContext context, WidgetRef ref) {
final list = ref.watch(_consListProvider);
return list.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => const Center(child: Text('加载失败')),
data: (items) => items.isEmpty
? const Center(child: Text('暂无问诊', style: TextStyle(color: AppColors.textHint)))
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: items.length,
itemBuilder: (_, i) {
final c = items[i];
final status = c['status'] ?? '';
final msg = c['lastMessage'] as Map<String, dynamic>?;
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(radius: 22, backgroundColor: const Color(0xFFF0F0FF), child: Text((c['patientName'] ?? '?')[0], style: const TextStyle(color: AppColors.primary))),
title: Text(c['patientName'] ?? c['patientPhone'] ?? '', style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(msg?['content'] ?? '暂无消息', maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: _StatusBadge(status),
onTap: () => pushRoute(ref, 'doctorChat', params: {'id': c['id']?.toString() ?? ''}),
),
);
},
),
);
}
}
class _StatusBadge extends StatelessWidget {
final String status;
const _StatusBadge(this.status);
@override Widget build(BuildContext context) {
final (label, color) = switch (status) {
'AiTalking' => ('AI中', const Color(0xFF6366F1)),
'WaitingDoctor' => ('等待医生', const Color(0xFFF59E0B)),
'DoctorReplied' => ('已回复', const Color(0xFF10B981)),
'Closed' => ('已关闭', const Color(0xFF9CA3AF)),
_ => (status, const Color(0xFF9CA3AF)),
};
return Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(8)), child: Text(label, style: TextStyle(fontSize: 12, color: color, fontWeight: FontWeight.w500)));
}
}

View File

@@ -0,0 +1,196 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../providers/auth_provider.dart';
import '../doctor/doctor_home_page.dart' show doctorPageProvider;
final _dashboardProvider = FutureProvider<Map<String, dynamic>?>((ref) async {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/doctor/dashboard');
return res.data['data'] as Map<String, dynamic>?;
});
class DoctorDashboardPage extends ConsumerWidget {
const DoctorDashboardPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final dash = ref.watch(_dashboardProvider);
final hasProfile = dash is AsyncData && dash.value != null;
return RefreshIndicator(
onRefresh: () => ref.refresh(_dashboardProvider.future),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
if (!hasProfile)
Container(
padding: const EdgeInsets.all(14),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(color: const Color(0xFFE0F2FE), borderRadius: BorderRadius.circular(12)),
child: Row(children: [
const Icon(Icons.info_outline, color: Color(0xFF0891B2), size: 20),
const SizedBox(width: 10),
const Expanded(child: Text('请完善个人信息', style: TextStyle(color: Color(0xFF0891B2), fontSize: 14))),
GestureDetector(
onTap: () => {},
child: const Text('去完善', style: TextStyle(color: Color(0xFF0891B2), fontWeight: FontWeight.w600)),
),
]),
),
dash.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => const Center(child: Text('加载失败')),
data: (data) => _buildContent(context, ref, data),
),
],
),
);
}
Widget _buildContent(BuildContext context, WidgetRef ref, Map<String, dynamic>? data) {
final stats = data?['stats'] as Map<String, dynamic>? ?? {};
return Column(children: [
// 统计卡片
Row(children: [
Expanded(child: _StatCard('患者总数', '${stats['totalPatients'] ?? 0}', Icons.people, const Color(0xFF3B82F6), () {
ref.read(doctorPageProvider.notifier).set('patients');
})),
const SizedBox(width: 10),
Expanded(child: _StatCard('进行中问诊', '${stats['activeConsultations'] ?? 0}', Icons.chat, const Color(0xFF10B981), () {
ref.read(doctorPageProvider.notifier).set('consultations');
})),
]),
const SizedBox(height: 10),
Row(children: [
Expanded(child: _StatCard('待审核报告', '${stats['pendingReports'] ?? 0}', Icons.description, const Color(0xFFF59E0B), () {
ref.read(doctorPageProvider.notifier).set('reports');
})),
const SizedBox(width: 10),
Expanded(child: _StatCard('今日随访', '${stats['todayFollowUps'] ?? 0}', Icons.event_note, const Color(0xFFEF4444), () {
ref.read(doctorPageProvider.notifier).set('followups');
})),
]),
const SizedBox(height: 20),
// 待办:未回复问诊
_TodoSection(
title: '待回复问诊',
icon: Icons.chat_outlined,
color: const Color(0xFF10B981),
items: (data?['pendingConsultations'] as List?)?.cast<Map<String, dynamic>>() ?? [],
itemLabel: (m) => m['patientName'] ?? '',
itemSubtitle: (m) => '待回复',
onTap: (m) {
ref.read(doctorPageProvider.notifier).set('consultations');
},
),
// 待办:待审核报告
_TodoSection(
title: '待审核报告',
icon: Icons.description_outlined,
color: const Color(0xFFF59E0B),
items: (data?['pendingReports'] as List?)?.cast<Map<String, dynamic>>() ?? [],
itemLabel: (m) => '${m['patientName'] ?? ''}',
itemSubtitle: (m) => '${m['category'] ?? ''}',
onTap: (m) {
ref.read(doctorPageProvider.notifier).set('reports');
},
),
// 今日随访
_TodoSection(
title: '今日随访',
icon: Icons.event_note_outlined,
color: const Color(0xFFEF4444),
items: (data?['todayFollowUps'] as List?)?.cast<Map<String, dynamic>>() ?? [],
itemLabel: (m) => '${m['title'] ?? ''}',
itemSubtitle: (m) => '${m['patientName'] ?? ''}',
onTap: (m) {
ref.read(doctorPageProvider.notifier).set('followups');
},
),
]);
}
}
class _StatCard extends StatelessWidget {
final String label;
final String value;
final IconData icon;
final Color color;
final VoidCallback onTap;
const _StatCard(this.label, this.value, this.icon, this.color, this.onTap);
@override Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
child: Row(children: [
Container(
width: 40, height: 40,
decoration: BoxDecoration(color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(10)),
child: Icon(icon, color: color, size: 20),
),
const SizedBox(width: 12),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(value, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textHint)),
]),
]),
),
);
}
}
class _TodoSection extends StatelessWidget {
final String title;
final IconData icon;
final Color color;
final List<Map<String, dynamic>> items;
final String Function(Map<String, dynamic>) itemLabel;
final String Function(Map<String, dynamic>) itemSubtitle;
final void Function(Map<String, dynamic>) onTap;
const _TodoSection({required this.title, required this.icon, required this.color, required this.items, required this.itemLabel, required this.itemSubtitle, required this.onTap});
@override Widget build(BuildContext context) {
if (items.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: 6),
Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
const Spacer(),
Text('${items.length}', style: const TextStyle(fontSize: 12, color: AppColors.textHint)),
]),
const SizedBox(height: 8),
...items.take(5).map((m) => GestureDetector(
onTap: () => onTap(m),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: const BoxDecoration(border: Border(top: BorderSide(color: Color(0xFFF5F5F5)))),
child: Row(children: [
Expanded(child: Text(itemLabel(m), style: const TextStyle(fontSize: 14, color: AppColors.textPrimary))),
Text(itemSubtitle(m), style: const TextStyle(fontSize: 12, color: AppColors.textHint)),
const SizedBox(width: 4),
const Icon(Icons.chevron_right, size: 16, color: AppColors.textHint),
]),
),
)),
]),
),
);
}
}

View File

@@ -0,0 +1,159 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
final _ptsSimple = FutureProvider<List<Map<String, dynamic>>>((ref) async {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/doctor/patients-simple');
return (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
});
class DoctorFollowUpEditPage extends ConsumerStatefulWidget {
final String? id;
const DoctorFollowUpEditPage({super.key, this.id});
@override ConsumerState<DoctorFollowUpEditPage> createState() => _DoctorFollowUpEditPageState();
}
class _DoctorFollowUpEditPageState extends ConsumerState<DoctorFollowUpEditPage> {
final _titleCtrl = TextEditingController();
final _notesCtrl = TextEditingController();
String? _pid;
DateTime _date = DateTime.now().add(const Duration(days: 7));
TimeOfDay _time = const TimeOfDay(hour: 9, minute: 0);
bool _saving = false;
bool _loaded = false;
bool get isEdit => widget.id != null && widget.id!.isNotEmpty;
@override void dispose() { _titleCtrl.dispose(); _notesCtrl.dispose(); super.dispose(); }
@override Widget build(BuildContext context) {
if (isEdit && !_loaded) {
ref.watch(_fupDetailForEdit(widget.id!)).whenData((d) {
if (d != null) {
_loaded = true;
_titleCtrl.text = d['title'] ?? '';
_notesCtrl.text = d['notes'] ?? '';
_pid = d['userId']?.toString();
final at = DateTime.tryParse(d['scheduledAt']?.toString() ?? '');
if (at != null) { _date = at; _time = TimeOfDay.fromDateTime(at); }
}
});
}
final pts = ref.watch(_ptsSimple);
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: AppBar(
backgroundColor: Colors.white, elevation: 0,
title: Text(isEdit ? '编辑随访' : '新建随访', style: const TextStyle(color: AppColors.textPrimary)),
leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref)),
),
body: ListView(padding: const EdgeInsets.all(16), children: [
const Text('患者', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
pts.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('加载失败'),
data: (list) => _dropdown(list),
),
const SizedBox(height: 16),
_input('随访标题', _titleCtrl, hint: '例:术后一个月复查'),
const SizedBox(height: 16),
const Text('随访时间', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
_dateTimePicker(),
const SizedBox(height: 16),
_input('备注', _notesCtrl, hint: '随访备注(可选)', maxLines: 4),
const SizedBox(height: 28),
_submitBtn(),
]),
);
}
Widget _dropdown(List<Map<String, dynamic>> list) => Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
child: DropdownButtonHideUnderline(
child: DropdownButton<String?>(
isExpanded: true,
value: _pid,
hint: const Text('选择患者', style: TextStyle(color: AppColors.textHint)),
items: [
const DropdownMenuItem<String?>(value: null, child: Text('选择患者', style: TextStyle(color: AppColors.textHint))),
...list.map((p) => DropdownMenuItem<String?>(value: p['id']?.toString(), child: Text('${p['name'] ?? p['phone']} (${p['phone']})', style: const TextStyle(fontSize: 15)))),
],
onChanged: (v) => setState(() { _pid = v; }),
),
),
);
Widget _input(String label, TextEditingController ctrl, {String? hint, int maxLines = 1}) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
child: TextField(controller: ctrl, maxLines: maxLines, decoration: InputDecoration(hintText: hint, border: InputBorder.none)),
),
]);
Widget _dateTimePicker() => Row(children: [
Expanded(child: GestureDetector(
onTap: () async {
final d = await showDatePicker(context: context, initialDate: _date, firstDate: DateTime.now(), lastDate: DateTime.now().add(const Duration(days: 365)));
if (d != null) setState(() => _date = d);
},
child: _pickerBox('${_date.year}-${_date.month.toString().padLeft(2, '0')}-${_date.day.toString().padLeft(2, '0')}'),
)),
const SizedBox(width: 12),
Expanded(child: GestureDetector(
onTap: () async {
final t = await showTimePicker(context: context, initialTime: _time);
if (t != null) setState(() => _time = t);
},
child: _pickerBox('${_time.hour.toString().padLeft(2, '0')}:${_time.minute.toString().padLeft(2, '0')}'),
)),
]);
Widget _pickerBox(String text) => Container(
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
child: Center(child: Text(text, style: const TextStyle(fontSize: 15))),
);
Widget _submitBtn() => SizedBox(width: double.infinity, child: ElevatedButton(
onPressed: _saving ? null : _save,
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF0891B2), foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))),
child: _saving ? const CircularProgressIndicator(color: Colors.white) : Text(isEdit ? '保存修改' : '创建随访', style: const TextStyle(fontSize: 16)),
));
Future<void> _save() async {
if (_pid == null) { _snack('请选择患者'); return; }
if (_titleCtrl.text.trim().isEmpty) { _snack('请输入随访标题'); return; }
setState(() => _saving = true);
final at = DateTime(_date.year, _date.month, _date.day, _time.hour, _time.minute);
try {
final api = ref.read(apiClientProvider);
final data = {'userId': _pid, 'title': _titleCtrl.text.trim(), 'scheduledAt': at.toIso8601String(), 'notes': _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim()};
if (isEdit) { await api.put('/api/doctor/follow-ups/${widget.id}', data: data); }
else { await api.post('/api/doctor/follow-ups', data: data); }
if (mounted) { _snack(isEdit ? '修改成功' : '创建成功', ok: true); popRoute(ref); }
} catch (e) { if (mounted) _snack('保存失败: $e'); }
finally { if (mounted) setState(() => _saving = false); }
}
void _snack(String msg, {bool ok = false}) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg), backgroundColor: ok ? AppColors.success : AppColors.error));
}
}
final _fupDetailForEdit = FutureProvider.family<Map<String, dynamic>?, String>((ref, id) async {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/doctor/follow-ups');
final items = (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
return items.cast<Map<String, dynamic>?>().firstWhere((f) => f?['id']?.toString() == id, orElse: () => null);
});

View File

@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
final _fupRefresh = FutureProvider<String?>((ref) async {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/doctor/follow-ups');
final items = (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
ref.read(_fupList.notifier).replace(items);
return null;
});
final _fupList = NotifierProvider<FupListN, List<Map<String, dynamic>>>(FupListN.new);
class FupListN extends Notifier<List<Map<String, dynamic>>> {
@override List<Map<String, dynamic>> build() => [];
void replace(List<Map<String, dynamic>> v) => state = v;
void markDone(String id) => state = state.map((f) => f['id'] == id ? {...f, 'status': 'Completed'} : f).toList();
void remove(String id) => state = state.where((f) => f['id'] != id).toList();
}
class DoctorFollowupsPage extends ConsumerStatefulWidget {
const DoctorFollowupsPage({super.key});
@override ConsumerState<DoctorFollowupsPage> createState() => _DoctorFollowupsPageState();
}
class _DoctorFollowupsPageState extends ConsumerState<DoctorFollowupsPage> {
String _filter = '';
@override void initState() { super.initState(); Future.microtask(() => ref.read(_fupRefresh)); }
@override Widget build(BuildContext context) {
var items = ref.watch(_fupList);
if (_filter == 'Upcoming') items = items.where((f) => f['status'] == 'Upcoming').toList();
else if (_filter == 'Completed') items = items.where((f) => f['status'] == 'Completed').toList();
return Column(children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [
_Filt('全部', _filter == '', () => setState(() => _filter = '')),
const SizedBox(width: 8),
_Filt('待完成', _filter == 'Upcoming', () => setState(() => _filter = 'Upcoming')),
const SizedBox(width: 8),
_Filt('已完成', _filter == 'Completed', () => setState(() => _filter = 'Completed')),
const Spacer(),
IconButton(icon: const Icon(Icons.add, color: AppColors.primary), onPressed: () => pushRoute(ref, 'doctorFollowUpEdit')),
]),
),
Expanded(
child: RefreshIndicator(
onRefresh: () => ref.refresh(_fupRefresh.future),
child: items.isEmpty
? ListView(children: const [SizedBox(height: 100), Center(child: Text('暂无随访', style: TextStyle(color: AppColors.textHint)))])
: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: items.length,
itemBuilder: (_, i) {
final f = items[i];
final upcoming = f['status'] == 'Upcoming';
return Container(
margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Expanded(child: Text(f['title'] ?? '', style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15))),
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: (upcoming ? const Color(0xFFF59E0B) : const Color(0xFF10B981)).withOpacity(0.1), borderRadius: BorderRadius.circular(8)), child: Text(upcoming ? '待完成' : '已完成', style: TextStyle(fontSize: 12, color: upcoming ? const Color(0xFFF59E0B) : const Color(0xFF10B981)))),
]),
const SizedBox(height: 4),
Text('${f['patientName'] ?? ''} · ${(f['scheduledAt'] ?? '').toString().substring(0, 16)}', style: const TextStyle(fontSize: 13, color: AppColors.textHint)),
if (f['notes'] != null) Padding(padding: const EdgeInsets.only(top: 4), child: Text(f['notes']!, style: const TextStyle(fontSize: 13, color: AppColors.textSecondary))),
if (upcoming) ...[
const SizedBox(height: 8),
Row(mainAxisAlignment: MainAxisAlignment.end, children: [
TextButton(onPressed: () => pushRoute(ref, 'doctorFollowUpEdit', params: {'id': f['id']?.toString() ?? ''}), child: const Text('编辑', style: TextStyle(fontSize: 13))),
TextButton(
onPressed: () async {
await ref.read(apiClientProvider).put('/api/doctor/follow-ups/${f['id']}', data: {'status': 'Completed'});
ref.read(_fupList.notifier).markDone(f['id']?.toString() ?? '');
},
child: const Text('完成', style: TextStyle(fontSize: 13, color: Color(0xFF10B981))),
),
TextButton(
onPressed: () async {
await ref.read(apiClientProvider).delete('/api/doctor/follow-ups/${f['id']}');
ref.read(_fupList.notifier).remove(f['id']?.toString() ?? '');
},
child: const Text('删除', style: TextStyle(fontSize: 13, color: AppColors.error)),
),
]),
],
]),
);
},
),
),
),
]);
}
}
class _Filt extends StatelessWidget {
final String label; final bool active; final VoidCallback onTap;
const _Filt(this.label, this.active, this.onTap);
@override Widget build(BuildContext context) => GestureDetector(
onTap: onTap,
child: Container(padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), decoration: BoxDecoration(color: active ? AppColors.primary : Colors.white, borderRadius: BorderRadius.circular(20), border: Border.all(color: active ? AppColors.primary : const Color(0xFFE5E7EB))), child: Text(label, style: TextStyle(fontSize: 13, color: active ? Colors.white : AppColors.textSecondary))),
);
}

View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/navigation_provider.dart';
import '../../widgets/doctor_drawer.dart';
import 'doctor_dashboard_page.dart';
import 'doctor_patients_page.dart';
import 'doctor_consultations_page.dart';
import 'doctor_reports_page.dart';
import 'doctor_followups_page.dart';
class DoctorHomePage extends ConsumerWidget {
const DoctorHomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final page = ref.watch(doctorPageProvider);
return PopScope(
canPop: page == 'dashboard',
onPopInvokedWithResult: (didPop, _) {
if (!didPop) ref.read(doctorPageProvider.notifier).set('dashboard');
},
child: Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
title: Text(_titleFor(page), style: const TextStyle(color: AppColors.textPrimary)),
leading: Builder(
builder: (ctx) => IconButton(
icon: const Icon(Icons.menu, color: AppColors.textPrimary),
onPressed: () => Scaffold.of(ctx).openDrawer(),
),
),
),
drawer: const DoctorDrawer(),
body: _bodyFor(page),
),
);
}
Widget _bodyFor(String page) => switch (page) {
'dashboard' => const DoctorDashboardPage(),
'patients' => const DoctorPatientsPage(),
'consultations' => const DoctorConsultationsPage(),
'reports' => const DoctorReportsPage(),
'followups' => const DoctorFollowupsPage(),
_ => const DoctorDashboardPage(),
};
String _titleFor(String page) => switch (page) {
'dashboard' => '工作台',
'patients' => '患者管理',
'consultations' => '问诊列表',
'reports' => '报告审核',
'followups' => '复查随访',
_ => '工作台',
};
}
final doctorPageProvider = NotifierProvider<DoctorPageNotifier, String>(DoctorPageNotifier.new);
class DoctorPageNotifier extends Notifier<String> {
@override String build() => 'dashboard';
void set(String page) => state = page;
}

View File

@@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
final _patientDetailProvider = FutureProvider.family<Map<String, dynamic>?, String>((ref, id) async {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/doctor/patients/$id');
return res.data['data'] as Map<String, dynamic>?;
});
class DoctorPatientDetailPage extends ConsumerWidget {
final String id;
const DoctorPatientDetailPage({super.key, required this.id});
@override Widget build(BuildContext context, WidgetRef ref) {
final detail = ref.watch(_patientDetailProvider(id));
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: AppBar(backgroundColor: Colors.white, elevation: 0, title: const Text('患者详情', style: TextStyle(color: AppColors.textPrimary)),
leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref)),
),
body: detail.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => const Center(child: Text('加载失败')),
data: (data) => data == null ? const Center(child: Text('患者不存在')) : _buildBody(data),
),
);
}
Widget _buildBody(Map<String, dynamic> data) {
final profile = data['profile'] as Map<String, dynamic>? ?? {};
final archive = data['archive'] as Map<String, dynamic>?;
final latest = data['latestRecords'] as List? ?? [];
final meds = data['medications'] as List? ?? [];
final reports = data['reports'] as List? ?? [];
final followUps = data['followUps'] as List? ?? [];
final trends = data['trendRecords'] as List? ?? [];
final diet = data['dietRecords'] as List? ?? [];
final exercise = data['exercisePlan'] as Map<String, dynamic>?;
return ListView(padding: const EdgeInsets.all(16), children: [
// 基本信息卡片
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
child: Row(children: [
CircleAvatar(radius: 28, backgroundColor: const Color(0xFFF0F0FF), child: Text((profile['name'] ?? '')[0], style: const TextStyle(fontSize: 22, color: AppColors.primary))),
const SizedBox(width: 14),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(profile['name'] ?? '未设置', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
Text(profile['phone'] ?? '', style: const TextStyle(fontSize: 13, color: AppColors.textHint)),
if (profile['gender'] != null) Text('${profile['gender']} · ${profile['birthDate'] ?? ''}', style: const TextStyle(fontSize: 12, color: AppColors.textHint)),
])),
]),
),
const SizedBox(height: 12),
// 健康档案
if (archive != null) ...[
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('健康档案', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
if (archive['diagnosis'] != null) _row('诊断', archive['diagnosis']),
if (archive['surgeryType'] != null) _row('手术史', '${archive['surgeryType']} ${archive['surgeryDate'] ?? ''}'),
if (archive['allergies'] != null && (archive['allergies'] as List).isNotEmpty) _row('过敏史', (archive['allergies'] as List).join('')),
if (archive['chronicDiseases'] != null && (archive['chronicDiseases'] as List).isNotEmpty) _row('慢病', (archive['chronicDiseases'] as List).join('')),
if (archive['familyHistory'] != null) _row('家族史', archive['familyHistory']),
]),
),
const SizedBox(height: 12),
],
// 最新健康指标
if (latest.isNotEmpty) ...[
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('健康指标', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
...latest.map((r) {
final type = r['metricType']?.toString() ?? '';
String val = '';
if (type == 'BloodPressure') val = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'} mmHg';
else val = '${r['value'] ?? '--'} ${r['unit'] ?? ''}';
return Padding(padding: const EdgeInsets.only(bottom: 4), child: Row(children: [
Text(_metricLabel(type), style: const TextStyle(fontSize: 14, color: AppColors.textHint)),
const Spacer(),
Text(val, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: r['isAbnormal'] == true ? AppColors.error : AppColors.textPrimary)),
]));
}),
]),
),
const SizedBox(height: 12),
],
// 用药列表
if (meds.isNotEmpty)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('当前用药', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
...meds.map((m) => Padding(padding: const EdgeInsets.only(bottom: 4), child: Text('${m['name']} ${m['dosage'] ?? ''} ${_freqLabel(m['frequency'] ?? '')}', style: const TextStyle(fontSize: 14)))),
]),
),
const SizedBox(height: 24),
// 报告和随访入口
Row(children: [
Expanded(child: _LinkCard('报告 (${reports.length})', Icons.description_outlined, () {})),
const SizedBox(width: 12),
Expanded(child: _LinkCard('随访 (${followUps.length})', Icons.event_note_outlined, () {})),
]),
]);
}
Widget _row(String label, String? value) => Padding(padding: const EdgeInsets.only(bottom: 4), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(width: 56, child: Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textHint))),
Expanded(child: Text(value ?? '', style: const TextStyle(fontSize: 14))),
]));
String _metricLabel(String t) => switch (t) { 'BloodPressure' => '血压', 'HeartRate' => '心率', 'Glucose' => '血糖', 'SpO2' => '血氧', 'Weight' => '体重', _ => t };
String _freqLabel(String f) => switch (f) { 'Daily' => '每日', 'Weekly' => '每周', 'Monthly' => '每月', 'AsNeeded' => '按需', _ => f };
}
class _LinkCard extends StatelessWidget {
final String label;
final IconData icon;
final VoidCallback onTap;
const _LinkCard(this.label, this.icon, this.onTap);
@override Widget build(BuildContext context) => GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(icon, size: 18, color: AppColors.primary),
const SizedBox(width: 6),
Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
]),
),
);
}

View File

@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
class DoctorPatientsPage extends ConsumerStatefulWidget {
const DoctorPatientsPage({super.key});
@override ConsumerState<DoctorPatientsPage> createState() => _DoctorPatientsPageState();
}
class _DoctorPatientsPageState extends ConsumerState<DoctorPatientsPage> {
final _searchCtrl = TextEditingController();
List<Map<String, dynamic>> _patients = [];
int _page = 1;
bool _loading = false;
bool _hasMore = true;
int _total = 0;
@override void initState() { super.initState(); _load(); }
@override void dispose() { _searchCtrl.dispose(); super.dispose(); }
Future<void> _load({bool reset = false}) async {
if (_loading) return;
if (reset) { _page = 1; _patients.clear(); }
setState(() => _loading = true);
try {
final api = ref.read(apiClientProvider);
final params = <String, dynamic>{'page': _page, 'pageSize': 20};
final search = _searchCtrl.text.trim();
if (search.isNotEmpty) params['search'] = search;
final res = await api.get('/api/doctor/patients', queryParameters: params);
final data = res.data['data'];
if (data != null) {
final items = (data['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
setState(() {
if (reset) _patients = items; else _patients.addAll(items);
_total = data['total'] ?? 0;
_hasMore = _patients.length < _total;
_page++;
});
}
} catch (_) {} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _onSearch() async { await _load(reset: true); }
Future<void> _loadMore() async { if (_hasMore) await _load(); }
@override Widget build(BuildContext context) {
return Column(children: [
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _searchCtrl,
onSubmitted: (_) => _onSearch(),
decoration: InputDecoration(
hintText: '搜索患者姓名或手机号',
prefixIcon: const Icon(Icons.search, color: AppColors.textHint),
suffixIcon: _searchCtrl.text.isNotEmpty ? IconButton(icon: const Icon(Icons.clear), onPressed: () { _searchCtrl.clear(); _onSearch(); }) : null,
filled: true, fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
),
),
),
Expanded(
child: RefreshIndicator(
onRefresh: () => _load(reset: true),
child: _patients.isEmpty && !_loading
? ListView(children: const [SizedBox(height: 100), Center(child: Text('暂无患者数据', style: TextStyle(color: AppColors.textHint)))])
: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _patients.length + (_hasMore ? 1 : 0),
itemBuilder: (_, i) {
if (i >= _patients.length) {
_loadMore();
return const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator(strokeWidth: 2)));
}
final p = _patients[i];
return _PatientTile(p: p, onTap: () => pushRoute(ref, 'doctorPatientDetail', params: {'id': p['id']?.toString() ?? ''}));
},
),
),
),
]);
}
}
class _PatientTile extends StatelessWidget {
final Map<String, dynamic> p;
final VoidCallback onTap;
const _PatientTile({required this.p, required this.onTap});
@override Widget build(BuildContext context) {
final gender = p['gender'] ?? '';
final genderIcon = gender == 'Male' ? Icons.male : gender == 'Female' ? Icons.female : Icons.person;
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
child: Row(children: [
CircleAvatar(radius: 22, backgroundColor: const Color(0xFFF0F0FF), child: Icon(genderIcon, color: AppColors.primary)),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(p['name'] ?? p['phone'] ?? '', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
Text(p['phone'] ?? '', style: const TextStyle(fontSize: 13, color: AppColors.textHint)),
])),
const Icon(Icons.chevron_right, color: AppColors.textHint),
]),
),
);
}
}

View File

@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
final _docProfileProvider = FutureProvider<Map<String, dynamic>?>((ref) async {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/doctor/profile');
return res.data['data'] as Map<String, dynamic>?;
});
class DoctorProfileEditPage extends ConsumerStatefulWidget {
const DoctorProfileEditPage({super.key});
@override ConsumerState<DoctorProfileEditPage> createState() => _DoctorProfileEditPageState();
}
class _DoctorProfileEditPageState extends ConsumerState<DoctorProfileEditPage> {
final _name = TextEditingController();
final _title = TextEditingController();
final _dept = TextEditingController();
final _hosp = TextEditingController();
bool _saving = false;
@override void dispose() { _name.dispose(); _title.dispose(); _dept.dispose(); _hosp.dispose(); super.dispose(); }
@override Widget build(BuildContext context) {
final prof = ref.watch(_docProfileProvider);
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: AppBar(backgroundColor: Colors.white, elevation: 0, title: const Text('个人信息', style: TextStyle(color: AppColors.textPrimary)), leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref))),
body: prof.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => const Center(child: Text('加载失败')),
data: (d) {
if (d != null) { _name.text = d['name'] ?? ''; _title.text = d['title'] ?? ''; _dept.text = d['department'] ?? ''; _hosp.text = d['hospital'] ?? ''; }
return ListView(padding: const EdgeInsets.all(16), children: [
_Field('姓名', _name),
const SizedBox(height: 12),
_Field('职称', _title),
const SizedBox(height: 12),
_Field('科室', _dept),
const SizedBox(height: 12),
_Field('医院', _hosp),
const SizedBox(height: 24),
SizedBox(width: double.infinity, child: ElevatedButton(
onPressed: _saving ? null : _save,
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF0891B2), foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))),
child: _saving ? const CircularProgressIndicator(color: Colors.white) : const Text('保存', style: TextStyle(fontSize: 16)),
)),
]);
},
),
);
}
Future<void> _save() async {
setState(() => _saving = true);
try {
await ref.read(apiClientProvider).put('/api/doctor/profile', data: {
'name': _name.text, 'title': _title.text, 'department': _dept.text, 'hospital': _hosp.text,
});
ref.invalidate(_docProfileProvider);
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存成功'), backgroundColor: AppColors.success));
} catch (_) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存失败'), backgroundColor: AppColors.error));
} finally {
if (mounted) setState(() => _saving = false);
}
}
}
class _Field extends StatelessWidget {
final String label; final TextEditingController ctrl;
const _Field(this.label, this.ctrl);
@override Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
child: TextField(controller: ctrl, decoration: InputDecoration(labelText: label, border: InputBorder.none)),
);
}

View File

@@ -0,0 +1,273 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
final _reportDetailProvider = FutureProvider.family<Map<String, dynamic>?, String>((ref, id) async {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/doctor/reports/$id');
return res.data['data'] as Map<String, dynamic>?;
});
class DoctorReportDetailPage extends ConsumerStatefulWidget {
final String id;
const DoctorReportDetailPage({super.key, required this.id});
@override ConsumerState<DoctorReportDetailPage> createState() => _DoctorReportDetailPageState();
}
class _DoctorReportDetailPageState extends ConsumerState<DoctorReportDetailPage> {
String _severity = '';
String _comment = '';
String _recommendation = '';
bool _submitting = false;
bool _submitted = false;
static const _severityOptions = ['Normal', 'Abnormal', 'Severe', 'Critical'];
static const _severityLabels = {'Normal': '正常', 'Abnormal': '异常', 'Severe': '严重', 'Critical': '危急'};
static const _severityColors = {'Normal': 0xFF10B981, 'Abnormal': 0xFFF59E0B, 'Severe': 0xFFEF4444, 'Critical': 0xFF991B1B};
static const _templates = ['药物剂量调整', '复查建议', '生活方式干预', '进一步检查', '专科转诊', '无需特殊处理'];
Future<void> _submitReview() async {
if (_severity.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请选择严重程度')));
return;
}
setState(() => _submitting = true);
try {
final api = ref.read(apiClientProvider);
await api.post('/api/doctor/reports/${widget.id}/review', data: {
'severity': _severity,
'comment': _comment,
'recommendation': _recommendation,
});
setState(() => _submitted = true);
ref.invalidate(_reportDetailProvider(widget.id));
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('审核提交成功'), backgroundColor: AppColors.success));
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('提交失败: $e'), backgroundColor: AppColors.error));
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override Widget build(BuildContext context) {
final detail = ref.watch(_reportDetailProvider(widget.id));
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: AppBar(
backgroundColor: Colors.white, elevation: 0,
title: const Text('报告审核', style: TextStyle(color: AppColors.textPrimary)),
leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref)),
),
body: detail.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => const Center(child: Text('加载失败')),
data: (data) => data == null ? const Center(child: Text('报告不存在')) : _buildBody(data),
),
);
}
Widget _buildBody(Map<String, dynamic> data) {
final aiSummary = data['aiSummary'] as String?;
final aiIndicators = _parseIndicators(data['aiIndicators']?.toString());
final reviewed = data['status'] == 'DoctorReviewed';
final doctorComment = data['doctorComment'] as String?;
final doctorRec = data['doctorRecommendation'] as String?;
final severity = data['severity'] as String?;
final fileUrl = data['fileUrl'] as String?;
return ListView(padding: const EdgeInsets.all(16), children: [
// 患者+分类信息
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
child: Row(children: [
CircleAvatar(radius: 24, backgroundColor: const Color(0xFFF0F0FF), child: Text((data['patientName'] ?? '?')[0], style: const TextStyle(color: AppColors.primary))),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(data['patientName'] ?? '', style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
Text('${data['category'] ?? ''} · ${data['fileType'] ?? ''}', style: const TextStyle(fontSize: 13, color: AppColors.textHint)),
])),
_StatusTag(data['status']?.toString() ?? ''),
]),
),
// AI预分析摘要
if (aiSummary != null && aiSummary.isNotEmpty) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [Container(width: 4, height: 16, decoration: BoxDecoration(color: const Color(0xFF6366F1), borderRadius: BorderRadius.circular(2))), const SizedBox(width: 8), const Text('AI 预分析', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600))]),
const SizedBox(height: 8),
Text(aiSummary, style: const TextStyle(fontSize: 14, color: AppColors.textSecondary, height: 1.5)),
]),
),
],
// AI指标表格
if (aiIndicators.isNotEmpty) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('指标检测', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
...aiIndicators.map((ind) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(children: [
Expanded(child: Text(ind['name'] ?? '', style: const TextStyle(fontSize: 14))),
Text('${ind['value'] ?? ''}', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
if (ind['unit'] != null) Text(' ${ind['unit']}', style: const TextStyle(fontSize: 12, color: AppColors.textHint)),
const SizedBox(width: 8),
if (ind['status'] != null)
Container(padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration(color: _indicatorColor(ind['status']!).withOpacity(0.1), borderRadius: BorderRadius.circular(4)), child: Text(ind['status']!, style: TextStyle(fontSize: 11, color: _indicatorColor(ind['status']!)))),
]),
)),
]),
),
],
// 查看原始报告
if (fileUrl != null && fileUrl.isNotEmpty) ...[
const SizedBox(height: 12),
SizedBox(width: double.infinity, child: OutlinedButton.icon(
onPressed: () {}, // TODO: 打开文件查看器
icon: const Icon(Icons.visibility, size: 18),
label: const Text('查看原始报告'),
style: OutlinedButton.styleFrom(foregroundColor: AppColors.primary, side: const BorderSide(color: Color(0xFFE5E7EB)), padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))),
)),
],
// 医生审核区(未审核时显示)
if (!reviewed && !_submitted) ...[
const SizedBox(height: 20),
Container(width: double.infinity, height: 1, color: const Color(0xFFE5E7EB)),
const SizedBox(height: 16),
const Text('医生审核', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
const SizedBox(height: 14),
// 严重程度
const Text('严重程度', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Wrap(spacing: 8, runSpacing: 8, children: _severityOptions.map((s) => GestureDetector(
onTap: () => setState(() => _severity = s),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: _severity == s ? Color(_severityColors[s]!) : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _severity == s ? Color(_severityColors[s]!) : const Color(0xFFE5E7EB)),
),
child: Text(_severityLabels[s]!, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: _severity == s ? Colors.white : AppColors.textSecondary)),
),
)).toList()),
const SizedBox(height: 16),
// 建议模板
const Text('建议模板(可多选)', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Wrap(spacing: 8, runSpacing: 8, children: _templates.map((t) {
final selected = _recommendation.contains(t);
return GestureDetector(
onTap: () {
setState(() {
if (selected) {
_recommendation = _recommendation.replaceAll('$t', '').replaceAll('$t', '').replaceAll(t, '');
} else {
_recommendation = _recommendation.isEmpty ? t : '$_recommendation$t';
}
});
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(color: selected ? const Color(0xFFEFF6FF) : Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: selected ? const Color(0xFF0891B2) : const Color(0xFFE5E7EB))),
child: Text(t, style: TextStyle(fontSize: 13, color: selected ? const Color(0xFF0891B2) : AppColors.textSecondary)),
),
);
}).toList()),
const SizedBox(height: 16),
// 自定义评语
const Text('评语', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
child: TextField(
maxLines: 5,
decoration: const InputDecoration(hintText: '输入审核评语(可选)', hintStyle: TextStyle(fontSize: 14, color: AppColors.textHint), border: InputBorder.none, contentPadding: EdgeInsets.all(16)),
onChanged: (v) => _comment = v,
),
),
const SizedBox(height: 20),
SizedBox(width: double.infinity, child: ElevatedButton(
onPressed: _submitting ? null : _submitReview,
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF0891B2), foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))),
child: _submitting ? const CircularProgressIndicator(color: Colors.white) : const Text('提交审核', style: TextStyle(fontSize: 16)),
)),
],
// 已审核结果
if (reviewed || _submitted) ...[
const SizedBox(height: 20),
Container(width: double.infinity, height: 1, color: const Color(0xFFE5E7EB)),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14), border: Border.all(color: const Color(0xFFD1FAE5))),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [const Icon(Icons.check_circle, color: Color(0xFF10B981), size: 20), const SizedBox(width: 8), const Text('审核已完成', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Color(0xFF10B981)))]),
const SizedBox(height: 12),
if (severity != null) _InfoRow('严重程度', _severityLabels[severity] ?? severity),
if (doctorRec != null && doctorRec.isNotEmpty) _InfoRow('建议', doctorRec),
if (doctorComment != null && doctorComment.isNotEmpty) _InfoRow('评语', doctorComment),
]),
),
],
const SizedBox(height: 24),
]);
}
List<Map<String, String>> _parseIndicators(String? raw) {
if (raw == null || raw.isEmpty) return [];
try {
final list = jsonDecode(raw);
if (list is List) return list.map((e) => Map<String, String>.from(e as Map)).toList();
} catch (_) {}
return [];
}
Color _indicatorColor(String s) => switch (s) {
'正常' || 'normal' => const Color(0xFF10B981),
'偏高' || 'high' => const Color(0xFFEF4444),
'偏低' || 'low' => const Color(0xFFF59E0B),
_ => AppColors.textHint,
};
Widget _InfoRow(String label, String value) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(width: 56, child: Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textHint))),
Expanded(child: Text(value, style: const TextStyle(fontSize: 14))),
]),
);
}
class _StatusTag extends StatelessWidget {
final String s;
const _StatusTag(this.s);
@override Widget build(BuildContext context) {
final (label, color) = s == 'DoctorReviewed' ? ('已审核', const Color(0xFF10B981)) : s == 'PendingDoctor' ? ('待审核', const Color(0xFFF59E0B)) : (s, AppColors.textHint);
return Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(8)), child: Text(label, style: TextStyle(fontSize: 12, color: color)));
}
}

View File

@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
final _reportsProvider = FutureProvider.family<List<Map<String, dynamic>>, String>((ref, status) async {
final api = ref.read(apiClientProvider);
final params = <String, dynamic>{};
if (status.isNotEmpty && status != 'All') params['status'] = status;
final res = await api.get('/api/doctor/reports', queryParameters: params.isNotEmpty ? params : null);
return (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
});
class DoctorReportsPage extends ConsumerStatefulWidget {
const DoctorReportsPage({super.key});
@override ConsumerState<DoctorReportsPage> createState() => _DoctorReportsPageState();
}
class _DoctorReportsPageState extends ConsumerState<DoctorReportsPage> {
String _filter = '';
@override Widget build(BuildContext context) {
final reports = ref.watch(_reportsProvider(_filter));
return Column(children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [
_FilterChip('全部', '', _filter, () => setState(() => _filter = '')),
const SizedBox(width: 8),
_FilterChip('待审核', 'PendingDoctor', _filter, () => setState(() => _filter = 'PendingDoctor')),
const SizedBox(width: 8),
_FilterChip('已审核', 'DoctorReviewed', _filter, () => setState(() => _filter = 'DoctorReviewed')),
]),
),
Expanded(child: reports.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => const Center(child: Text('加载失败')),
data: (items) => items.isEmpty
? const Center(child: Text('暂无报告', style: TextStyle(color: AppColors.textHint)))
: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: items.length,
itemBuilder: (_, i) {
final r = items[i];
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.description, color: AppColors.primary),
title: Text(r['patientName'] ?? '', style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text('${r['category'] ?? ''} · ${r['createdAt'] ?? ''}'.replaceAll('T', ' ').substring(0, 16)),
trailing: const Icon(Icons.chevron_right, color: AppColors.textHint),
onTap: () => pushRoute(ref, 'doctorReportDetail', params: {'id': r['id']?.toString() ?? ''}),
),
);
},
),
)),
]);
}
}
class _FilterChip extends StatelessWidget {
final String label, value, current;
final VoidCallback onTap;
const _FilterChip(this.label, this.value, this.current, this.onTap);
@override Widget build(BuildContext context) {
final active = value == current;
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
decoration: BoxDecoration(color: active ? AppColors.primary : Colors.white, borderRadius: BorderRadius.circular(20), border: Border.all(color: active ? AppColors.primary : const Color(0xFFE5E7EB))),
child: Text(label, style: TextStyle(fontSize: 13, color: active ? Colors.white : AppColors.textSecondary)),
),
);
}
}

View File

@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
class DoctorSettingsPage extends ConsumerWidget {
const DoctorSettingsPage({super.key});
@override Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: AppBar(backgroundColor: Colors.white, elevation: 0, title: const Text('设置', style: TextStyle(color: AppColors.textPrimary)), leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref))),
body: ListView(padding: const EdgeInsets.all(16), children: [
_SettingTile(Icons.notifications_outlined, '推送通知', trailing: Switch(value: true, onChanged: (_) {})),
_SettingTile(Icons.description_outlined, '隐私政策', onTap: () {}),
_SettingTile(Icons.article_outlined, '用户协议', onTap: () {}),
const SizedBox(height: 24),
_SettingTile(Icons.delete_forever_outlined, '删除账号', color: Colors.red, onTap: () => _confirmDelete(context, ref)),
const SizedBox(height: 24),
SizedBox(width: double.infinity, child: ElevatedButton(
onPressed: () async { await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); },
style: ElevatedButton.styleFrom(backgroundColor: Colors.white, foregroundColor: AppColors.error, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14), side: const BorderSide(color: Color(0xFFFECACA))), padding: const EdgeInsets.symmetric(vertical: 14)),
child: const Text('退出登录'),
)),
]),
);
}
void _confirmDelete(BuildContext context, WidgetRef ref) {
showDialog(context: context, builder: (ctx) => AlertDialog(
title: const Text('删除账号'),
content: const Text('此操作将永久删除你的所有数据,包括个人信息、问诊记录等。此操作不可撤销。'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')),
TextButton(
onPressed: () async {
Navigator.pop(ctx);
try {
await ref.read(apiClientProvider).delete('/api/user/account');
await ref.read(authProvider.notifier).logout();
if (context.mounted) goRoute(ref, 'login');
} catch (_) {}
},
child: const Text('确认删除', style: TextStyle(color: Colors.red)),
),
],
));
}
}
class _SettingTile extends StatelessWidget {
final IconData icon; final String label; final Color? color; final Widget? trailing; final VoidCallback? onTap;
const _SettingTile(this.icon, this.label, {this.color, this.trailing, this.onTap});
@override Widget build(BuildContext context) => Container(
margin: const EdgeInsets.only(bottom: 8), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
child: ListTile(leading: Icon(icon, color: color ?? AppColors.textPrimary), title: Text(label, style: TextStyle(fontSize: 15, color: color ?? AppColors.textPrimary)), trailing: trailing ?? (onTap != null ? const Icon(Icons.chevron_right, color: AppColors.textHint) : null), onTap: onTap),
);
}

View File

@@ -92,7 +92,7 @@ class _HomePageState extends ConsumerState<HomePage> {
onTap: () => Scaffold.of(ctx).openDrawer(),
child: const Padding(
padding: EdgeInsets.all(8),
child: Icon(LucideIcons.menu, size: 22, color: AppColors.textPrimary),
child: Icon(LucideIcons.menu, size: 22, color: AppColors.primary),
),
)),
const SizedBox(width: 12),

View File

@@ -904,14 +904,14 @@ class ChatMessagesView extends ConsumerWidget {
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.82),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: isUser ? AppTheme.primary : AppColors.cardBackground,
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: isUser ? null : Border.all(color: AppColors.border, width: 1.5),
border: Border.all(color: isUser ? const Color(0xFFE8E4FF) : AppColors.border, width: isUser ? 1 : 1.5),
boxShadow: isUser ? [] : [BoxShadow(color: AppTheme.primary.withAlpha(12), blurRadius: 10, offset: const Offset(0, 3))],
),
child: Column(
@@ -919,7 +919,7 @@ class ChatMessagesView extends ConsumerWidget {
children: [
// 文字内容
if (isUser)
SelectableText(msg.content, style: const TextStyle(fontSize: 19, color: Colors.white, height: 1.5))
SelectableText(msg.content, style: const TextStyle(fontSize: 19, color: AppColors.textPrimary, height: 1.5))
else
MarkdownBody(
data: _cleanAiText(msg.content),
@@ -1160,11 +1160,11 @@ class ChatMessagesView extends ConsumerWidget {
accent: const Color(0xFFF0A060),
),
ActiveAgent.medication => _AgentColors(
gradient: [const Color(0xFFB8E0D8), const Color(0xFF8ED4C8), const Color(0xFF68B8AC)],
bg: const Color(0xFFF0FAF8),
border: const Color(0xFFD4ECE8),
iconBg: const Color(0xFFE4F6F2),
accent: const Color(0xFF68B8AC),
gradient: [const Color(0xFFB0CCF8), const Color(0xFF7DA8F0), const Color(0xFF4A80E8)],
bg: const Color(0xFFEEF2FF),
border: const Color(0xFFC8D8FA),
iconBg: const Color(0xFFDEE6FC),
accent: const Color(0xFF4A80E8),
),
ActiveAgent.report => _AgentColors(
gradient: [const Color(0xFFD8D0F0), const Color(0xFFC4B8EC), const Color(0xFFA898D8)],
@@ -1359,7 +1359,7 @@ class ChatMessagesView extends ConsumerWidget {
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Container(
width: double.infinity, padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
decoration: const BoxDecoration(gradient: LinearGradient(colors: [Color(0xFFF0ECFF), Color(0xFFEBF4FF)])),
decoration: const BoxDecoration(gradient: AppColors.drawerGradient),
child: Row(children: [
Container(width: 28, height: 28, decoration: BoxDecoration(color: Colors.white.withOpacity(0.6), borderRadius: BorderRadius.circular(8)),
child: const Icon(Icons.health_and_safety, size: 16, color: AppColors.primary)),

View File

@@ -9,7 +9,7 @@ import '../providers/data_providers.dart';
import '../providers/omron_device_provider.dart';
import '../widgets/common_widgets.dart';
/// 饮食记录列表(左滑删除)
/// 饮食记录列表(趋势+日历+胶囊详情+侧滑编辑删除)
class DietRecordListPage extends ConsumerStatefulWidget {
const DietRecordListPage({super.key});
@override ConsumerState<DietRecordListPage> createState() => _DietRecordListPageState();
@@ -17,6 +17,8 @@ class DietRecordListPage extends ConsumerStatefulWidget {
class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
List<Map<String, dynamic>> _data = [];
bool _loading = true;
int _trendDays = 7;
DateTime _selectedDate = DateTime.now();
@override void initState() { super.initState(); _refresh(); }
Future<void> _refresh() async {
@@ -28,55 +30,143 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
if (mounted) setState(() => _data.removeWhere((d) => d['id']?.toString() == id));
}
String _dateKey(DateTime d) => '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
int _dayCalories(DateTime d) {
final key = _dateKey(d);
return _data.where((r) => (r['recordedAt']?.toString() ?? '').startsWith(key)).fold(0, (s, r) => s + ((r['totalCalories'] as num?)?.toInt() ?? 0));
}
List<Map<String, dynamic>> _dayRecords(DateTime d) {
final key = _dateKey(d);
return _data.where((r) => (r['recordedAt']?.toString() ?? '').startsWith(key)).toList();
}
List<double> _trendData() => List.generate(_trendDays, (i) => _dayCalories(DateTime.now().subtract(Duration(days: _trendDays - 1 - i))).toDouble());
@override Widget build(BuildContext context) {
return GradientScaffold(
appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('饮食记录')),
body: Container(
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
child: _loading
? const Center(child: CircularProgressIndicator(color: AppTheme.primary))
: _data.isEmpty
? _empty(context, '饮食记录', '暂无饮食记录,可通过「拍饮食」录入')
: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _data.length,
itemBuilder: (ctx, i) {
final d = _data[i];
final items = (d['foodItems'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final mealNames = {'Breakfast':'早餐','Lunch':'午餐','Dinner':'晚餐','Snack':'加餐'};
final mealLabel = mealNames[d['mealType']?.toString()] ?? d['mealType']?.toString() ?? '';
final mealIcons = {'Breakfast':'🌅','Lunch':'☀️','Dinner':'🌙','Snack':'🍪'};
return SwipeDeleteTile(
key: Key(d['id']?.toString() ?? '$i'),
onDelete: () => _delete(d['id']?.toString() ?? ''),
onTap: () => pushRoute(ref, 'dietDetail', params: {'id': d['id']?.toString() ?? ''}),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd)),
child: Row(children: [
Container(width: 44, height: 44, decoration: BoxDecoration(color: AppColors.iconBg, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Center(child: Text(mealIcons[d['mealType']?.toString()] ?? '🍽️', style: const TextStyle(fontSize: 23)))),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Text(mealLabel, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
const SizedBox(width: 8),
Text('${d['totalCalories'] ?? 0}千卡', style: const TextStyle(fontSize: 16, color: AppTheme.primary)),
]),
const SizedBox(height: 4),
Text(items.map((f) => f['name']).join(' · '), style: const TextStyle(fontSize: 16, color: AppColors.textSecondary), maxLines: 1, overflow: TextOverflow.ellipsis),
])),
Icon(Icons.chevron_right, size: 21, color: AppColors.textHint),
]),
),
);
},
),
),
if (_loading) return Scaffold(backgroundColor: const Color(0xFFF5F5F5), appBar: AppBar(backgroundColor: Colors.white, elevation: 0, title: const Text('饮食记录', style: TextStyle(color: AppColors.textPrimary)), leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref))), body: const Center(child: CircularProgressIndicator()));
final todayRecords = _dayRecords(_selectedDate);
final totalCal = todayRecords.fold(0, (s, r) => s + ((r['totalCalories'] as num?)?.toInt() ?? 0));
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: AppBar(backgroundColor: Colors.white, elevation: 0, title: const Text('饮食记录', style: TextStyle(color: AppColors.textPrimary)), leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref))),
body: ListView(padding: const EdgeInsets.all(16), children: [
// 热量趋势
Container(padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [const Text('热量趋势', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), const Spacer(),
_Toggle('7天', _trendDays == 7, () => setState(() => _trendDays = 7)),
const SizedBox(width: 6), _Toggle('30天', _trendDays == 30, () => setState(() => _trendDays = 30)),
]),
const SizedBox(height: 10), SizedBox(height: 120, child: _TrendChart(_trendData())),
])),
const SizedBox(height: 12),
// 饮食日历
Container(padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('饮食日历', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
SingleChildScrollView(scrollDirection: Axis.horizontal, child: Row(children: List.generate(7, (i) {
final d = DateTime.now().subtract(Duration(days: 6 - i));
final cal = _dayCalories(d);
final isToday = _dateKey(d) == _dateKey(DateTime.now());
final isSel = _dateKey(d) == _dateKey(_selectedDate);
return GestureDetector(onTap: () => setState(() => _selectedDate = d), child: Container(width: 42, margin: const EdgeInsets.symmetric(horizontal: 3), padding: const EdgeInsets.symmetric(vertical: 8), decoration: BoxDecoration(color: isSel ? AppColors.primary : (isToday ? AppColors.primary.withOpacity(0.1) : Colors.white), borderRadius: BorderRadius.circular(12), border: Border.all(color: isSel ? AppColors.primary : (isToday ? AppColors.primary : const Color(0xFFE5E7EB)))), child: Column(children: [
Text(['','','','','','',''][d.weekday-1], style: TextStyle(fontSize: 10, color: isSel ? Colors.white : (isToday ? AppColors.primary : AppColors.textHint))),
Text('${d.day}', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: isSel ? Colors.white : (isToday ? AppColors.primary : AppColors.textPrimary))),
if (cal > 0) Text('$cal', style: TextStyle(fontSize: 9, color: isSel ? Colors.white70 : AppColors.textHint)),
])));
}))),
])),
const SizedBox(height: 12),
// 当日详情
Row(children: [
Text('${_selectedDate.month}${_selectedDate.day}', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
const SizedBox(width: 8), Text('$totalCal 千卡', style: const TextStyle(fontSize: 14, color: AppColors.textHint)),
const Spacer(), Text('${todayRecords.length}', style: const TextStyle(fontSize: 13, color: AppColors.textHint)),
]),
const SizedBox(height: 8),
if (todayRecords.isEmpty) const Padding(padding: EdgeInsets.all(20), child: Center(child: Text('当天暂无记录', style: TextStyle(color: AppColors.textHint)))),
...todayRecords.map((d) {
final items = (d['foodItems'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final mealNames = {'Breakfast': '早餐', 'Lunch': '午餐', 'Dinner': '晚餐', 'Snack': '加餐'};
final mealIcons = {'Breakfast': Icons.wb_sunny, 'Lunch': Icons.wb_sunny_outlined, 'Dinner': Icons.nights_stay, 'Snack': Icons.cookie};
final mealLabel = mealNames[d['mealType']?.toString()] ?? '';
final mealIcon = mealIcons[d['mealType']?.toString()] ?? Icons.restaurant;
final cal = d['totalCalories'] ?? 0;
final id = d['id']?.toString() ?? '';
return Padding(padding: const EdgeInsets.only(bottom: 8), child: ClipRRect(borderRadius: BorderRadius.circular(14), child: _SwipeAction(
onEdit: () => _showEditDialog(id, cal),
onDelete: () => _delete(id),
child: Container(padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), child: Row(children: [
Container(width: 40, height: 40, decoration: BoxDecoration(color: AppColors.iconBg, borderRadius: BorderRadius.circular(12)), child: Icon(mealIcon, size: 20, color: AppColors.primary)),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [Text(mealLabel, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), const SizedBox(width: 8), Text('$cal 千卡', style: const TextStyle(fontSize: 13, color: AppColors.textSecondary))]),
if (items.isNotEmpty) Padding(padding: const EdgeInsets.only(top: 3), child: Text(items.map((f) => f['name']).join(' · '), style: const TextStyle(fontSize: 13, color: AppColors.textHint), maxLines: 1, overflow: TextOverflow.ellipsis)),
])),
])),
)));
}),
]),
);
}
void _showEditDialog(String id, num cal) {
final ctrl = TextEditingController(text: '$cal');
showDialog(context: context, builder: (ctx) => AlertDialog(
title: const Text('修改热量'),
content: TextField(controller: ctrl, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: '热量(千卡)')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')),
TextButton(onPressed: () async {
Navigator.pop(ctx);
await ref.read(dietServiceProvider).updateRecord(id, {'totalCalories': int.tryParse(ctrl.text) ?? 0});
_refresh();
}, child: const Text('保存')),
],
));
}
}
/// 饮食记录详情页
class _Toggle extends StatelessWidget {
final String l; final bool a; final VoidCallback t;
const _Toggle(this.l, this.a, this.t);
@override Widget build(BuildContext c) => GestureDetector(onTap: t, child: Container(padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration(color: a ? AppColors.primary : Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: a ? AppColors.primary : const Color(0xFFE5E7EB))), child: Text(l, style: TextStyle(fontSize: 12, color: a ? Colors.white : AppColors.textSecondary))));
}
class _TrendChart extends StatelessWidget {
final List<double> data;
const _TrendChart(this.data);
@override Widget build(BuildContext c) {
if (data.isEmpty) return const Center(child: Text('暂无数据', style: TextStyle(color: AppColors.textHint)));
final m = data.reduce((a, b) => a > b ? a : b);
if (m == 0) return const Center(child: Text('暂无数据', style: TextStyle(color: AppColors.textHint)));
return Row(crossAxisAlignment: CrossAxisAlignment.end, children: List.generate(data.length, (i) {
final h = (data[i] / m * 100).clamp(4.0, 100.0);
return Expanded(child: Padding(padding: EdgeInsets.symmetric(horizontal: data.length > 15 ? 1.0 : 2.0), child: Column(mainAxisAlignment: MainAxisAlignment.end, children: [
if (data.length <= 14) Text('${data[i].toInt()}', style: const TextStyle(fontSize: 8, color: AppColors.textHint)),
const SizedBox(height: 2), Container(height: h, decoration: BoxDecoration(color: data[i] > 0 ? AppColors.primary : const Color(0xFFE5E7EB), borderRadius: BorderRadius.circular(2))),
])));
}));
}
}
class _SwipeAction extends StatelessWidget {
final Widget child; final VoidCallback onEdit; final VoidCallback onDelete;
const _SwipeAction({required this.child, required this.onEdit, required this.onDelete});
@override Widget build(BuildContext c) => Dismissible(
key: UniqueKey(), direction: DismissDirection.endToStart,
confirmDismiss: (_) async { return false; },
background: Container(decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), color: const Color(0xFF3B82F6)), alignment: Alignment.centerLeft, padding: const EdgeInsets.only(left: 20), child: const Icon(Icons.edit, color: Colors.white)),
secondaryBackground: Container(decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), color: AppColors.error), alignment: Alignment.centerRight, padding: const EdgeInsets.only(right: 20), child: const Icon(Icons.delete, color: Colors.white)),
onDismissed: (_) => onDelete(),
onUpdate: (details) {
if (details.reached && details.direction == DismissDirection.startToEnd) { onEdit(); }
},
child: child,
);
}
/// 饮食记录详情页(保留原功能)"""
class DietRecordDetailPage extends ConsumerWidget {
final String id;
const DietRecordDetailPage({super.key, required this.id});
@@ -103,18 +193,13 @@ class DietRecordDetailPage extends ConsumerWidget {
]),
])),
const SizedBox(height: 16),
...items.map((f) => Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd)),
child: Row(children: [
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(f['name']?.toString() ?? '', style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)),
if ((f['portion']?.toString() ?? '').isNotEmpty) Text(f['portion']!.toString(), style: const TextStyle(fontSize: 16, color: AppColors.textSecondary)),
])),
Text('${f['calories'] ?? 0} kcal', style: const TextStyle(fontSize: 18, color: AppTheme.primary, fontWeight: FontWeight.w600)),
]),
)),
...items.map((f) => Container(margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Row(children: [
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(f['name']?.toString() ?? '', style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)),
if ((f['portion']?.toString() ?? '').isNotEmpty) Text(f['portion']!.toString(), style: const TextStyle(fontSize: 16, color: AppColors.textSecondary)),
])),
Text('${f['calories'] ?? 0} kcal', style: const TextStyle(fontSize: 18, color: AppTheme.primary, fontWeight: FontWeight.w600)),
]))),
]);
},
),
@@ -406,10 +491,9 @@ class HealthArchivePage extends ConsumerStatefulWidget {
class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
final _nameCtrl = TextEditingController();
final _genderCtrl = TextEditingController();
final _birthCtrl = TextEditingController();
String _birthDate = '';
final _diagnosisCtrl = TextEditingController();
final _surgeryCtrl = TextEditingController();
final _surgeryDateCtrl = TextEditingController();
final List<Map<String, String>> _surgeries = [];
final _allergiesCtrl = TextEditingController();
final _chronicCtrl = TextEditingController();
final _dietCtrl = TextEditingController();
@@ -419,10 +503,10 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
@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();
_nameCtrl.dispose(); _genderCtrl.dispose();
_diagnosisCtrl.dispose(); _allergiesCtrl.dispose();
_chronicCtrl.dispose(); _dietCtrl.dispose(); _familyCtrl.dispose();
super.dispose();
}
Future<void> _load() async {
@@ -432,13 +516,14 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
if (p != null && mounted) {
_nameCtrl.text = p['name'] ?? '';
_genderCtrl.text = p['gender'] ?? '';
_birthCtrl.text = p['birthDate'] ?? '';
_birthDate = p['birthDate'] ?? '';
}
final a = await srv.getHealthArchive();
if (a != null && mounted) {
_diagnosisCtrl.text = a['diagnosis'] ?? '';
_surgeryCtrl.text = a['surgeryType'] ?? '';
_surgeryDateCtrl.text = a['surgeryDate'] ?? '';
final st = a['surgeryType'] as String?;
final sd = a['surgeryDate'] as String?;
if (st != null && st.isNotEmpty) _surgeries.add({'type': st, 'date': sd ?? ''});
_allergiesCtrl.text = (a['allergies'] as List?)?.join('') ?? '';
_chronicCtrl.text = (a['chronicDiseases'] as List?)?.join('') ?? '';
_dietCtrl.text = (a['dietRestrictions'] as List?)?.join('') ?? '';
@@ -448,157 +533,184 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
} catch (_) { if (mounted) setState(() => _loading = false); }
}
Future<void> _pickDate(void Function(String) onPicked) async {
final now = DateTime.now();
final d = await showDatePicker(
context: context,
initialDate: DateTime(now.year - 30),
firstDate: DateTime(1900),
lastDate: now,
locale: const Locale('zh'),
);
if (d != null) {
onPicked('${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}');
}
}
void _addSurgery() => setState(() => _surgeries.add({'type': '', 'date': ''}));
void _removeSurgery(int i) => setState(() => _surgeries.removeAt(i));
Future<void> _save() async {
setState(() => _saving = true);
try {
final srv = ref.read(userServiceProvider);
await srv.updateProfile(name: _nameCtrl.text, gender: _genderCtrl.text, birthDate: _birthCtrl.text);
await srv.updateProfile(name: _nameCtrl.text, gender: _genderCtrl.text, birthDate: _birthDate);
final surgeryType = _surgeries.map((s) => s['type'] ?? '').where((s) => s.isNotEmpty).join('');
final surgeryDate = _surgeries.map((s) => s['date'] ?? '').where((s) => s.isNotEmpty).join('');
await srv.updateHealthArchive({
'diagnosis': _diagnosisCtrl.text,
'surgeryType': _surgeryCtrl.text,
'surgeryDate': _surgeryDateCtrl.text,
'surgeryType': surgeryType,
'surgeryDate': surgeryDate,
'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: AppColors.success),
);
}
if (mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存成功'), backgroundColor: AppColors.success)); }
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('保存失败,请重试'), backgroundColor: AppColors.error),
);
}
} finally {
if (mounted) setState(() => _saving = false);
}
if (mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存失败,请重试'), backgroundColor: AppColors.error)); }
} finally { if (mounted) setState(() => _saving = false); }
}
@override Widget build(BuildContext context) {
if (_loading) {
return GradientScaffold(
appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('健康档案')),
body: const Center(child: CircularProgressIndicator(color: AppColors.primary)),
);
return Scaffold(backgroundColor: const Color(0xFFF5F5F5), appBar: AppBar(backgroundColor: Colors.white, elevation: 0, title: const Text('健康档案', style: TextStyle(color: AppColors.textPrimary)), leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref))), body: const Center(child: CircularProgressIndicator()));
}
return GradientScaffold(
appBar: AppBar(
backgroundColor: AppColors.cardBackground,
title: const Text('健康档案'),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildSection('个人资料', Icons.person_outline, [
_buildField('姓名', _nameCtrl, hint: '请输入姓名'),
const SizedBox(height: 12),
Row(children: [
Expanded(child: _buildField('性别', _genderCtrl, hint: '男/女')),
const SizedBox(width: 12),
Expanded(child: _buildField('出生日期', _birthCtrl, hint: '如 1970-03-15')),
]),
]),
const SizedBox(height: 16),
_buildSection('医疗信息', Icons.local_hospital_outlined, [
_buildField('主要诊断', _diagnosisCtrl, hint: '如: 冠心病'),
const SizedBox(height: 12),
Row(children: [
Expanded(child: _buildField('手术类型', _surgeryCtrl, hint: '如: PCI支架植入术')),
const SizedBox(width: 12),
Expanded(child: _buildField('手术日期', _surgeryDateCtrl, hint: '如 2026-03-15')),
]),
]),
const SizedBox(height: 16),
_buildSection('病史与限制', Icons.warning_amber_outlined, [
_buildField('过敏史', _allergiesCtrl, hint: '多个用、分隔,如: 青霉素、海鲜'),
const SizedBox(height: 12),
_buildField('慢性病史', _chronicCtrl, hint: '多个用、分隔,如: 高血压、高血脂'),
const SizedBox(height: 12),
_buildField('饮食限制', _dietCtrl, hint: '多个用、分隔,如: 低盐、低脂'),
const SizedBox(height: 12),
_buildField('家族病史', _familyCtrl, hint: '如: 父亲冠心病'),
]),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: _saving ? null : _save,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)),
elevation: 0,
),
child: Text(_saving ? '保存中...' : '保存档案', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
),
),
const SizedBox(height: 40),
],
),
);
}
Widget _buildSection(String title, IconData icon, List<Widget> fields) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: AppColors.cardBackground,
borderRadius: BorderRadius.circular(AppTheme.rMd),
border: Border.all(color: Color(0xFFC8DDFD), width: 1.5),
boxShadow: [AppTheme.shadowCard],
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Container(
width: 36, height: 36,
decoration: BoxDecoration(
gradient: AppColors.bgGradient,
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 20, color: AppColors.primary),
),
const SizedBox(width: 10),
Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: AppBar(backgroundColor: Colors.white, elevation: 0, title: const Text('健康档案', style: TextStyle(color: AppColors.textPrimary)), leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref))),
body: ListView(padding: const EdgeInsets.all(16), children: [
_Section('个人资料', Icons.person_outline, [
_F('姓名', _nameCtrl, hint: '请输入姓名'),
const SizedBox(height: 12),
_F('性别', _genderCtrl, hint: '男/女'),
const SizedBox(height: 12),
_DateF('出生日期', _birthDate, () => _pickDate((v) => setState(() => _birthDate = v))),
]),
const SizedBox(height: 16),
...fields,
const SizedBox(height: 12),
_Section('医疗信息', Icons.local_hospital_outlined, [
_F('主要诊断', _diagnosisCtrl, hint: '如: 冠心病'),
const SizedBox(height: 14),
Row(children: [const Text('手术史', style: TextStyle(fontSize: 14, color: AppColors.textSecondary)), const Spacer(), _AddBtn(() => _addSurgery())]),
..._surgeries.asMap().entries.map((e) {
final i = e.key; final s = e.value;
return Padding(padding: const EdgeInsets.only(top: 8), child: Row(children: [
Expanded(child: _F2('手术', s['type'] ?? '', (v) => s['type'] = v, hint: '如: 心脏搭桥')),
const SizedBox(width: 8),
Expanded(child: _DateF2('日期', s['date'] ?? '', () => _pickDate((v) => s['date'] = v), hint: '点击选择')),
GestureDetector(onTap: () => _removeSurgery(i), child: Container(width: 32, height: 32, decoration: BoxDecoration(color: AppColors.errorLight, borderRadius: BorderRadius.circular(8)), child: const Icon(Icons.close, size: 16, color: AppColors.error))),
]));
}),
]),
const SizedBox(height: 12),
_Section('病史与限制', Icons.warning_amber_outlined, [
_F('过敏史', _allergiesCtrl, hint: '多个用、分隔,如: 青霉素、海鲜'),
const SizedBox(height: 12),
_F('慢性病史', _chronicCtrl, hint: '多个用、分隔,如: 高血压、高血脂'),
const SizedBox(height: 12),
_F('饮食限制', _dietCtrl, hint: '多个用、分隔,如: 低盐、低脂'),
const SizedBox(height: 12),
_F('家族病史', _familyCtrl, hint: '如: 父亲冠心病'),
]),
const SizedBox(height: 24),
Container(
decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), gradient: AppColors.primaryGradient),
padding: const EdgeInsets.all(1.5),
child: SizedBox(width: double.infinity, height: 52, child: ElevatedButton(
onPressed: _saving ? null : _save,
style: ElevatedButton.styleFrom(backgroundColor: Colors.white, foregroundColor: AppColors.primary, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(13)), elevation: 0),
child: Text(_saving ? '保存中...' : '保存档案', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
)),
),
const SizedBox(height: 40),
]),
);
}
}
Widget _buildField(String label, TextEditingController ctrl, {String? hint}) {
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: const TextStyle(fontSize: 14, color: AppColors.textSecondary)),
const SizedBox(height: 6),
TextField(
controller: ctrl,
style: const TextStyle(fontSize: 17, color: AppColors.textPrimary),
decoration: InputDecoration(
hintText: hint,
hintStyle: const TextStyle(fontSize: 15, color: AppColors.textHint),
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: AppColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: AppColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppColors.primary, width: 1.5),
),
class _Section extends StatelessWidget {
final String title; final IconData icon; final List<Widget> fields;
const _Section(this.title, this.icon, this.fields);
@override Widget build(BuildContext c) => Container(padding: const EdgeInsets.all(18), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14), border: Border.all(color: const Color(0xFFE5E7EB))), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Container(width: 36, height: 36, decoration: BoxDecoration(color: AppColors.iconBg, borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 20, color: AppColors.primary)),
const SizedBox(width: 10), Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
]),
const SizedBox(height: 16), ...fields,
]));
}
class _F extends StatelessWidget {
final String label; final TextEditingController ctrl; final String? hint;
const _F(this.label, this.ctrl, {this.hint});
@override Widget build(BuildContext c) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: const TextStyle(fontSize: 14, color: AppColors.textSecondary)),
const SizedBox(height: 6),
TextField(controller: ctrl, style: const TextStyle(fontSize: 16), decoration: InputDecoration(hintText: hint, hintStyle: const TextStyle(fontSize: 14, color: AppColors.textHint), filled: true, fillColor: const Color(0xFFF5F5F5), contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: AppColors.primary)))),
]);
}
class _F2 extends StatelessWidget {
final String label; final String value; final void Function(String) chg; final String? hint;
const _F2(this.label, this.value, this.chg, {this.hint});
@override Widget build(BuildContext c) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
const SizedBox(height: 4),
TextField(
controller: TextEditingController(text: value),
onChanged: chg,
style: const TextStyle(fontSize: 14),
decoration: InputDecoration(hintText: hint, hintStyle: const TextStyle(fontSize: 13, color: AppColors.textHint), filled: true, fillColor: const Color(0xFFF5F5F5), contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: AppColors.primary))),
),
]);
}
class _DateF extends StatelessWidget {
final String label; final String value; final VoidCallback onTap;
const _DateF(this.label, this.value, this.onTap);
@override Widget build(BuildContext c) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: const TextStyle(fontSize: 14, color: AppColors.textSecondary)),
const SizedBox(height: 6),
GestureDetector(onTap: onTap, child: Container(height: 48, padding: const EdgeInsets.symmetric(horizontal: 14), decoration: BoxDecoration(color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(10)), alignment: Alignment.centerLeft, child: Row(children: [
Text(value.isEmpty ? '点击选择日期' : value, style: TextStyle(fontSize: 16, color: value.isEmpty ? AppColors.textHint : AppColors.textPrimary)),
const Spacer(), const Icon(Icons.calendar_today, size: 18, color: AppColors.textHint),
]))),
]);
}
class _DateF2 extends StatelessWidget {
final String label; final String value; final VoidCallback onTap; final String? hint;
const _DateF2(this.label, this.value, this.onTap, {this.hint});
@override Widget build(BuildContext c) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
const SizedBox(height: 4),
GestureDetector(onTap: onTap, child: Container(height: 44, padding: const EdgeInsets.symmetric(horizontal: 10), decoration: BoxDecoration(color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(8)), alignment: Alignment.centerLeft, child: Row(children: [
Text(value.isEmpty ? (hint ?? '点击选择') : value, style: TextStyle(fontSize: 13, color: value.isEmpty ? AppColors.textHint : AppColors.textPrimary)),
const Spacer(), const Icon(Icons.calendar_today, size: 14, color: AppColors.textHint),
]))),
]);
}
class _AddBtn extends StatelessWidget {
final VoidCallback onTap;
const _AddBtn(this.onTap);
@override
Widget build(BuildContext c) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: const Color(0xFFF0F0FF),
borderRadius: BorderRadius.circular(8),
),
child: const Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.add, size: 14, color: AppColors.primary),
SizedBox(width: 2),
Text('添加', style: TextStyle(fontSize: 13, color: AppColors.primary)),
]),
),
]);
);
}
}
@@ -610,167 +722,218 @@ class HealthCalendarPage extends ConsumerStatefulWidget {
class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
DateTime _currentMonth = DateTime.now();
Map<String, List<String>> _events = {};
DateTime? _selectedDate;
Map<String, List<Map<String, dynamic>>> _events = {};
Map<String, dynamic>? _selectedDayData;
@override void initState() {
super.initState();
_loadMonth();
}
static const _medBlue = Color(0xFF0000FF);
static const _exGreen = Color(0xFF00FF00);
static const _fupOrange = Color(0xFFFFA500);
@override void initState() { super.initState(); _loadMonth(); _selectDate(DateTime.now()); }
String _dateKey(DateTime d) => '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
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 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>>{};
final events = <String, List<Map<String, dynamic>>>{};
for (final item in list) {
final map = item as Map<String, dynamic>;
events[map['date'] as String] = List<String>.from(map['events'] ?? []);
final types = (map['events'] as List?)?.cast<String>() ?? [];
events[map['date'] as String] = types.map((t) => <String, dynamic>{'type': t}).toList();
}
if (mounted) setState(() => _events = events);
} catch (e) { debugPrint('[Calendar] 加载日历失败: $e'); }
// 如果有选中的日期,刷新详情
if (_selectedDate != null) _selectDate(_selectedDate!);
} catch (_) {}
}
void _selectDate(DateTime d) {
setState(() { _selectedDate = d; _selectedDayData = null; });
final key = _dateKey(d);
final details = _events[key];
if (details != null && details.isNotEmpty) {
// 从后端拉详细数据
_loadDayDetail(d);
}
}
Future<void> _loadDayDetail(DateTime d) async {
try {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/calendar/day', queryParameters: {
'date': _dateKey(d),
});
if (mounted) setState(() => _selectedDayData = res.data['data'] as Map<String, dynamic>?);
} catch (_) {}
}
@override Widget build(BuildContext context) {
return GradientScaffold(
appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('健康日历'), centerTitle: true),
final today = DateTime.now();
final isToday = _selectedDate != null && _dateKey(_selectedDate!) == _dateKey(today);
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: AppBar(backgroundColor: Colors.white, elevation: 0, title: const Text('健康日历', style: TextStyle(color: AppColors.textPrimary)), leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref))),
body: Column(children: [
_buildMonthHeader(),
_buildWeekdayHeader(),
_buildCalendarGrid(),
const SizedBox(height: 16),
_buildLegend(),
Container(color: Colors.white, child: Column(children: [
_buildMonthHeader(),
_buildWeekdayHeader(),
_buildCalendarGrid(),
])),
const SizedBox(height: 12),
// 图例
Padding(padding: const EdgeInsets.symmetric(horizontal: 16), child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
_Legend('用药提醒', _medBlue),
const SizedBox(width: 20),
_Legend('运动计划', _exGreen),
const SizedBox(width: 20),
_Legend('复查随访', _fupOrange),
])),
const SizedBox(height: 12),
// 当日详情
Expanded(child: _buildDayDetail()),
]),
);
}
Widget _buildMonthHeader() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.chevron_left, size: 32),
onPressed: () { setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1)); _loadMonth(); },
),
Text(
'${_currentMonth.year}${_currentMonth.month}',
style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w600),
),
IconButton(
icon: const Icon(Icons.chevron_right, size: 32),
onPressed: () { setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1)); _loadMonth(); },
),
],
);
}
Widget _buildMonthHeader() => Padding(padding: const EdgeInsets.symmetric(horizontal: 8), child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
IconButton(icon: const Icon(Icons.chevron_left), onPressed: () { setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1)); _loadMonth(); }),
Text('${_currentMonth.year}${_currentMonth.month}', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
IconButton(icon: const Icon(Icons.chevron_right), onPressed: () { setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1)); _loadMonth(); }),
]));
Widget _buildWeekdayHeader() {
const weekdays = ['', '', '', '', '', '', ''];
return Row(children: weekdays.map((day) => Expanded(
child: Center(child: Text(day, style: const TextStyle(fontSize: 17, color: AppColors.textHint))),
)).toList());
const wd = ['', '', '', '', '', '', ''];
return Row(children: wd.map((d) => Expanded(child: Center(child: Text(d, style: const TextStyle(fontSize: 14, color: AppColors.textHint))))).toList());
}
Widget _buildCalendarGrid() {
final firstDay = DateTime(_currentMonth.year, _currentMonth.month, 1);
final lastDay = DateTime(_currentMonth.year, _currentMonth.month + 1, 0);
final daysInMonth = lastDay.day;
final startWeekday = firstDay.weekday % 7;
final days = List.generate(42, (i) {
final dayIndex = i - startWeekday;
if (dayIndex < 0 || dayIndex >= daysInMonth) return null;
return dayIndex + 1;
});
return Expanded(
child: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 7),
itemCount: 42,
itemBuilder: (ctx, i) {
final day = days[i];
if (day == null) return const SizedBox();
return _buildDayCell(day);
},
),
);
}
Widget _buildDayCell(int day) {
final date = DateTime(_currentMonth.year, _currentMonth.month, day);
final first = DateTime(_currentMonth.year, _currentMonth.month, 1);
final last = DateTime(_currentMonth.year, _currentMonth.month + 1, 0);
final dim = last.day;
final sw = first.weekday % 7;
final days = List.generate(42, (i) { final di = i - sw; return di < 0 || di >= dim ? null : di + 1; });
final today = DateTime.now();
final isToday = date.year == today.year && date.month == today.month && date.day == today.day;
final events = _getEvents(date);
return Container(
decoration: isToday ? BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.circular(20),
) : null,
child: Stack(
alignment: Alignment.center,
children: [
Text(
'$day',
style: TextStyle(
fontSize: 19,
color: isToday ? Colors.white : AppColors.textPrimary,
fontWeight: isToday ? FontWeight.w600 : FontWeight.normal,
return GridView.builder(
shrinkWrap: true, physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 7),
itemCount: 42,
itemBuilder: (_, i) {
final day = days[i];
if (day == null) return const SizedBox();
final d = DateTime(_currentMonth.year, _currentMonth.month, day);
final isToday = _dateKey(d) == _dateKey(today);
final isSel = _selectedDate != null && _dateKey(d) == _dateKey(_selectedDate!);
final evs = _events[_dateKey(d)] ?? [];
return GestureDetector(
onTap: () => _selectDate(d),
child: Container(
margin: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: isSel ? AppColors.primary.withOpacity(0.08) : (isToday ? AppColors.primary.withOpacity(0.05) : Colors.white),
borderRadius: BorderRadius.circular(12),
border: isSel ? Border.all(color: AppColors.primary, width: 1.5) : (isToday ? Border.all(color: AppColors.primary.withOpacity(0.3), width: 1) : null),
),
child: Stack(alignment: Alignment.center, children: [
Text('$day', style: TextStyle(fontSize: 16, color: isSel ? AppColors.primary : AppColors.textPrimary, fontWeight: isSel || isToday ? FontWeight.w600 : FontWeight.normal)),
if (evs.isNotEmpty)
Positioned(bottom: 4, child: Row(children: evs.take(3).map((e) => Container(width: 5, height: 5, margin: const EdgeInsets.symmetric(horizontal: 1), decoration: BoxDecoration(color: _dotColor(e['type']?.toString() ?? ''), shape: BoxShape.circle))).toList())),
]),
),
if (events.isNotEmpty)
Positioned(
bottom: 4,
child: Row(children: events.map((type) => Container(
width: 6,
height: 6,
margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(
color: _getEventColor(type),
borderRadius: BorderRadius.circular(3),
),
)).toList()),
),
],
),
);
},
);
}
List<String> _getEvents(DateTime date) {
final key = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
return _events[key] ?? [];
}
Color _dotColor(String t) => switch (t) { 'medication' => _medBlue, 'exercise' => _exGreen, 'followup' => _fupOrange, _ => AppColors.textHint };
Color _getEventColor(String type) {
switch (type) {
case 'medication': return AppTheme.primary;
case 'exercise': return AppTheme.success;
case 'followup': return AppColors.warning;
default: return AppColors.textHint;
Widget _buildDayDetail() {
if (_selectedDate == null) return const Center(child: Text('点击日期查看当天安排', style: TextStyle(color: AppColors.textHint)));
if (_selectedDayData == null) {
final evs = _events[_dateKey(_selectedDate!)] ?? [];
if (evs.isEmpty) return Center(child: Column(mainAxisSize: MainAxisSize.min, children: [const Icon(Icons.event_available, size: 40, color: AppColors.textHint), const SizedBox(height: 8), Text('${_selectedDate!.month}${_selectedDate!.day}日无事', style: const TextStyle(color: AppColors.textHint))]));
}
}
Widget _buildLegend() {
final items = [
{'color': AppTheme.primary, 'label': '用药提醒'},
{'color': AppTheme.success, 'label': '运动计划'},
{'color': AppColors.warning, 'label': '复查随访'},
];
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: items.map((item) => Row(children: [
Container(width: 10, height: 10, decoration: BoxDecoration(color: item['color'] as Color, borderRadius: BorderRadius.circular(5))),
const SizedBox(width: 4),
Text(item['label'] as String, style: const TextStyle(fontSize: 15, color: AppColors.textSecondary)),
const SizedBox(width: 20),
])).toList()),
);
final data = _selectedDayData;
if (data == null) return Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.event_available, size: 40, color: AppColors.textHint),
const SizedBox(height: 8),
Text('${_selectedDate!.month}${_selectedDate!.day}日无事', style: const TextStyle(color: AppColors.textHint)),
]));
final meds = (data['medications'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final exs = (data['exercises'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final fups = (data['followUps'] as List?)?.cast<Map<String, dynamic>>() ?? [];
if (meds.isEmpty && exs.isEmpty && fups.isEmpty) {
return Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.event_available, size: 40, color: AppColors.textHint),
const SizedBox(height: 8),
Text('${_selectedDate!.month}${_selectedDate!.day}日无事', style: const TextStyle(color: AppColors.textHint)),
]));
}
return ListView(padding: const EdgeInsets.symmetric(horizontal: 16), children: [
// 用药
if (meds.isNotEmpty) ...[
_SectionTitle('用药提醒', _medBlue, Icons.medication),
...meds.map((m) => _EventCard('${m['name'] ?? ''} ${m['dosage'] ?? ''}', '${m['timeOfDay'] ?? ''}', _medBlue)),
],
// 运动
if (exs.isNotEmpty) ...[
_SectionTitle('运动计划', _exGreen, Icons.fitness_center),
...exs.map((e) => _EventCard('${e['type'] ?? ''}', '${e['duration'] ?? 0}分钟', _exGreen)),
],
// 随访
if (fups.isNotEmpty) ...[
_SectionTitle('复查随访', _fupOrange, Icons.event_note),
...fups.map((f) => _EventCard(f['title'] ?? '', f['doctorName'] ?? '', _fupOrange)),
],
]);
}
}
class _SectionTitle extends StatelessWidget {
final String title; final Color color; final IconData icon;
const _SectionTitle(this.title, this.color, this.icon);
@override Widget build(BuildContext c) => Padding(padding: const EdgeInsets.only(top: 12, bottom: 6), child: Row(children: [
Container(width: 4, height: 16, decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(2))),
const SizedBox(width: 8), Icon(icon, size: 16, color: color),
const SizedBox(width: 6), Text(title, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: color)),
]));
}
class _EventCard extends StatelessWidget {
final String title; final String sub; final Color color;
const _EventCard(this.title, this.sub, this.color);
@override Widget build(BuildContext c) => Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFFE5E7EB))),
child: Row(children: [
Container(width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
const SizedBox(width: 10),
Expanded(child: Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500))),
Text(sub, style: const TextStyle(fontSize: 12, color: AppColors.textHint)),
]),
);
}
class _Legend extends StatelessWidget {
final String label; final Color color;
const _Legend(this.label, this.color);
@override Widget build(BuildContext c) => Row(children: [
Container(width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
const SizedBox(width: 4),
Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
]);
}
/// 静态文本页
class StaticTextPage extends ConsumerWidget {
final String type;