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:
@@ -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))]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user