feat: UI 系统中心化 + 趋势图重构 + 法律文档 H5 上线
- UI 系统: 新增 design_tokens / module_visuals / app_buttons / app_status_badge / app_toast / ai_content 六个共享模块; 28 个页面接入, SnackBar 全部替换为 AppToast - 趋势图: 直线折线 + 渐变填充 + 阴影; 去网格线; X 轴日+月份分隔; Y 轴整数刻度最多 4 个; 点击数据点/录入记录显示上方浮层, 选中点变实心 - 法律文档 H5: 后端 wwwroot 托管隐私政策/服务协议/关于/个人信息收集清单/第三方 SDK 清单 + 索引页; Program.cs 启用 UseDefaultFiles + UseStaticFiles - 其他: auth_provider 登录流程调整; api_client IP 适配; 主页智能体栏间距优化
This commit is contained in:
@@ -174,7 +174,6 @@ builder.Services.AddHttpClient<FastGptKnowledgeClient>(client =>
|
|||||||
builder.Services.AddHostedService<MedicationReminderService>();
|
builder.Services.AddHostedService<MedicationReminderService>();
|
||||||
builder.Services.AddHostedService<ExerciseReminderService>();
|
builder.Services.AddHostedService<ExerciseReminderService>();
|
||||||
builder.Services.AddHostedService<HealthRecordReminderService>();
|
builder.Services.AddHostedService<HealthRecordReminderService>();
|
||||||
builder.Services.AddHostedService<HealthRecordReminderService>();
|
|
||||||
builder.Services.AddHostedService<MedicationReminderWorker>();
|
builder.Services.AddHostedService<MedicationReminderWorker>();
|
||||||
builder.Services.AddHostedService<CleanupService>();
|
builder.Services.AddHostedService<CleanupService>();
|
||||||
builder.Services.AddHostedService<ReportAnalysisWorker>();
|
builder.Services.AddHostedService<ReportAnalysisWorker>();
|
||||||
@@ -204,6 +203,10 @@ app.UseCors();
|
|||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
// 默认 wwwroot 静态文件(法律文档 H5 页面等)
|
||||||
|
app.UseDefaultFiles();
|
||||||
|
app.UseStaticFiles();
|
||||||
|
|
||||||
var uploadsPath = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
var uploadsPath = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
||||||
Directory.CreateDirectory(uploadsPath);
|
Directory.CreateDirectory(uploadsPath);
|
||||||
app.UseStaticFiles(new StaticFileOptions
|
app.UseStaticFiles(new StaticFileOptions
|
||||||
|
|||||||
61
backend/src/Health.WebApi/wwwroot/about.html
Normal file
61
backend/src/Health.WebApi/wwwroot/about.html
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>关于我们 - 小脉健康</title>
|
||||||
|
<meta name="description" content="小脉健康 - 面向血管病患者的 AI 健康管理应用">
|
||||||
|
<link rel="stylesheet" href="legal.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="topnav">
|
||||||
|
<div class="brand"><img class="brand-icon" src="app-icon.png" alt="">小脉健康</div>
|
||||||
|
<a href="index.html">文档列表</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header class="doc-header">
|
||||||
|
<h1>关于小脉健康</h1>
|
||||||
|
<div class="doc-meta">
|
||||||
|
<span>版本:v1.0.0</span>
|
||||||
|
<span>Build 20260101</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<article class="content">
|
||||||
|
<h2>产品介绍</h2>
|
||||||
|
<p>小脉健康是一款面向血管病患者的 AI 健康管理应用。以对话为核心交互方式,患者可以通过自然语言记录健康数据、获取饮食运动建议、管理用药、解读检查报告。</p>
|
||||||
|
|
||||||
|
<h2>核心功能</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>AI 智能问诊:</strong>基于大语言模型的健康咨询服务</li>
|
||||||
|
<li><strong>健康数据管理:</strong>血压、心率、血糖、血氧、体重的记录与趋势分析</li>
|
||||||
|
<li><strong>智能用药管理:</strong>AI 解析处方,自动生成用药计划和提醒</li>
|
||||||
|
<li><strong>饮食识别分析:</strong>拍照即可识别食物种类、估算热量营养素</li>
|
||||||
|
<li><strong>报告智能解读:</strong>上传检查报告,AI 自动提取指标并预解读</li>
|
||||||
|
<li><strong>运动计划管理:</strong>制定和追踪每日运动目标</li>
|
||||||
|
<li><strong>在线医生问诊:</strong>与签约医生进行远程咨询</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>开发团队</h2>
|
||||||
|
<p>由专业医疗团队与 AI 技术团队联合打造。</p>
|
||||||
|
|
||||||
|
<h2>技术支持</h2>
|
||||||
|
<p>如遇到问题或有建议,请通过以下方式联系我们:</p>
|
||||||
|
<ul>
|
||||||
|
<li>在线客服:App 内「设置」→「意见反馈」</li>
|
||||||
|
<li>客服热线:400-xxx-xxxx(工作日 9:00-18:00)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>版权声明</h2>
|
||||||
|
<p>© 2025-2026 小脉健康团队。保留所有权利。</p>
|
||||||
|
<p>本软件受中华人民共和国著作权法保护。</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<p>© 2025-2026 小脉健康 · 保留所有权利</p>
|
||||||
|
<p><a href="privacy.html">隐私政策</a> · <a href="terms.html">用户服务协议</a></p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
351
backend/src/Health.WebApi/wwwroot/collection-list.html
Normal file
351
backend/src/Health.WebApi/wwwroot/collection-list.html
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>个人信息收集清单 - 小脉健康</title>
|
||||||
|
<meta name="description" content="小脉健康个人信息收集清单 - 列明我们收集的个人信息类型、用途、是否必填及保存期限">
|
||||||
|
<link rel="stylesheet" href="legal.css">
|
||||||
|
<style>
|
||||||
|
.collection-table { width: 100%; border-collapse: collapse; margin: 14px 0; font-size: 14px; }
|
||||||
|
.collection-table th,
|
||||||
|
.collection-table td { padding: 10px 12px; border: 1px solid var(--border); text-align: left; vertical-align: top; }
|
||||||
|
.collection-table th { background: var(--bg-mute); font-weight: 600; color: var(--text); white-space: nowrap; }
|
||||||
|
.collection-table td { color: var(--text-mid); }
|
||||||
|
.collection-table tr:nth-child(even) td { background: #FAFBFC; }
|
||||||
|
.tag-required { display: inline-block; padding: 1px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; background: #FEE2E2; color: #B91C1C; }
|
||||||
|
.tag-optional { display: inline-block; padding: 1px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; background: #E0E7FF; color: #3730A3; }
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.collection-table { font-size: 13px; }
|
||||||
|
.collection-table th, .collection-table td { padding: 8px 6px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="topnav">
|
||||||
|
<div class="brand"><img class="brand-icon" src="app-icon.png" alt="">小脉健康</div>
|
||||||
|
<a href="index.html">文档列表</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header class="doc-header">
|
||||||
|
<h1>个人信息收集清单</h1>
|
||||||
|
<div class="doc-meta">
|
||||||
|
<span>更新日期:2026年7月7日</span>
|
||||||
|
<span>生效日期:2026年7月7日</span>
|
||||||
|
<span>版本:v1.0</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<article class="content">
|
||||||
|
<p>为帮助您了解小脉健康如何收集您的个人信息,依据《个人信息保护法》《App 违法违规收集使用个人信息行为认定方法》等规定,特制定本清单。本清单说明我们收集的个人信息类型、用途、是否必填、是否上传服务器及保存期限。</p>
|
||||||
|
<p>除本清单列明的内容外,我们不会收集与提供服务无关的个人信息。如需扩展收集范围,将更新本清单并取得您的同意。</p>
|
||||||
|
|
||||||
|
<h2>一、注册登录与账号信息</h2>
|
||||||
|
<table class="collection-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>信息类型</th><th>用途</th><th>必填</th><th>上传服务器</th><th>保存期限</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>手机号码</td>
|
||||||
|
<td>注册、登录、身份识别、短信验证码验证</td>
|
||||||
|
<td><span class="tag-required">必填</span></td>
|
||||||
|
<td>是</td>
|
||||||
|
<td>账号存续期间</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>短信验证码</td>
|
||||||
|
<td>登录身份验证</td>
|
||||||
|
<td><span class="tag-required">必填</span></td>
|
||||||
|
<td>是(验证后失效)</td>
|
||||||
|
<td>短期(验证后即删)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>姓名/昵称</td>
|
||||||
|
<td>个人资料展示</td>
|
||||||
|
<td><span class="tag-optional">选填</span></td>
|
||||||
|
<td>是</td>
|
||||||
|
<td>账号存续期间</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>性别、出生日期</td>
|
||||||
|
<td>个人资料完善、健康风险评估</td>
|
||||||
|
<td><span class="tag-optional">选填</span></td>
|
||||||
|
<td>是</td>
|
||||||
|
<td>账号存续期间</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>JWT 登录令牌</td>
|
||||||
|
<td>会话保持</td>
|
||||||
|
<td>自动生成</td>
|
||||||
|
<td>服务端生成,App 本地保存访问令牌</td>
|
||||||
|
<td>令牌有效期或退出登录后清除</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>二、健康档案与健康记录</h2>
|
||||||
|
<table class="collection-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>信息类型</th><th>用途</th><th>必填</th><th>上传服务器</th><th>保存期限</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>健康档案(诊断、手术、过敏史、慢性病史、家族史、饮食禁忌)</td>
|
||||||
|
<td>个性化健康管理、AI 咨询上下文</td>
|
||||||
|
<td><span class="tag-optional">选填</span></td>
|
||||||
|
<td>是</td>
|
||||||
|
<td>账号存续期间</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>血压(收缩压、舒张压、脉搏)</td>
|
||||||
|
<td>健康指标记录、趋势分析、异常提醒</td>
|
||||||
|
<td><span class="tag-optional">选填</span></td>
|
||||||
|
<td>是</td>
|
||||||
|
<td>账号存续期间</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>心率、血糖、血氧、体重</td>
|
||||||
|
<td>健康指标记录、趋势分析</td>
|
||||||
|
<td><span class="tag-optional">选填</span></td>
|
||||||
|
<td>是</td>
|
||||||
|
<td>账号存续期间</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>记录时间、记录来源</td>
|
||||||
|
<td>数据溯源、区分手动/设备录入</td>
|
||||||
|
<td>自动记录</td>
|
||||||
|
<td>是</td>
|
||||||
|
<td>账号存续期间</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>三、用药与生活方式</h2>
|
||||||
|
<table class="collection-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>信息类型</th><th>用途</th><th>必填</th><th>上传服务器</th><th>保存期限</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>用药计划(药品名称、剂量、频次、服药时间)</td>
|
||||||
|
<td>用药管理、提醒生成</td>
|
||||||
|
<td><span class="tag-optional">选填</span></td>
|
||||||
|
<td>是</td>
|
||||||
|
<td>账号存续期间</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>服药打卡记录</td>
|
||||||
|
<td>用药依从性追踪</td>
|
||||||
|
<td>自动记录</td>
|
||||||
|
<td>是</td>
|
||||||
|
<td>账号存续期间</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>饮食记录(餐次、食物、份量、热量、评分)</td>
|
||||||
|
<td>饮食管理、营养分析</td>
|
||||||
|
<td><span class="tag-optional">选填</span></td>
|
||||||
|
<td>是</td>
|
||||||
|
<td>账号存续期间</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>运动计划与打卡</td>
|
||||||
|
<td>运动管理、目标追踪</td>
|
||||||
|
<td><span class="tag-optional">选填</span></td>
|
||||||
|
<td>是</td>
|
||||||
|
<td>账号存续期间</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>日历与提醒设置</td>
|
||||||
|
<td>站内提醒推送</td>
|
||||||
|
<td><span class="tag-optional">选填</span></td>
|
||||||
|
<td>是</td>
|
||||||
|
<td>账号存续期间</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>四、检查报告与图片文件</h2>
|
||||||
|
<table class="collection-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>信息类型</th><th>用途</th><th>必填</th><th>上传服务器</th><th>保存期限</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>检查报告图片</td>
|
||||||
|
<td>报告管理、AI 结构化解读、医生审核</td>
|
||||||
|
<td><span class="tag-optional">选填</span></td>
|
||||||
|
<td>是(服务器文件存储)</td>
|
||||||
|
<td>账号存续期间,删除报告时同步删除</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>PDF 文件</td>
|
||||||
|
<td>AI 对话附件解析</td>
|
||||||
|
<td><span class="tag-optional">选填</span></td>
|
||||||
|
<td>是(服务器文件存储)</td>
|
||||||
|
<td>账号存续期间或按服务器文件清理策略处理</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>聊天图片</td>
|
||||||
|
<td>AI 对话附件、视觉模型分析</td>
|
||||||
|
<td><span class="tag-optional">选填</span></td>
|
||||||
|
<td>是(服务器文件存储)</td>
|
||||||
|
<td>账号存续期间或按服务器文件清理策略处理</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>饮食图片</td>
|
||||||
|
<td>食物识别、热量估算</td>
|
||||||
|
<td><span class="tag-optional">选填</span></td>
|
||||||
|
<td>是(临时存储)</td>
|
||||||
|
<td>识别完成后短期保留</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>AI 解析结果(指标、摘要、建议)</td>
|
||||||
|
<td>报告预解读、健康咨询</td>
|
||||||
|
<td>自动生成</td>
|
||||||
|
<td>是</td>
|
||||||
|
<td>账号存续期间</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>五、咨询与医生服务</h2>
|
||||||
|
<table class="collection-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>信息类型</th><th>用途</th><th>必填</th><th>上传服务器</th><th>保存期限</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>AI 对话内容(文字、附件)</td>
|
||||||
|
<td>AI 健康咨询、上下文维护</td>
|
||||||
|
<td><span class="tag-optional">选填</span></td>
|
||||||
|
<td>是</td>
|
||||||
|
<td>账号存续期间</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>医生咨询消息</td>
|
||||||
|
<td>在线问诊、实时通信</td>
|
||||||
|
<td><span class="tag-optional">选填</span></td>
|
||||||
|
<td>是</td>
|
||||||
|
<td>账号存续期间</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>随访记录</td>
|
||||||
|
<td>医生随访管理</td>
|
||||||
|
<td>自动记录</td>
|
||||||
|
<td>是</td>
|
||||||
|
<td>账号存续期间</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>医生-患者签约关系</td>
|
||||||
|
<td>咨询、报告审核、随访服务</td>
|
||||||
|
<td>自动建立</td>
|
||||||
|
<td>是</td>
|
||||||
|
<td>账号存续期间</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>六、蓝牙设备信息</h2>
|
||||||
|
<table class="collection-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>信息类型</th><th>用途</th><th>必填</th><th>上传服务器</th><th>保存期限</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>设备标识、设备名称、设备类型</td>
|
||||||
|
<td>蓝牙设备绑定与识别</td>
|
||||||
|
<td><span class="tag-optional">选填</span></td>
|
||||||
|
<td>否,主要保存在 App 本地</td>
|
||||||
|
<td>解绑设备、清除缓存或卸载 App 后清除</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>蓝牙服务 UUID</td>
|
||||||
|
<td>设备通信</td>
|
||||||
|
<td>自动记录</td>
|
||||||
|
<td>否,主要保存在 App 本地</td>
|
||||||
|
<td>解绑设备、清除缓存或卸载 App 后清除</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>血压计同步数据(收缩压、舒张压、脉搏、测量时间)</td>
|
||||||
|
<td>健康指标录入</td>
|
||||||
|
<td>自动同步</td>
|
||||||
|
<td>是</td>
|
||||||
|
<td>账号存续期间</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>已绑定设备信息、最近同步时间、短期去重指纹</td>
|
||||||
|
<td>设备管理与去重</td>
|
||||||
|
<td>自动记录</td>
|
||||||
|
<td>否,保存在 App 本地</td>
|
||||||
|
<td>短期去重指纹保留约 24 小时;绑定信息在解绑设备、清除缓存或卸载 App 后清除</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>七、设备与日志信息</h2>
|
||||||
|
<table class="collection-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>信息类型</th><th>用途</th><th>必填</th><th>上传服务器</th><th>保存期限</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>设备型号、操作系统版本、App 版本</td>
|
||||||
|
<td>功能适配、问题排查</td>
|
||||||
|
<td>自动采集</td>
|
||||||
|
<td>是</td>
|
||||||
|
<td>日志保留期</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>网络请求状态、接口错误信息</td>
|
||||||
|
<td>故障排查、性能优化</td>
|
||||||
|
<td>自动采集</td>
|
||||||
|
<td>是</td>
|
||||||
|
<td>日志保留期</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>本地缓存(登录 token、偏好设置、设备绑定信息)</td>
|
||||||
|
<td>会话保持、离线体验</td>
|
||||||
|
<td>自动存储</td>
|
||||||
|
<td>否(仅本地)</td>
|
||||||
|
<td>登录 token 在退出登录后清除;其他缓存可通过清除缓存、解绑设备或卸载 App 清除</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>八、我们不会收集的信息</h2>
|
||||||
|
<ul>
|
||||||
|
<li>我们不会收集您的精确定位经纬度(定位权限仅用于 Android 蓝牙扫描)。</li>
|
||||||
|
<li>我们不会读取您的通讯录、短信、通话记录。</li>
|
||||||
|
<li>我们不会未经您主动选择而读取相册其他内容或持续访问相机。</li>
|
||||||
|
<li>我们不会收集您的生物识别信息(指纹、面容等)。</li>
|
||||||
|
<li>我们不会向第三方出售您的个人信息或健康数据。</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>九、您的权利</h2>
|
||||||
|
<p>依据《个人信息保护法》,您享有以下权利:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>查询、复制:</strong>在 App 内查看您的个人资料、健康数据、用药记录等。</li>
|
||||||
|
<li><strong>更正、补充:</strong>在 App 内修改个人资料、健康档案等信息。</li>
|
||||||
|
<li><strong>删除:</strong>在 App 内删除健康记录、报告、对话等内容。</li>
|
||||||
|
<li><strong>账号注销:</strong>在设置页使用"删除账号"功能,注销后主要业务数据将被删除。</li>
|
||||||
|
<li><strong>撤回同意:</strong>您可随时关闭相应权限或停止使用相关功能。</li>
|
||||||
|
</ul>
|
||||||
|
<p>如需行使上述权利或对本清单有任何疑问,请通过《<a href="privacy.html">隐私政策</a>》中的联系方式与我们联系。</p>
|
||||||
|
|
||||||
|
<div class="contact-card">
|
||||||
|
<p><strong>公司/运营主体:</strong>xxx</p>
|
||||||
|
<p><strong>个人信息保护负责人:</strong>xxx</p>
|
||||||
|
<p><strong>客服邮箱:</strong>xxx</p>
|
||||||
|
<p><strong>客服电话:</strong>xxx</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<p>© 2025-2026 小脉健康 · 保留所有权利</p>
|
||||||
|
<p><a href="privacy.html">隐私政策</a> · <a href="terms.html">用户服务协议</a> · <a href="sdk-list.html">第三方 SDK 清单</a></p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
61
backend/src/Health.WebApi/wwwroot/index.html
Normal file
61
backend/src/Health.WebApi/wwwroot/index.html
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>法律文档 - 小脉健康</title>
|
||||||
|
<meta name="description" content="小脉健康法律文档中心 - 隐私政策、服务协议、关于我们">
|
||||||
|
<link rel="stylesheet" href="legal.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="topnav">
|
||||||
|
<div class="brand"><img class="brand-icon" src="app-icon.png" alt="">小脉健康</div>
|
||||||
|
<span style="font-size:13px;color:var(--text-mute);">法律文档中心</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header class="doc-header">
|
||||||
|
<h1>法律文档中心</h1>
|
||||||
|
<div class="doc-meta">
|
||||||
|
<span>更新日期:2026年6月29日</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p style="margin: 16px 0 20px; color: var(--text-mid); font-size: 15px;">
|
||||||
|
欢迎使用小脉健康。请您在使用本应用前,仔细阅读以下文档。点击任意文档查看完整内容。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="doc-list">
|
||||||
|
<a class="doc-item" href="privacy.html">
|
||||||
|
<div class="doc-item-title">隐私政策</div>
|
||||||
|
<div class="doc-item-desc">说明我们如何收集、使用、存储、共享和保护您的个人信息及健康数据</div>
|
||||||
|
<div class="doc-item-date">更新日期:2026年6月29日 · 版本 v1.0</div>
|
||||||
|
</a>
|
||||||
|
<a class="doc-item" href="terms.html">
|
||||||
|
<div class="doc-item-title">用户服务协议</div>
|
||||||
|
<div class="doc-item-desc">约定您与小脉健康之间的权利和义务,包括服务内容、行为规范、责任限制等</div>
|
||||||
|
<div class="doc-item-date">更新日期:2026年6月29日 · 版本 v1.0</div>
|
||||||
|
</a>
|
||||||
|
<a class="doc-item" href="collection-list.html">
|
||||||
|
<div class="doc-item-title">个人信息收集清单</div>
|
||||||
|
<div class="doc-item-desc">列明我们收集的个人信息类型、用途、是否必填、是否上传服务器及保存期限</div>
|
||||||
|
<div class="doc-item-date">更新日期:2026年7月7日 · 版本 v1.0</div>
|
||||||
|
</a>
|
||||||
|
<a class="doc-item" href="sdk-list.html">
|
||||||
|
<div class="doc-item-title">第三方信息共享清单</div>
|
||||||
|
<div class="doc-item-desc">列明接入的第三方 SDK 及外部服务名称、提供方、共享数据类型与用途</div>
|
||||||
|
<div class="doc-item-date">更新日期:2026年7月7日 · 版本 v1.0</div>
|
||||||
|
</a>
|
||||||
|
<a class="doc-item" href="about.html">
|
||||||
|
<div class="doc-item-title">关于我们</div>
|
||||||
|
<div class="doc-item-desc">了解小脉健康的产品介绍、核心功能、开发团队和技术支持方式</div>
|
||||||
|
<div class="doc-item-date">版本 v1.0.0 · Build 20260101</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<p>© 2025-2026 小脉健康 · 保留所有权利</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
250
backend/src/Health.WebApi/wwwroot/legal.css
Normal file
250
backend/src/Health.WebApi/wwwroot/legal.css
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
/* 小脉健康 - 法律文档共享样式 */
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--brand: #6366F1;
|
||||||
|
--brand-dark: #4F46E5;
|
||||||
|
--brand-light: #EEF2FF;
|
||||||
|
--text: #1F2937;
|
||||||
|
--text-mid: #4B5563;
|
||||||
|
--text-mute: #6B7280;
|
||||||
|
--border: #E5E7EB;
|
||||||
|
--bg: #FFFFFF;
|
||||||
|
--bg-mute: #F9FAFB;
|
||||||
|
}
|
||||||
|
|
||||||
|
html { -webkit-text-size-adjust: 100%; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Helvetica Neue", sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-mute);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 20px 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部导航 */
|
||||||
|
.topnav {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topnav .brand {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--brand-dark);
|
||||||
|
text-decoration: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topnav .brand-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 7px;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topnav a {
|
||||||
|
color: var(--text-mid);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topnav a:hover { color: var(--brand); }
|
||||||
|
|
||||||
|
/* 文档头部 */
|
||||||
|
.doc-header {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 28px 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-header h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-meta {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-mute);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-meta span::before {
|
||||||
|
content: "·";
|
||||||
|
margin-right: 6px;
|
||||||
|
color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-meta span:first-child::before { content: ""; margin: 0; }
|
||||||
|
|
||||||
|
/* 正文 */
|
||||||
|
.content {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 28px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 28px 0 14px;
|
||||||
|
padding-left: 12px;
|
||||||
|
border-left: 3px solid var(--brand);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h2:first-child { margin-top: 0; }
|
||||||
|
|
||||||
|
.content h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 20px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content p {
|
||||||
|
margin: 10px 0;
|
||||||
|
color: var(--text-mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content ul,
|
||||||
|
.content ol {
|
||||||
|
margin: 10px 0 10px 4px;
|
||||||
|
padding-left: 20px;
|
||||||
|
color: var(--text-mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content li {
|
||||||
|
margin: 6px 0;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content li ul,
|
||||||
|
.content li ol {
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content strong {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 联系卡片 */
|
||||||
|
.contact-card {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--brand-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
border-left: 3px solid var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-card h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: var(--brand-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-card p {
|
||||||
|
margin: 6px 0;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部 */
|
||||||
|
.footer {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 32px auto 0;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-mute);
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: var(--brand);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 索引页 */
|
||||||
|
.doc-list {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-item {
|
||||||
|
display: block;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-item:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.doc-item:hover {
|
||||||
|
background: var(--brand-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-item-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-item-title::after {
|
||||||
|
content: "→";
|
||||||
|
color: var(--text-mute);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-item-desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-mute);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-item-date {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-mute);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.container { padding: 16px 14px 48px; }
|
||||||
|
.topnav { padding: 12px 14px; }
|
||||||
|
.doc-header { padding: 20px 18px; }
|
||||||
|
.doc-header h1 { font-size: 21px; }
|
||||||
|
.content { padding: 20px 16px; }
|
||||||
|
.content h2 { font-size: 17px; }
|
||||||
|
body { font-size: 15px; }
|
||||||
|
}
|
||||||
188
backend/src/Health.WebApi/wwwroot/privacy.html
Normal file
188
backend/src/Health.WebApi/wwwroot/privacy.html
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>隐私政策 - 小脉健康</title>
|
||||||
|
<meta name="description" content="小脉健康隐私政策 - 说明我们如何收集、使用、存储、共享和保护您的个人信息及健康数据">
|
||||||
|
<link rel="stylesheet" href="legal.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="topnav">
|
||||||
|
<div class="brand"><img class="brand-icon" src="app-icon.png" alt="">小脉健康</div>
|
||||||
|
<a href="index.html">文档列表</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header class="doc-header">
|
||||||
|
<h1>小脉健康隐私政策</h1>
|
||||||
|
<div class="doc-meta">
|
||||||
|
<span>更新日期:2026年6月29日</span>
|
||||||
|
<span>生效日期:2026年6月29日</span>
|
||||||
|
<span>版本:v1.0</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<article class="content">
|
||||||
|
<p>小脉健康重视您的个人信息和健康数据保护。本政策说明我们在您使用小脉健康 App 及相关服务时,如何收集、使用、存储、共享和保护您的信息,以及您如何管理自己的信息。</p>
|
||||||
|
<p>请您在注册、登录和使用本应用前仔细阅读本政策。若您不同意本政策内容,请停止注册或使用相关服务。</p>
|
||||||
|
|
||||||
|
<h2>一、我们收集的信息</h2>
|
||||||
|
<p>为向您提供健康管理、AI 健康咨询、报告解读、饮食识别、用药管理、运动计划、在线医生咨询和蓝牙设备同步等功能,我们会根据您使用的具体功能收集以下信息:</p>
|
||||||
|
|
||||||
|
<h3>1. 账号与身份信息</h3>
|
||||||
|
<ul>
|
||||||
|
<li>手机号码:用于注册、登录、身份识别和账号安全验证。</li>
|
||||||
|
<li>姓名或昵称、性别、出生日期、头像:用于完善个人资料和展示账号信息。</li>
|
||||||
|
<li>医生选择或签约关系:用于向您提供医生咨询、报告审核和随访相关服务。</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>2. 健康档案与健康记录</h3>
|
||||||
|
<ul>
|
||||||
|
<li>健康档案:诊断信息、手术类型、手术日期、过敏史、饮食禁忌、慢性病史、家族史等您主动填写的信息。</li>
|
||||||
|
<li>健康指标:血压、心率、血糖、血氧、体重、记录时间、记录来源等。</li>
|
||||||
|
<li>用药信息:药品名称、剂量、频次、服药时间、提醒设置、服药打卡记录等。</li>
|
||||||
|
<li>饮食记录:餐次、食物名称、份量、热量估算、饮食评分等。</li>
|
||||||
|
<li>运动计划:运动项目、计划内容、完成情况等。</li>
|
||||||
|
<li>检查报告:您上传的报告图片、报告文件、AI 识别出的指标、摘要、医生审核意见等。</li>
|
||||||
|
<li>在线咨询和随访信息:您与医生或系统产生的咨询内容、咨询状态、随访内容和时间。</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>3. 图片、相机、相册和文件信息</h3>
|
||||||
|
<ul>
|
||||||
|
<li>当您使用"拍照上传报告""拍照识别饮食""聊天中发送图片"等功能时,我们会调用设备相机,用于拍摄检查报告、饮食图片或您希望 AI 辅助查看的图片。</li>
|
||||||
|
<li>当您使用"从相册选择""上传 PDF"等功能时,我们会读取您主动选择的图片或 PDF 文件,用于报告管理、饮食识别、AI 对话附件解析等功能。</li>
|
||||||
|
<li>未经您主动选择或确认,我们不会读取您的相册其他内容,也不会持续访问您的相机。</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>4. 蓝牙设备信息</h3>
|
||||||
|
<ul>
|
||||||
|
<li>当您使用蓝牙设备功能时,我们会扫描和连接附近支持的健康设备。目前代码中已实现血压计数据同步,设备模型中预留了血糖仪、体重秤、血氧仪类型,但当前实际支持的数据解析以血压计为主。</li>
|
||||||
|
<li>我们会在本地保存已绑定设备的设备标识、设备名称、设备类型、服务 UUID、最近同步时间,以及用于避免重复同步的短期记录指纹。</li>
|
||||||
|
<li>通过血压计同步时,我们会读取并记录收缩压、舒张压、脉搏、测量时间等数据。</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>5. 位置信息相关说明</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Android 系统在部分版本中要求 App 申请定位权限后才允许进行蓝牙低功耗设备扫描。我们申请定位权限的目的,是用于发现和连接蓝牙健康设备。</li>
|
||||||
|
<li>当前代码未将您的定位经纬度上传至服务器,也未用于地图、轨迹、广告或位置画像。</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>6. 设备、日志和网络信息</h3>
|
||||||
|
<ul>
|
||||||
|
<li>设备型号、操作系统版本、App 版本、网络请求状态、接口错误信息等,用于功能适配、问题排查、服务安全和性能优化。</li>
|
||||||
|
<li>本应用会使用网络请求、流式消息、实时通信等能力,以便完成登录、AI 回复、医生咨询、数据同步等功能。</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>二、我们如何使用这些信息</h2>
|
||||||
|
<p>我们会将收集的信息用于以下目的:</p>
|
||||||
|
<ul>
|
||||||
|
<li>为您创建和维护账号,完成登录、退出登录、令牌刷新和账号安全验证。</li>
|
||||||
|
<li>记录和展示您的健康指标、用药、饮食、运动、报告、咨询和随访信息。</li>
|
||||||
|
<li>根据您输入或上传的信息,提供 AI 健康解释、报告预解读、饮食识别、用药和运动建议。</li>
|
||||||
|
<li>通过蓝牙连接健康设备并同步血压等测量数据。</li>
|
||||||
|
<li>向医生端展示与您咨询、签约、报告审核或随访相关的必要信息。</li>
|
||||||
|
<li>发送用药、运动、健康指标、报告处理等站内提醒。</li>
|
||||||
|
<li>进行系统维护、故障排查、安全审计、服务优化和合规管理。</li>
|
||||||
|
</ul>
|
||||||
|
<p>本应用提供的 AI 分析、健康提醒和报告解读仅作为健康管理参考,不构成诊断、治疗或处方建议。如您出现胸痛、呼吸困难、意识异常、严重血压或血糖异常等紧急情况,请立即联系医生或前往医疗机构就诊。</p>
|
||||||
|
|
||||||
|
<h2>三、权限调用说明</h2>
|
||||||
|
<h3>1. 相机权限</h3>
|
||||||
|
<ul>
|
||||||
|
<li>使用场景:拍摄检查报告、饮食图片、聊天附件图片。</li>
|
||||||
|
<li>使用目的:上传报告进行 AI 结构化解读;识别食物种类和估算份量、热量;在 AI 对话中让系统理解您主动发送的图片内容。</li>
|
||||||
|
</ul>
|
||||||
|
<h3>2. 相册/图片读取权限</h3>
|
||||||
|
<ul>
|
||||||
|
<li>使用场景:从相册选择报告图片、饮食图片或聊天图片。</li>
|
||||||
|
<li>使用目的:上传您主动选择的图片并完成报告管理、饮食识别或 AI 对话附件解析。</li>
|
||||||
|
</ul>
|
||||||
|
<h3>3. 文件读取权限</h3>
|
||||||
|
<ul>
|
||||||
|
<li>使用场景:在聊天中选择 PDF 文件。</li>
|
||||||
|
<li>使用目的:上传您主动选择的 PDF,供系统提取文字或生成 AI 对话上下文。</li>
|
||||||
|
</ul>
|
||||||
|
<h3>4. 蓝牙权限</h3>
|
||||||
|
<ul>
|
||||||
|
<li>使用场景:扫描、绑定和连接蓝牙健康设备。</li>
|
||||||
|
<li>使用目的:发现支持的血压计等健康设备,并同步血压、脉搏、测量时间等数据。</li>
|
||||||
|
</ul>
|
||||||
|
<h3>5. 定位权限</h3>
|
||||||
|
<ul>
|
||||||
|
<li>使用场景:Android 蓝牙低功耗扫描。</li>
|
||||||
|
<li>使用目的:满足系统蓝牙扫描要求。我们不会收集或上传您的精确定位经纬度。</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>四、信息上传和第三方服务</h2>
|
||||||
|
<p>在您主动使用相关功能时,以下数据可能会上传至服务器:</p>
|
||||||
|
<ul>
|
||||||
|
<li>账号信息、个人资料、健康档案和健康指标。</li>
|
||||||
|
<li>用药计划、服药记录、饮食记录、运动计划、日历和提醒数据。</li>
|
||||||
|
<li>检查报告图片、聊天图片、PDF 文件及其 AI 解析结果。</li>
|
||||||
|
<li>饮食图片识别结果、报告 AI 解读结果、AI 对话内容和医生咨询内容。</li>
|
||||||
|
<li>蓝牙血压计同步得到的血压、脉搏和测量时间。</li>
|
||||||
|
</ul>
|
||||||
|
<p>为实现 AI 对话、图片识别、报告解读、知识库检索、短信验证和实时咨询等功能,我们可能接入以下第三方或外部服务。实际启用情况以正式部署配置为准:</p>
|
||||||
|
<ul>
|
||||||
|
<li>大语言模型服务:用于 AI 健康咨询、报告预解读、文本生成和内容理解。</li>
|
||||||
|
<li>视觉模型服务:用于饮食图片识别、报告图片或聊天图片内容识别。</li>
|
||||||
|
<li>知识库检索服务:用于在 AI 回复时检索医学知识库或业务知识库资料。</li>
|
||||||
|
<li>短信服务:用于向您的手机号发送登录或注册验证码。</li>
|
||||||
|
<li>对象存储或服务器文件存储服务:用于保存您主动上传的报告、图片或 PDF 文件。</li>
|
||||||
|
<li>实时通信服务:用于医生咨询消息的实时收发。</li>
|
||||||
|
</ul>
|
||||||
|
<p>我们不会向第三方出售您的个人信息或健康数据。若第三方服务处理您的信息,我们会尽力要求其仅在实现相关功能所必需的范围内处理,并采取合理的安全保护措施。</p>
|
||||||
|
|
||||||
|
<h2>五、信息存储和保存期限</h2>
|
||||||
|
<ul>
|
||||||
|
<li>您的账号资料、健康档案、健康记录、用药、饮食、运动、报告、咨询、随访、通知和 AI 对话等业务数据,会保存至服务器数据库,用于持续向您提供服务。</li>
|
||||||
|
<li>您主动上传的检查报告图片会保存于服务器文件目录,并与报告记录关联;删除单份报告时,系统会删除对应报告记录和原始报告文件。</li>
|
||||||
|
<li>您在聊天中上传的图片或 PDF 会保存于服务器文件目录,用于 AI 附件解析和会话上下文展示。</li>
|
||||||
|
<li>饮食识别图片会临时保存至服务器用于识别处理,并提交视觉模型进行分析;识别结果会用于生成饮食记录。</li>
|
||||||
|
<li>App 本地仅保存登录 token、偏好设置、已绑定蓝牙设备信息、最近同步信息和短期去重指纹等缓存数据,不作为健康业务数据的唯一来源。</li>
|
||||||
|
<li>我们将在实现服务目的所需期限内保存您的信息;法律法规另有要求的,从其规定。</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>六、您如何管理个人信息</h2>
|
||||||
|
<p>您可以在 App 内查看、修改或删除部分信息:</p>
|
||||||
|
<ul>
|
||||||
|
<li>在个人资料、健康档案、健康数据、用药、饮食、运动、报告、AI 会话等页面查看或管理相关记录。</li>
|
||||||
|
<li>在设置页查看《隐私政策》和《服务协议》。</li>
|
||||||
|
<li>在设置页使用"删除账号"功能申请注销账号。</li>
|
||||||
|
</ul>
|
||||||
|
<p>账号注销后,系统会删除您的账号及主要业务数据,包括健康记录、用药记录、饮食记录、运动计划、报告记录、AI 对话、医生咨询、随访、通知、健康档案、登录令牌等。注销后相关数据不可恢复。</p>
|
||||||
|
<p>请注意:如法律法规要求留存,或为处理争议、审计、安全风控等确有必要,我们可能在法定或合理期限内保留必要记录。</p>
|
||||||
|
|
||||||
|
<h2>七、我们如何保护信息安全</h2>
|
||||||
|
<ul>
|
||||||
|
<li>使用登录认证和访问控制保护您的账号和接口。</li>
|
||||||
|
<li>通过 HTTPS 等方式保护数据传输安全,正式上线时应使用有效的 HTTPS 证书。</li>
|
||||||
|
<li>对服务器、数据库和文件存储采取权限控制、日志审计、备份和安全配置。</li>
|
||||||
|
<li>医生端、管理端仅能访问其职责范围内必要的数据。</li>
|
||||||
|
<li>如发生个人信息安全事件,我们将按照法律法规要求及时采取补救措施并履行通知义务。</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>八、未成年人保护</h2>
|
||||||
|
<p>本应用主要面向具备完全民事行为能力的成人用户。如未成年人需要使用本应用,应在监护人同意和指导下使用。监护人发现未成年人信息被不当收集或使用的,可通过本政策中的联系方式与我们联系。</p>
|
||||||
|
|
||||||
|
<h2>九、政策更新</h2>
|
||||||
|
<p>我们可能根据产品功能、法律法规或合规要求更新本政策。政策更新后,我们会在 App 内或相关页面展示更新后的版本;重大变更将通过弹窗、公告或其他合理方式提示您。</p>
|
||||||
|
|
||||||
|
<h2>十、联系我们</h2>
|
||||||
|
<div class="contact-card">
|
||||||
|
<p>如您对本政策、个人信息保护或账号注销有任何问题,可通过以下方式联系我们:</p>
|
||||||
|
<p><strong>公司/运营主体:</strong>xxx</p>
|
||||||
|
<p><strong>隐私负责人/客服邮箱:</strong>xxx</p>
|
||||||
|
<p><strong>客服电话:</strong>xxx</p>
|
||||||
|
<p><strong>联系地址:</strong>xxx</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<p>© 2025-2026 小脉健康 · 保留所有权利</p>
|
||||||
|
<p><a href="terms.html">用户服务协议</a> · <a href="about.html">关于我们</a></p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
243
backend/src/Health.WebApi/wwwroot/sdk-list.html
Normal file
243
backend/src/Health.WebApi/wwwroot/sdk-list.html
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>第三方信息共享清单 - 小脉健康</title>
|
||||||
|
<meta name="description" content="小脉健康第三方 SDK 及外部服务共享清单 - 列明接入的第三方 SDK 名称、提供方、数据类型与用途">
|
||||||
|
<link rel="stylesheet" href="legal.css">
|
||||||
|
<style>
|
||||||
|
.sdk-table { width: 100%; border-collapse: collapse; margin: 14px 0; font-size: 14px; }
|
||||||
|
.sdk-table th,
|
||||||
|
.sdk-table td { padding: 12px; border: 1px solid var(--border); text-align: left; vertical-align: top; }
|
||||||
|
.sdk-table th { background: var(--bg-mute); font-weight: 600; color: var(--text); white-space: nowrap; }
|
||||||
|
.sdk-table td { color: var(--text-mid); }
|
||||||
|
.sdk-table tr:nth-child(even) td { background: #FAFBFC; }
|
||||||
|
.sdk-name { font-weight: 600; color: var(--text); }
|
||||||
|
.sdk-type { display: inline-block; padding: 2px 10px; border-radius: 4px; font-size: 12px; font-weight: 600; background: var(--brand-light); color: var(--brand-dark); margin-bottom: 4px; }
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.sdk-table { font-size: 13px; }
|
||||||
|
.sdk-table th, .sdk-table td { padding: 8px 6px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="topnav">
|
||||||
|
<div class="brand"><img class="brand-icon" src="app-icon.png" alt="">小脉健康</div>
|
||||||
|
<a href="index.html">文档列表</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header class="doc-header">
|
||||||
|
<h1>第三方信息共享清单</h1>
|
||||||
|
<div class="doc-meta">
|
||||||
|
<span>更新日期:2026年7月7日</span>
|
||||||
|
<span>生效日期:2026年7月7日</span>
|
||||||
|
<span>版本:v1.0</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<article class="content">
|
||||||
|
<p>为实现小脉健康的各项功能,我们接入了以下第三方 SDK 及外部服务。本清单依据《个人信息保护法》《App 违法违规收集使用个人信息行为认定方法》等规定,说明每项第三方服务的名称、提供方、共享的数据类型、用途及隐私政策链接。</p>
|
||||||
|
<p>我们仅在被您主动触发对应功能时,向第三方共享实现该功能所必需的信息。第三方仅可在必要范围内处理您的信息,不得用于其他目的。</p>
|
||||||
|
|
||||||
|
<h2>一、App 端 SDK 清单</h2>
|
||||||
|
|
||||||
|
<h3>1. flutter_blue_plus(蓝牙通信)</h3>
|
||||||
|
<table class="sdk-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><th>SDK 名称</th><td class="sdk-name">flutter_blue_plus</td></tr>
|
||||||
|
<tr><th>SDK 类型</th><td><span class="sdk-type">蓝牙通信</span></td></tr>
|
||||||
|
<tr><th>提供方</th><td>开源社区(BSD-3-Clause License)</td></tr>
|
||||||
|
<tr><th>使用场景</th><td>扫描、连接蓝牙健康设备(血压计),同步测量数据</td></tr>
|
||||||
|
<tr><th>共享数据</th><td>蓝牙设备标识、设备名称、服务 UUID;不涉及个人身份信息</td></tr>
|
||||||
|
<tr><th>用途</th><td>建立蓝牙连接、读取血压计测量数据</td></tr>
|
||||||
|
<tr><th>数据去向</th><td>仅在本地设备与蓝牙健康设备间通信,不上传至第三方</td></tr>
|
||||||
|
<tr><th>权限</th><td>蓝牙(Android: BLUETOOTH_SCAN/CONNECT,iOS: NSBluetoothAlwaysUsageDescription)</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>2. permission_handler(权限管理)</h3>
|
||||||
|
<table class="sdk-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><th>SDK 名称</th><td class="sdk-name">permission_handler</td></tr>
|
||||||
|
<tr><th>SDK 类型</th><td><span class="sdk-type">权限管理</span></td></tr>
|
||||||
|
<tr><th>提供方</th><td>Baseflow(开源,MIT License)</td></tr>
|
||||||
|
<tr><th>使用场景</th><td>申请相机、相册、蓝牙、定位等系统权限</td></tr>
|
||||||
|
<tr><th>共享数据</th><td>不收集任何个人信息,仅用于权限申请与状态查询</td></tr>
|
||||||
|
<tr><th>用途</th><td>统一管理运行时权限请求</td></tr>
|
||||||
|
<tr><th>数据去向</th><td>不涉及数据传输</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>3. image_picker / file_picker(系统选择器)</h3>
|
||||||
|
<table class="sdk-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><th>SDK 名称</th><td class="sdk-name">image_picker、file_picker</td></tr>
|
||||||
|
<tr><th>SDK 类型</th><td><span class="sdk-type">系统组件</span></td></tr>
|
||||||
|
<tr><th>提供方</th><td>Flutter 官方 / 开源社区</td></tr>
|
||||||
|
<tr><th>使用场景</th><td>从相册选择图片、调用相机拍摄、选择 PDF 文件</td></tr>
|
||||||
|
<tr><th>共享数据</th><td>仅您主动选择的图片或文件,不涉及其他相册内容</td></tr>
|
||||||
|
<tr><th>用途</th><td>报告上传、饮食拍照、聊天附件、PDF 解析</td></tr>
|
||||||
|
<tr><th>数据去向</th><td>所选文件经 App 上传至小脉健康服务器,不上传至第三方</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>4. dio(HTTP 网络库)</h3>
|
||||||
|
<table class="sdk-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><th>SDK 名称</th><td class="sdk-name">dio</td></tr>
|
||||||
|
<tr><th>SDK 类型</th><td><span class="sdk-type">网络通信</span></td></tr>
|
||||||
|
<tr><th>提供方</th><td>开源社区(MIT License)</td></tr>
|
||||||
|
<tr><th>使用场景</th><td>App 与小脉健康服务器之间的 HTTP 通信</td></tr>
|
||||||
|
<tr><th>共享数据</th><td>App 发起的 API 请求内容(含登录令牌、业务数据)</td></tr>
|
||||||
|
<tr><th>用途</th><td>登录、数据同步、AI 对话、医生咨询等功能的数据传输</td></tr>
|
||||||
|
<tr><th>数据去向</th><td>仅发送至小脉健康服务器,不发送至其他第三方</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>5. signalr_netcore(实时通信)</h3>
|
||||||
|
<table class="sdk-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><th>SDK 名称</th><td class="sdk-name">signalr_netcore</td></tr>
|
||||||
|
<tr><th>SDK 类型</th><td><span class="sdk-type">实时通信</span></td></tr>
|
||||||
|
<tr><th>提供方</th><td>开源社区(MIT License),基于 Microsoft SignalR 协议</td></tr>
|
||||||
|
<tr><th>使用场景</th><td>医生咨询消息实时收发</td></tr>
|
||||||
|
<tr><th>共享数据</th><td>咨询消息内容、会话标识</td></tr>
|
||||||
|
<tr><th>用途</th><td>在线医生问诊的实时消息推送</td></tr>
|
||||||
|
<tr><th>数据去向</th><td>仅发送至小脉健康服务器,不发送至其他第三方</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>6. sqflite(本地数据库)</h3>
|
||||||
|
<table class="sdk-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><th>SDK 名称</th><td class="sdk-name">sqflite</td></tr>
|
||||||
|
<tr><th>SDK 类型</th><td><span class="sdk-type">本地存储</span></td></tr>
|
||||||
|
<tr><th>提供方</th><td>开源社区(BSD-2-Clause License)</td></tr>
|
||||||
|
<tr><th>使用场景</th><td>App 本地缓存数据存储</td></tr>
|
||||||
|
<tr><th>共享数据</th><td>登录令牌、偏好设置、蓝牙设备绑定信息等缓存数据</td></tr>
|
||||||
|
<tr><th>用途</th><td>会话保持、离线体验、设备绑定信息本地保存</td></tr>
|
||||||
|
<tr><th>数据去向</th><td>仅存储在用户本地设备,不上传至任何服务器</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>7. 其他 UI 与工具库</h3>
|
||||||
|
<table class="sdk-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>SDK 名称</th><th>用途</th><th>是否收集个人信息</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>flutter_riverpod</td><td>状态管理</td><td>否</td></tr>
|
||||||
|
<tr><td>fl_chart</td><td>图表渲染</td><td>否</td></tr>
|
||||||
|
<tr><td>flutter_markdown</td><td>Markdown 文本渲染</td><td>否</td></tr>
|
||||||
|
<tr><td>shadcn_ui</td><td>UI 组件库</td><td>否</td></tr>
|
||||||
|
<tr><td>google_fonts</td><td>字体加载</td><td>否(首次可能从 Google Fonts CDN 加载字体文件)</td></tr>
|
||||||
|
<tr><td>cupertino_icons</td><td>iOS 风格图标</td><td>否</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>二、服务器端外部服务清单</h2>
|
||||||
|
|
||||||
|
<h3>1. DeepSeek 大语言模型服务</h3>
|
||||||
|
<table class="sdk-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><th>服务名称</th><td class="sdk-name">DeepSeek API</td></tr>
|
||||||
|
<tr><th>服务类型</th><td><span class="sdk-type">大语言模型</span></td></tr>
|
||||||
|
<tr><th>提供方</th><td>深度求索(DeepSeek)</td></tr>
|
||||||
|
<tr><th>使用场景</th><td>AI 健康咨询、报告预解读、对话内容生成</td></tr>
|
||||||
|
<tr><th>共享数据</th><td>您输入的咨询文字、健康档案摘要、AI 对话上下文</td></tr>
|
||||||
|
<tr><th>用途</th><td>AI 生成健康咨询回复</td></tr>
|
||||||
|
<tr><th>服务地址</th><td>https://api.deepseek.com/v1</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>2. 通义千问大语言模型 / 视觉模型服务</h3>
|
||||||
|
<table class="sdk-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><th>服务名称</th><td class="sdk-name">通义千问(Qwen)</td></tr>
|
||||||
|
<tr><th>服务类型</th><td><span class="sdk-type">大语言模型 / 视觉模型</span></td></tr>
|
||||||
|
<tr><th>提供方</th><td>阿里云计算有限公司</td></tr>
|
||||||
|
<tr><th>使用场景</th><td>饮食图片识别、报告图片内容识别、AI 对话</td></tr>
|
||||||
|
<tr><th>共享数据</th><td>您上传的饮食图片、报告图片、聊天图片及对应文字指令</td></tr>
|
||||||
|
<tr><th>用途</th><td>图像内容识别、文字生成</td></tr>
|
||||||
|
<tr><th>服务地址</th><td>https://dashscope.aliyuncs.com/compatible-mode/v1</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>3. FastGPT 知识库检索服务</h3>
|
||||||
|
<table class="sdk-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><th>服务名称</th><td class="sdk-name">FastGPT</td></tr>
|
||||||
|
<tr><th>服务类型</th><td><span class="sdk-type">知识库检索</span></td></tr>
|
||||||
|
<tr><th>提供方</th><td>自建部署(基于 FastGPT 开源项目)</td></tr>
|
||||||
|
<tr><th>使用场景</th><td>AI 回复时检索医学知识库</td></tr>
|
||||||
|
<tr><th>共享数据</th><td>检索查询词(不含个人身份信息)</td></tr>
|
||||||
|
<tr><th>用途</th><td>为 AI 回复提供医学知识依据</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>4. 服务器文件存储</h3>
|
||||||
|
<table class="sdk-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><th>服务名称</th><td class="sdk-name">小脉健康服务器文件存储</td></tr>
|
||||||
|
<tr><th>服务类型</th><td><span class="sdk-type">文件存储</span></td></tr>
|
||||||
|
<tr><th>提供方</th><td>自建服务</td></tr>
|
||||||
|
<tr><th>使用场景</th><td>存储您上传的报告图片、聊天图片、PDF 文件</td></tr>
|
||||||
|
<tr><th>共享数据</th><td>报告图片、聊天图片、PDF 文件</td></tr>
|
||||||
|
<tr><th>用途</th><td>文件持久化存储与访问</td></tr>
|
||||||
|
<tr><th>数据去向</th><td>保存至小脉健康服务器,不共享给独立第三方存储服务。若正式上线改用云存储或对象存储服务,将更新本清单。</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>5. PostgreSQL 数据库</h3>
|
||||||
|
<table class="sdk-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><th>服务名称</th><td class="sdk-name">PostgreSQL</td></tr>
|
||||||
|
<tr><th>服务类型</th><td><span class="sdk-type">关系型数据库</span></td></tr>
|
||||||
|
<tr><th>提供方</th><td>自建部署(基于 PostgreSQL 开源项目)</td></tr>
|
||||||
|
<tr><th>使用场景</th><td>存储账号、健康档案、健康记录、用药、饮食、运动、报告、咨询等业务数据</td></tr>
|
||||||
|
<tr><th>共享数据</th><td>所有业务数据(详见《<a href="collection-list.html">个人信息收集清单</a>》)</td></tr>
|
||||||
|
<tr><th>用途</th><td>业务数据持久化与查询</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>6. 短信服务(计划接入)</h3>
|
||||||
|
<table class="sdk-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><th>服务名称</th><td class="sdk-name">短信验证码服务</td></tr>
|
||||||
|
<tr><th>服务类型</th><td><span class="sdk-type">短信</span></td></tr>
|
||||||
|
<tr><th>提供方</th><td>待定(正式上线时确定,预计为阿里云/腾讯云短信服务)</td></tr>
|
||||||
|
<tr><th>使用场景</th><td>注册、登录时发送验证码</td></tr>
|
||||||
|
<tr><th>共享数据</th><td>手机号码</td></tr>
|
||||||
|
<tr><th>用途</th><td>身份验证</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>三、我们不会共享的信息</h2>
|
||||||
|
<ul>
|
||||||
|
<li>我们不会向第三方出售您的个人信息或健康数据。</li>
|
||||||
|
<li>我们不会将您的健康数据用于商业广告投放。</li>
|
||||||
|
<li>第三方服务提供方仅可在实现您所使用的功能所必需的范围内处理您的信息,并应采取合理的安全保护措施。</li>
|
||||||
|
<li>除上述清单列明的第三方服务外,我们不会共享给其他第三方。</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>四、第三方服务变更</h2>
|
||||||
|
<p>我们可能根据业务需要调整接入的第三方服务。新增、变更或停止使用第三方服务时,我们将及时更新本清单。如新增第三方服务涉及新的信息共享范围,将另行取得您的同意。</p>
|
||||||
|
|
||||||
|
<div class="contact-card">
|
||||||
|
<p>如对本清单或第三方信息共享有任何疑问,请通过以下方式联系我们:</p>
|
||||||
|
<p><strong>公司/运营主体:</strong>xxx</p>
|
||||||
|
<p><strong>个人信息保护负责人:</strong>xxx</p>
|
||||||
|
<p><strong>客服邮箱:</strong>xxx</p>
|
||||||
|
<p><strong>客服电话:</strong>xxx</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<p>© 2025-2026 小脉健康 · 保留所有权利</p>
|
||||||
|
<p><a href="privacy.html">隐私政策</a> · <a href="terms.html">用户服务协议</a> · <a href="collection-list.html">个人信息收集清单</a></p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
140
backend/src/Health.WebApi/wwwroot/terms.html
Normal file
140
backend/src/Health.WebApi/wwwroot/terms.html
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>用户服务协议 - 小脉健康</title>
|
||||||
|
<meta name="description" content="小脉健康用户服务协议 - 约定您与小脉健康之间的权利和义务">
|
||||||
|
<link rel="stylesheet" href="legal.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="topnav">
|
||||||
|
<div class="brand"><img class="brand-icon" src="app-icon.png" alt="">小脉健康</div>
|
||||||
|
<a href="index.html">文档列表</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header class="doc-header">
|
||||||
|
<h1>小脉健康服务协议</h1>
|
||||||
|
<div class="doc-meta">
|
||||||
|
<span>更新日期:2026年6月29日</span>
|
||||||
|
<span>生效日期:2026年6月29日</span>
|
||||||
|
<span>版本:v1.0</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<article class="content">
|
||||||
|
<p>欢迎使用小脉健康。请您在注册、登录和使用本应用前,仔细阅读并理解本服务协议。您点击同意、注册、登录或继续使用本应用,即表示您已阅读、理解并同意本协议及《<a href="privacy.html">隐私政策</a>》。</p>
|
||||||
|
<p>如您不同意本协议,请停止注册或使用本应用。</p>
|
||||||
|
|
||||||
|
<h2>一、服务内容</h2>
|
||||||
|
<p>小脉健康为用户提供健康数据记录、健康档案管理、用药管理、饮食识别、运动计划、健康日历、检查报告管理、AI 健康解释、在线医生咨询、随访和蓝牙健康设备同步等功能。具体服务内容会根据产品版本、实际开通情况、监管要求和运营安排进行调整。</p>
|
||||||
|
<p>本应用当前可能包含以下服务:</p>
|
||||||
|
<ul>
|
||||||
|
<li>健康数据管理:记录和展示血压、心率、血糖、血氧、体重等指标。</li>
|
||||||
|
<li>蓝牙设备同步:连接支持的健康设备,当前主要用于血压计数据同步。</li>
|
||||||
|
<li>报告管理与预解读:上传检查报告图片,由系统进行结构化识别和 AI 辅助解读。</li>
|
||||||
|
<li>饮食识别:上传或拍摄饮食图片,识别食物种类、估算份量和热量。</li>
|
||||||
|
<li>AI 健康咨询:根据您输入的文字、图片、PDF 或健康档案,提供健康解释和管理建议。</li>
|
||||||
|
<li>用药、运动和日历提醒:帮助您记录计划、查看进度和接收站内提醒。</li>
|
||||||
|
<li>在线医生咨询:在开通范围内与医生或相关服务人员进行消息沟通。</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>二、账号注册与使用</h2>
|
||||||
|
<ul>
|
||||||
|
<li>您应使用真实、准确、有效的信息完成注册、登录和资料填写。</li>
|
||||||
|
<li>您应妥善保管账号、验证码、登录状态及设备,不得将账号转让、出租、出借或提供给他人使用。</li>
|
||||||
|
<li>因您主动泄露验证码、账号信息、设备信息或操作不当造成的损失,由您自行承担。</li>
|
||||||
|
<li>如我们发现您的账号存在违法违规、异常访问、攻击系统、冒用身份、恶意上传等行为,有权依法采取限制功能、暂停服务、要求整改、注销账号或配合监管处理等措施。</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>三、健康服务和医疗边界</h2>
|
||||||
|
<p>小脉健康是健康管理辅助工具,不是医疗机构,不提供急诊服务。本应用中的 AI 分析、健康提醒、饮食运动建议、报告预解读、风险提示和自动生成内容,仅供健康管理参考,不构成诊断、治疗、处方、用药调整或医疗结论。</p>
|
||||||
|
<p>您理解并同意:</p>
|
||||||
|
<ul>
|
||||||
|
<li>AI 回复可能受输入信息完整性、图片清晰度、模型能力、网络状态和第三方服务状态影响,可能存在不准确、不完整或延迟。</li>
|
||||||
|
<li>检查报告解读仅是对报告文字、指标或图片内容的辅助说明,不能替代医生结合病史、体格检查、影像资料和线下检查作出的诊断。</li>
|
||||||
|
<li>饮食识别、热量估算、运动建议和健康评分可能存在误差,仅作为参考。</li>
|
||||||
|
<li>用药相关内容不应替代医生、药师或说明书建议。请勿根据 App 内容自行开始、停止、更换或调整药物。</li>
|
||||||
|
<li>医生咨询、随访和报告审核等服务,应结合线下诊疗意见综合判断。</li>
|
||||||
|
</ul>
|
||||||
|
<p>如您出现胸痛、胸闷憋气、呼吸困难、意识异常、晕厥、言语不清、一侧肢体无力、严重头痛、严重过敏、血压或血糖明显异常等紧急情况,请立即拨打急救电话或前往医疗机构就诊,不应等待或依赖本应用回复。</p>
|
||||||
|
|
||||||
|
<h2>四、用户上传内容和行为规范</h2>
|
||||||
|
<p>您在使用本应用时,可能会上传文字、图片、PDF、检查报告、健康数据、咨询内容或其他资料。您应保证上传内容来源合法、真实、准确,不侵犯他人合法权益。</p>
|
||||||
|
<p>您不得利用本应用从事以下行为:</p>
|
||||||
|
<ul>
|
||||||
|
<li>提交虚假、违法、侵权、辱骂、歧视、色情、暴力、诈骗或误导性信息。</li>
|
||||||
|
<li>冒用他人身份,上传他人个人信息、健康数据、病历、报告或图片。</li>
|
||||||
|
<li>干扰应用正常运行,攻击、破解、扫描、爬取或尝试未经授权访问系统、数据和接口。</li>
|
||||||
|
<li>利用本应用从事违法违规、损害他人权益、扰乱医疗服务秩序或违反公序良俗的行为。</li>
|
||||||
|
<li>将 AI 回复、报告解读或医生咨询内容用于违法用途,或断章取义传播造成误导。</li>
|
||||||
|
</ul>
|
||||||
|
<p>因您上传内容不真实、不合法、不完整或侵犯他人权益造成的后果,由您自行承担。</p>
|
||||||
|
|
||||||
|
<h2>五、第三方服务和外部能力</h2>
|
||||||
|
<p>为实现短信登录、AI 对话、图片识别、报告解读、知识库检索、文件存储、实时通信等功能,本应用可能依赖第三方或外部服务。实际启用的服务以正式部署配置为准。</p>
|
||||||
|
<p>您理解并同意:</p>
|
||||||
|
<ul>
|
||||||
|
<li>第三方服务可能因网络、系统维护、接口限制、模型能力、政策调整等原因导致延迟、中断、识别失败或结果不准确。</li>
|
||||||
|
<li>我们会尽力保障服务稳定性,但不承诺第三方服务始终无错误、不中断或满足您的全部预期。</li>
|
||||||
|
<li>涉及个人信息和健康数据处理的第三方服务,我们会按照《<a href="privacy.html">隐私政策</a>》进行说明,并在合理范围内要求其采取安全保护措施。</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>六、数据与隐私</h2>
|
||||||
|
<p>我们会按照《<a href="privacy.html">隐私政策</a>》收集、使用、存储、共享和保护您的个人信息及健康数据。您可在 App 内"设置"中查看《隐私政策》,并依法行使查询、更正、删除和注销账号等权利。</p>
|
||||||
|
<p>您可以在 App 内删除部分健康数据、报告、对话或其他记录;您也可以通过"删除账号"功能申请注销账号。账号注销后,系统会删除您的账号及主要业务数据,包括健康记录、用药记录、饮食记录、运动计划、报告记录、AI 对话、医生咨询、随访、通知、健康档案、登录令牌等。注销后相关数据不可恢复。</p>
|
||||||
|
<p>如法律法规要求留存,或为处理争议、审计、安全风控等确有必要,我们可能在法定或合理期限内保留必要记录。</p>
|
||||||
|
|
||||||
|
<h2>七、服务变更、中断与终止</h2>
|
||||||
|
<p>为提升服务质量、修复问题、满足合规要求或调整业务安排,我们可能对功能、页面、规则、服务范围、第三方能力或收费策略进行调整。</p>
|
||||||
|
<p>在以下情况下,服务可能发生中断、延迟或终止:</p>
|
||||||
|
<ul>
|
||||||
|
<li>系统维护、升级、迁移、故障修复或安全加固。</li>
|
||||||
|
<li>网络、服务器、数据库、短信、AI 模型、云服务或第三方接口异常。</li>
|
||||||
|
<li>您的设备、系统版本、权限设置、网络环境或操作方式导致服务无法正常使用。</li>
|
||||||
|
<li>法律法规、监管要求、司法行政机关要求或不可抗力。</li>
|
||||||
|
<li>您违反本协议、法律法规或平台规则。</li>
|
||||||
|
</ul>
|
||||||
|
<p>我们会尽力降低服务中断对您的影响,但不承诺服务永久、连续、无错误运行。</p>
|
||||||
|
|
||||||
|
<h2>八、知识产权</h2>
|
||||||
|
<p>本应用的软件、页面、图标、文字、图片、交互设计、技术方案、数据结构、算法逻辑、商标、标识及相关内容,除依法属于第三方或用户自行上传的内容外,相关权利归小脉健康或其合法权利人所有。</p>
|
||||||
|
<p>未经授权,您不得复制、修改、传播、出租、出售、反向工程、反编译、抓取、镜像或以其他方式使用本应用及相关内容。</p>
|
||||||
|
<p>您上传的文字、图片、报告、PDF、健康数据等内容的合法权利仍归您或原权利人所有。为向您提供服务,您授权我们在必要范围内对相关内容进行存储、处理、识别、分析、展示和传输。</p>
|
||||||
|
|
||||||
|
<h2>九、责任限制</h2>
|
||||||
|
<p>在法律允许范围内,小脉健康不对以下情况造成的损失承担超出法定范围的责任:</p>
|
||||||
|
<ul>
|
||||||
|
<li>因您提供的信息不真实、不完整、不准确或操作不当导致的结果偏差。</li>
|
||||||
|
<li>因您将 AI 回复、报告预解读、饮食识别、运动建议或健康提醒作为诊断、治疗、处方或急救依据造成的后果。</li>
|
||||||
|
<li>因第三方服务、网络故障、设备异常、系统维护、不可抗力或监管要求导致的服务中断、数据延迟或功能不可用。</li>
|
||||||
|
<li>因您泄露账号、验证码、设备或登录状态导致的信息泄露或损失。</li>
|
||||||
|
<li>因您上传违法、侵权或不当内容引发的争议或责任。</li>
|
||||||
|
</ul>
|
||||||
|
<p>本协议不排除或限制法律法规规定不得排除或限制的责任。</p>
|
||||||
|
|
||||||
|
<h2>十、协议更新</h2>
|
||||||
|
<p>我们可能根据业务发展、产品变化、法律法规或监管要求更新本协议。更新后,我们会在 App 内或相关页面展示新版本;重大变更将通过弹窗、公告或其他合理方式提示您。</p>
|
||||||
|
<p>如您在协议更新后继续使用本应用,视为您已接受更新后的协议。</p>
|
||||||
|
|
||||||
|
<h2>十一、法律适用与争议解决</h2>
|
||||||
|
<p>本协议的订立、履行、解释和争议解决适用中华人民共和国法律。因本协议或本应用服务产生争议的,双方应先友好协商;协商不成的,依法向有管辖权的人民法院解决。</p>
|
||||||
|
|
||||||
|
<h2>十二、联系我们</h2>
|
||||||
|
<div class="contact-card">
|
||||||
|
<p>如您对本协议或服务使用有任何疑问,请通过以下方式联系我们:</p>
|
||||||
|
<p><strong>公司/运营主体:</strong>xxx</p>
|
||||||
|
<p><strong>客服/支持邮箱:</strong>xxx</p>
|
||||||
|
<p><strong>客服电话:</strong>xxx</p>
|
||||||
|
<p><strong>联系地址:</strong>xxx</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<p>© 2025-2026 小脉健康 · 保留所有权利</p>
|
||||||
|
<p><a href="privacy.html">隐私政策</a> · <a href="about.html">关于我们</a></p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
96
docs/superpowers/plans/2026-07-06-ui-system-first-pass.md
Normal file
96
docs/superpowers/plans/2026-07-06-ui-system-first-pass.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# UI System First Pass Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build the first pass of the app-wide UI system and migrate the highest-impact frontend surfaces to the agreed typography, color, icon, layout, button, feedback, and AI content rules.
|
||||||
|
|
||||||
|
**Architecture:** Centralize design decisions in token and common widget files, then migrate pages by replacing local hardcoded visuals with shared module visuals and components. Keep this pass focused on visible consistency and maintainability without adding the full accessibility-mode feature yet.
|
||||||
|
|
||||||
|
**Tech Stack:** Flutter, Riverpod, shadcn_ui Lucide icons, flutter_markdown.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Design Tokens And Common Widgets
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `health_app/lib/core/app_colors.dart`
|
||||||
|
- Modify: `health_app/lib/core/app_theme.dart`
|
||||||
|
- Create: `health_app/lib/core/app_design_tokens.dart`
|
||||||
|
- Create: `health_app/lib/core/app_module_visuals.dart`
|
||||||
|
- Create: `health_app/lib/widgets/app_buttons.dart`
|
||||||
|
- Create: `health_app/lib/widgets/app_status_badge.dart`
|
||||||
|
- Create: `health_app/lib/widgets/app_toast.dart`
|
||||||
|
- Create: `health_app/lib/widgets/ai_content.dart`
|
||||||
|
|
||||||
|
- [ ] Add typography, spacing, radius, shadow, module color, and module icon tokens.
|
||||||
|
- [ ] Add reusable gradient-outline button and module-aware buttons.
|
||||||
|
- [ ] Add top-center toast helper for light feedback.
|
||||||
|
- [ ] Add shared AI Markdown renderer and plain text cleaner.
|
||||||
|
- [ ] Run `flutter analyze`.
|
||||||
|
|
||||||
|
### Task 2: AI Conversation And Cards
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `health_app/lib/pages/home/widgets/chat_messages_view.dart`
|
||||||
|
- Modify: `health_app/lib/pages/home/home_page.dart`
|
||||||
|
|
||||||
|
- [ ] Apply shared AI content renderer to AI bubbles.
|
||||||
|
- [ ] Normalize user bubble, AI bubble, attachments, AI generated note, and Markdown typography.
|
||||||
|
- [ ] Update intelligent agent visuals to use shared module icons and colors, including `dumbbell` for exercise and `pill` for medication.
|
||||||
|
- [ ] Preserve gradient-outline white-background buttons in agent welcome cards.
|
||||||
|
- [ ] Keep today health card gradient outline and ensure empty medication windows still show a light status.
|
||||||
|
- [ ] Run `flutter analyze lib/pages/home/widgets/chat_messages_view.dart lib/pages/home/home_page.dart`.
|
||||||
|
|
||||||
|
### Task 3: Sidebar
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `health_app/lib/widgets/health_drawer.dart`
|
||||||
|
|
||||||
|
- [ ] Keep current structure: user header, health dashboard, common functions, conversation records.
|
||||||
|
- [ ] Convert user header to no-frame layout with settings on the right.
|
||||||
|
- [ ] Keep health dashboard as a light panel.
|
||||||
|
- [ ] Convert common functions to a two-column light matrix without heavy per-item cards.
|
||||||
|
- [ ] Keep conversation records as lightweight rows with summary left and date right.
|
||||||
|
- [ ] Run `flutter analyze lib/widgets/health_drawer.dart`.
|
||||||
|
|
||||||
|
### Task 4: Core Management Pages
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `health_app/lib/widgets/enterprise_widgets.dart`
|
||||||
|
- Modify: `health_app/lib/pages/medication/medication_list_page.dart`
|
||||||
|
- Modify: `health_app/lib/pages/medication/medication_checkin_page.dart`
|
||||||
|
- Modify: `health_app/lib/pages/remaining_pages.dart`
|
||||||
|
- Modify: `health_app/lib/pages/report/report_pages.dart`
|
||||||
|
- Modify: `health_app/lib/pages/report/ai_analysis_page.dart`
|
||||||
|
- Modify: `health_app/lib/pages/notifications/notification_center_page.dart`
|
||||||
|
|
||||||
|
- [ ] Update summary panels to avoid repeating page titles and reduce icon emphasis.
|
||||||
|
- [ ] Apply module colors and Lucide icons to medication, exercise, reports, and notifications.
|
||||||
|
- [ ] Apply shared AI content renderer to report AI text to prevent visible raw Markdown markers such as `**`.
|
||||||
|
- [ ] Normalize list title/subtitle/tag sizes and row heights.
|
||||||
|
- [ ] Run targeted `flutter analyze` on modified files.
|
||||||
|
|
||||||
|
### Task 5: Profile, Health Archive, Diet, Device
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `health_app/lib/pages/profile/profile_page.dart`
|
||||||
|
- Modify: `health_app/lib/pages/remaining_pages.dart`
|
||||||
|
- Modify: `health_app/lib/pages/diet/diet_capture_page.dart`
|
||||||
|
- Modify: `health_app/lib/pages/device/device_management_page.dart`
|
||||||
|
- Modify: `health_app/lib/widgets/ble_sync_dialog.dart`
|
||||||
|
|
||||||
|
- [ ] Use two-column short-field layout where appropriate in profile/health archive surfaces already in scope.
|
||||||
|
- [ ] Apply module tokens to diet and device surfaces.
|
||||||
|
- [ ] Improve Bluetooth/device panels with shared radius, typography, and module colors.
|
||||||
|
- [ ] Replace obvious bottom SnackBar style feedback in touched flows with top-center toast where low-risk.
|
||||||
|
- [ ] Run targeted `flutter analyze` on modified files.
|
||||||
|
|
||||||
|
### Task 6: Full Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: `health_app`
|
||||||
|
|
||||||
|
- [ ] Run `flutter analyze`.
|
||||||
|
- [ ] If available and practical, run a smoke test or build command for the Flutter app.
|
||||||
|
- [ ] Summarize changed files and any deferred surfaces.
|
||||||
|
|
||||||
@@ -118,6 +118,13 @@ class _RootNavigator extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (!authState.isLoading &&
|
||||||
|
!authState.isLoggedIn &&
|
||||||
|
current.name != 'login') {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
goRoute(ref, 'login');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: stack.length <= 1,
|
canPop: stack.length <= 1,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import 'local_database.dart';
|
|||||||
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
|
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
|
||||||
const String baseUrl = String.fromEnvironment(
|
const String baseUrl = String.fromEnvironment(
|
||||||
'API_BASE_URL',
|
'API_BASE_URL',
|
||||||
defaultValue: 'http://192.168.10.139:5000',
|
defaultValue: 'http://10.4.212.224:5000',
|
||||||
);
|
);
|
||||||
|
|
||||||
class ApiException implements Exception {
|
class ApiException implements Exception {
|
||||||
@@ -19,21 +19,46 @@ class ApiException implements Exception {
|
|||||||
String toString() => message;
|
String toString() => message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
typedef AuthExpiredListener = void Function();
|
||||||
|
|
||||||
|
class AuthExpiredNotifier {
|
||||||
|
final List<AuthExpiredListener> _listeners = [];
|
||||||
|
|
||||||
|
AuthExpiredListener addListener(AuthExpiredListener listener) {
|
||||||
|
_listeners.add(listener);
|
||||||
|
return () => _listeners.remove(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
void notify() {
|
||||||
|
for (final listener in List<AuthExpiredListener>.from(_listeners)) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Dio HTTP 客户端封装——带 token 注入、401 自动刷新
|
/// Dio HTTP 客户端封装——带 token 注入、401 自动刷新
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
final Dio _dio;
|
final Dio _dio;
|
||||||
final LocalDatabase _db;
|
final LocalDatabase _db;
|
||||||
|
final AuthExpiredNotifier? _authExpiredNotifier;
|
||||||
|
|
||||||
ApiClient({required LocalDatabase db})
|
ApiClient({
|
||||||
: _db = db,
|
required LocalDatabase db,
|
||||||
_dio = Dio(BaseOptions(
|
AuthExpiredNotifier? authExpiredNotifier,
|
||||||
|
}) : _db = db,
|
||||||
|
_authExpiredNotifier = authExpiredNotifier,
|
||||||
|
_dio = Dio(
|
||||||
|
BaseOptions(
|
||||||
baseUrl: baseUrl,
|
baseUrl: baseUrl,
|
||||||
connectTimeout: const Duration(seconds: 15),
|
connectTimeout: const Duration(seconds: 15),
|
||||||
receiveTimeout: const Duration(seconds: 60),
|
receiveTimeout: const Duration(seconds: 60),
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
)) {
|
),
|
||||||
|
) {
|
||||||
_dio.interceptors.add(_AuthInterceptor(this));
|
_dio.interceptors.add(_AuthInterceptor(this));
|
||||||
_dio.interceptors.add(LogInterceptor(requestBody: false, responseBody: false));
|
_dio.interceptors.add(
|
||||||
|
LogInterceptor(requestBody: false, responseBody: false),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Dio get dio => _dio;
|
Dio get dio => _dio;
|
||||||
@@ -51,8 +76,15 @@ class ApiClient {
|
|||||||
await _db.delete('refresh_token');
|
await _db.delete('refresh_token');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void notifyAuthExpired() {
|
||||||
|
_authExpiredNotifier?.notify();
|
||||||
|
}
|
||||||
|
|
||||||
/// 带 token 的 GET 请求
|
/// 带 token 的 GET 请求
|
||||||
Future<Response> get(String path, {Map<String, dynamic>? queryParameters}) async {
|
Future<Response> get(
|
||||||
|
String path, {
|
||||||
|
Map<String, dynamic>? queryParameters,
|
||||||
|
}) async {
|
||||||
return _request(_dio.get(path, queryParameters: queryParameters));
|
return _request(_dio.get(path, queryParameters: queryParameters));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,9 +104,16 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 上传文件(multipart),返回文件 URL
|
/// 上传文件(multipart),返回文件 URL
|
||||||
Future<String?> uploadFile(String path, File file, {String fieldName = 'file'}) async {
|
Future<String?> uploadFile(
|
||||||
|
String path,
|
||||||
|
File file, {
|
||||||
|
String fieldName = 'file',
|
||||||
|
}) async {
|
||||||
final formData = FormData.fromMap({
|
final formData = FormData.fromMap({
|
||||||
fieldName: await MultipartFile.fromFile(file.path, filename: file.path.split('/').last),
|
fieldName: await MultipartFile.fromFile(
|
||||||
|
file.path,
|
||||||
|
filename: file.path.split('/').last,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
final res = await _request(_dio.post(path, data: formData));
|
final res = await _request(_dio.post(path, data: formData));
|
||||||
final data = res.data;
|
final data = res.data;
|
||||||
@@ -118,7 +157,8 @@ class ApiClient {
|
|||||||
return _ensureBusinessSuccess(await request);
|
return _ensureBusinessSuccess(await request);
|
||||||
} on DioException catch (error) {
|
} on DioException catch (error) {
|
||||||
final body = error.response?.data;
|
final body = error.response?.data;
|
||||||
final message = body is Map && body['message']?.toString().trim().isNotEmpty == true
|
final message =
|
||||||
|
body is Map && body['message']?.toString().trim().isNotEmpty == true
|
||||||
? body['message'].toString()
|
? body['message'].toString()
|
||||||
: error.type == DioExceptionType.connectionTimeout ||
|
: error.type == DioExceptionType.connectionTimeout ||
|
||||||
error.type == DioExceptionType.receiveTimeout
|
error.type == DioExceptionType.receiveTimeout
|
||||||
@@ -144,7 +184,10 @@ class _AuthInterceptor extends Interceptor {
|
|||||||
_AuthInterceptor(this._client);
|
_AuthInterceptor(this._client);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
|
void onRequest(
|
||||||
|
RequestOptions options,
|
||||||
|
RequestInterceptorHandler handler,
|
||||||
|
) async {
|
||||||
if (!options.path.contains('/auth/')) {
|
if (!options.path.contains('/auth/')) {
|
||||||
final token = await _client.accessToken;
|
final token = await _client.accessToken;
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
@@ -160,20 +203,26 @@ class _AuthInterceptor extends Interceptor {
|
|||||||
final refresh = await _client.refreshToken;
|
final refresh = await _client.refreshToken;
|
||||||
if (refresh != null) {
|
if (refresh != null) {
|
||||||
try {
|
try {
|
||||||
final response = await Dio(BaseOptions(baseUrl: baseUrl))
|
final response = await Dio(
|
||||||
.post('/api/auth/refresh', data: {'refreshToken': refresh});
|
BaseOptions(baseUrl: baseUrl),
|
||||||
|
).post('/api/auth/refresh', data: {'refreshToken': refresh});
|
||||||
final data = response.data['data'];
|
final data = response.data['data'];
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
await _client.saveTokens(data['accessToken'], data['refreshToken']);
|
await _client.saveTokens(data['accessToken'], data['refreshToken']);
|
||||||
final opts = err.requestOptions;
|
final opts = err.requestOptions;
|
||||||
final token = data['accessToken'];
|
final token = data['accessToken'];
|
||||||
opts.headers['Authorization'] = 'Bearer $token';
|
opts.headers['Authorization'] = 'Bearer $token';
|
||||||
final retryResponse = await Dio(BaseOptions(baseUrl: baseUrl)).fetch(opts);
|
final retryResponse = await Dio(
|
||||||
|
BaseOptions(baseUrl: baseUrl),
|
||||||
|
).fetch(opts);
|
||||||
return handler.resolve(retryResponse);
|
return handler.resolve(retryResponse);
|
||||||
}
|
}
|
||||||
} catch (e) { log('[ApiClient] token刷新失败: $e'); }
|
} catch (e) {
|
||||||
|
log('[ApiClient] token刷新失败: $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await _client.clearTokens();
|
await _client.clearTokens();
|
||||||
|
_client.notifyAuthExpired();
|
||||||
}
|
}
|
||||||
handler.next(err);
|
handler.next(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,37 @@ class AppColors {
|
|||||||
static const Color meadowAccent = Color(0xFFBAE6FD);
|
static const Color meadowAccent = Color(0xFFBAE6FD);
|
||||||
static const Color sageAccent = Color(0xFFE9D5FF);
|
static const Color sageAccent = Color(0xFFE9D5FF);
|
||||||
|
|
||||||
|
static const Color health = Color(0xFF3B82F6);
|
||||||
|
static const Color healthLight = Color(0xFFEFF6FF);
|
||||||
|
static const Color healthBorder = Color(0xFFBFDBFE);
|
||||||
|
static const Color medication = Color(0xFF8B5CF6);
|
||||||
|
static const Color medicationLight = Color(0xFFF5F3FF);
|
||||||
|
static const Color medicationBorder = Color(0xFFDDD6FE);
|
||||||
|
static const Color exercise = Color(0xFF10B981);
|
||||||
|
static const Color exerciseLight = Color(0xFFECFDF5);
|
||||||
|
static const Color exerciseBorder = Color(0xFFA7F3D0);
|
||||||
|
static const Color report = Color(0xFF2563EB);
|
||||||
|
static const Color reportLight = Color(0xFFEEF2FF);
|
||||||
|
static const Color reportBorder = Color(0xFFC7D2FE);
|
||||||
|
static const Color diet = Color(0xFFF97316);
|
||||||
|
static const Color dietLight = Color(0xFFFFF7ED);
|
||||||
|
static const Color dietBorder = Color(0xFFFED7AA);
|
||||||
|
static const Color device = Color(0xFF06B6D4);
|
||||||
|
static const Color deviceLight = Color(0xFFECFEFF);
|
||||||
|
static const Color deviceBorder = Color(0xFFA5F3FC);
|
||||||
|
static const Color notification = Color(0xFF7C5CFF);
|
||||||
|
static const Color notificationLight = Color(0xFFF5F3FF);
|
||||||
|
static const Color notificationBorder = Color(0xFFDDD6FE);
|
||||||
|
static const Color doctor = Color(0xFF0F766E);
|
||||||
|
static const Color doctorLight = Color(0xFFF0FDFA);
|
||||||
|
static const Color doctorBorder = Color(0xFF99F6E4);
|
||||||
|
static const Color calendar = Color(0xFF6366F1);
|
||||||
|
static const Color calendarLight = Color(0xFFEEF2FF);
|
||||||
|
static const Color calendarBorder = Color(0xFFC7D2FE);
|
||||||
|
static const Color followup = Color(0xFFDB2777);
|
||||||
|
static const Color followupLight = Color(0xFFFDF2F8);
|
||||||
|
static const Color followupBorder = Color(0xFFFBCFE8);
|
||||||
|
|
||||||
static const Color background = Color(0xFFF8FAFF);
|
static const Color background = Color(0xFFF8FAFF);
|
||||||
static const Color backgroundSoft = Color(0xFFFFFFFF);
|
static const Color backgroundSoft = Color(0xFFFFFFFF);
|
||||||
static const Color cardBackground = Color(0xFFFFFFFF);
|
static const Color cardBackground = Color(0xFFFFFFFF);
|
||||||
@@ -131,6 +162,66 @@ class AppColors {
|
|||||||
colors: [Color(0xFF60A5FA), Color(0xFF8B5CF6)],
|
colors: [Color(0xFF60A5FA), Color(0xFF8B5CF6)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
static const LinearGradient healthGradient = LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFF60A5FA), Color(0xFF2563EB)],
|
||||||
|
);
|
||||||
|
|
||||||
|
static const LinearGradient medicationGradient = LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFFA78BFA), Color(0xFF7C3AED)],
|
||||||
|
);
|
||||||
|
|
||||||
|
static const LinearGradient exerciseGradient = LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFF34D399), Color(0xFF059669)],
|
||||||
|
);
|
||||||
|
|
||||||
|
static const LinearGradient reportGradient = LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFF60A5FA), Color(0xFF2563EB)],
|
||||||
|
);
|
||||||
|
|
||||||
|
static const LinearGradient dietGradient = LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFFFB923C), Color(0xFFF97316)],
|
||||||
|
);
|
||||||
|
|
||||||
|
static const LinearGradient deviceGradient = LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFF22D3EE), Color(0xFF0891B2)],
|
||||||
|
);
|
||||||
|
|
||||||
|
static const LinearGradient notificationGradient = LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFF8B5CF6), Color(0xFF6366F1)],
|
||||||
|
);
|
||||||
|
|
||||||
|
static const LinearGradient doctorCareGradient = LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFF14B8A6), Color(0xFF0F766E)],
|
||||||
|
);
|
||||||
|
|
||||||
|
static const LinearGradient calendarGradient = LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFF818CF8), Color(0xFF4F46E5)],
|
||||||
|
);
|
||||||
|
|
||||||
|
static const LinearGradient followupGradient = LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFFF472B6), Color(0xFFDB2777)],
|
||||||
|
);
|
||||||
|
|
||||||
static List<BoxShadow> get cardShadow => [
|
static List<BoxShadow> get cardShadow => [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: const Color(0xFF101828).withValues(alpha: 0.07),
|
color: const Color(0xFF101828).withValues(alpha: 0.07),
|
||||||
|
|||||||
171
health_app/lib/core/app_design_tokens.dart
Normal file
171
health_app/lib/core/app_design_tokens.dart
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'app_colors.dart';
|
||||||
|
|
||||||
|
class AppSpacing {
|
||||||
|
AppSpacing._();
|
||||||
|
|
||||||
|
static const double xs = 4;
|
||||||
|
static const double sm = 8;
|
||||||
|
static const double md = 12;
|
||||||
|
static const double lg = 16;
|
||||||
|
static const double xl = 20;
|
||||||
|
static const double xxl = 24;
|
||||||
|
|
||||||
|
static const EdgeInsets page = EdgeInsets.fromLTRB(16, 12, 16, 24);
|
||||||
|
static const EdgeInsets pageWithFab = EdgeInsets.fromLTRB(16, 12, 16, 88);
|
||||||
|
static const EdgeInsets panel = EdgeInsets.all(16);
|
||||||
|
static const EdgeInsets listItem = EdgeInsets.all(16);
|
||||||
|
static const EdgeInsets sheet = EdgeInsets.fromLTRB(20, 18, 20, 24);
|
||||||
|
static const EdgeInsets drawer = EdgeInsets.symmetric(horizontal: 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppRadius {
|
||||||
|
AppRadius._();
|
||||||
|
|
||||||
|
static const double xs = 6;
|
||||||
|
static const double sm = 10;
|
||||||
|
static const double md = 14;
|
||||||
|
static const double lg = 16;
|
||||||
|
static const double xl = 20;
|
||||||
|
static const double card = 24;
|
||||||
|
static const double pill = 999;
|
||||||
|
|
||||||
|
static BorderRadius get xsBorder => BorderRadius.circular(xs);
|
||||||
|
static BorderRadius get smBorder => BorderRadius.circular(sm);
|
||||||
|
static BorderRadius get mdBorder => BorderRadius.circular(md);
|
||||||
|
static BorderRadius get lgBorder => BorderRadius.circular(lg);
|
||||||
|
static BorderRadius get xlBorder => BorderRadius.circular(xl);
|
||||||
|
static BorderRadius get cardBorder => BorderRadius.circular(card);
|
||||||
|
static BorderRadius get pillBorder => BorderRadius.circular(pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppShadows {
|
||||||
|
AppShadows._();
|
||||||
|
|
||||||
|
static List<BoxShadow> get none => const [];
|
||||||
|
|
||||||
|
static List<BoxShadow> get soft => [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF101828).withValues(alpha: 0.045),
|
||||||
|
blurRadius: 14,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
static List<BoxShadow> get panel => [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF101828).withValues(alpha: 0.07),
|
||||||
|
blurRadius: 22,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
static List<BoxShadow> get floating => [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF101828).withValues(alpha: 0.14),
|
||||||
|
blurRadius: 28,
|
||||||
|
offset: const Offset(0, 14),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppTextStyles {
|
||||||
|
AppTextStyles._();
|
||||||
|
|
||||||
|
static const TextStyle appBarTitle = TextStyle(
|
||||||
|
fontSize: 19,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const TextStyle summaryTitle = TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
height: 1.2,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const TextStyle summarySubtitle = TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
height: 1.35,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const TextStyle sectionTitle = TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const TextStyle listTitle = TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
height: 1.22,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const TextStyle primaryListTitle = TextStyle(
|
||||||
|
fontSize: 19,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
height: 1.22,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const TextStyle listSubtitle = TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
height: 1.35,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const TextStyle formLabel = TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const TextStyle formValue = TextStyle(
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const TextStyle tag = TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
height: 1.15,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const TextStyle button = TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const TextStyle miniButton = TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const TextStyle chatBody = TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
height: 1.5,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const TextStyle chatTitle = TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
height: 1.35,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const TextStyle aiNote = TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
height: 1.25,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
168
health_app/lib/core/app_module_visuals.dart
Normal file
168
health_app/lib/core/app_module_visuals.dart
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
|
import 'app_colors.dart';
|
||||||
|
|
||||||
|
enum AppModule {
|
||||||
|
ai,
|
||||||
|
health,
|
||||||
|
medication,
|
||||||
|
exercise,
|
||||||
|
report,
|
||||||
|
diet,
|
||||||
|
device,
|
||||||
|
notification,
|
||||||
|
doctor,
|
||||||
|
calendar,
|
||||||
|
followup,
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppModuleVisual {
|
||||||
|
final AppModule module;
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final Color color;
|
||||||
|
final Color lightColor;
|
||||||
|
final Color borderColor;
|
||||||
|
final LinearGradient gradient;
|
||||||
|
|
||||||
|
const AppModuleVisual({
|
||||||
|
required this.module,
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
required this.color,
|
||||||
|
required this.lightColor,
|
||||||
|
required this.borderColor,
|
||||||
|
required this.gradient,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppModuleVisuals {
|
||||||
|
AppModuleVisuals._();
|
||||||
|
|
||||||
|
static const ai = AppModuleVisual(
|
||||||
|
module: AppModule.ai,
|
||||||
|
label: 'AI',
|
||||||
|
icon: LucideIcons.bot,
|
||||||
|
color: AppColors.primary,
|
||||||
|
lightColor: AppColors.primarySoft,
|
||||||
|
borderColor: AppColors.primaryLight,
|
||||||
|
gradient: AppColors.primaryGradient,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const health = AppModuleVisual(
|
||||||
|
module: AppModule.health,
|
||||||
|
label: '健康',
|
||||||
|
icon: LucideIcons.heartPulse,
|
||||||
|
color: AppColors.health,
|
||||||
|
lightColor: AppColors.healthLight,
|
||||||
|
borderColor: AppColors.healthBorder,
|
||||||
|
gradient: AppColors.healthGradient,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const medication = AppModuleVisual(
|
||||||
|
module: AppModule.medication,
|
||||||
|
label: '用药',
|
||||||
|
icon: LucideIcons.pill,
|
||||||
|
color: AppColors.medication,
|
||||||
|
lightColor: AppColors.medicationLight,
|
||||||
|
borderColor: AppColors.medicationBorder,
|
||||||
|
gradient: AppColors.medicationGradient,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const exercise = AppModuleVisual(
|
||||||
|
module: AppModule.exercise,
|
||||||
|
label: '运动',
|
||||||
|
icon: LucideIcons.footprints,
|
||||||
|
color: AppColors.exercise,
|
||||||
|
lightColor: AppColors.exerciseLight,
|
||||||
|
borderColor: AppColors.exerciseBorder,
|
||||||
|
gradient: AppColors.exerciseGradient,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const report = AppModuleVisual(
|
||||||
|
module: AppModule.report,
|
||||||
|
label: '报告',
|
||||||
|
icon: LucideIcons.fileText,
|
||||||
|
color: AppColors.report,
|
||||||
|
lightColor: AppColors.reportLight,
|
||||||
|
borderColor: AppColors.reportBorder,
|
||||||
|
gradient: AppColors.reportGradient,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const diet = AppModuleVisual(
|
||||||
|
module: AppModule.diet,
|
||||||
|
label: '饮食',
|
||||||
|
icon: LucideIcons.utensils,
|
||||||
|
color: AppColors.diet,
|
||||||
|
lightColor: AppColors.dietLight,
|
||||||
|
borderColor: AppColors.dietBorder,
|
||||||
|
gradient: AppColors.dietGradient,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const device = AppModuleVisual(
|
||||||
|
module: AppModule.device,
|
||||||
|
label: '设备',
|
||||||
|
icon: LucideIcons.bluetooth,
|
||||||
|
color: AppColors.device,
|
||||||
|
lightColor: AppColors.deviceLight,
|
||||||
|
borderColor: AppColors.deviceBorder,
|
||||||
|
gradient: AppColors.deviceGradient,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const notification = AppModuleVisual(
|
||||||
|
module: AppModule.notification,
|
||||||
|
label: '通知',
|
||||||
|
icon: LucideIcons.bell,
|
||||||
|
color: AppColors.notification,
|
||||||
|
lightColor: AppColors.notificationLight,
|
||||||
|
borderColor: AppColors.notificationBorder,
|
||||||
|
gradient: AppColors.notificationGradient,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const doctor = AppModuleVisual(
|
||||||
|
module: AppModule.doctor,
|
||||||
|
label: '医生',
|
||||||
|
icon: LucideIcons.stethoscope,
|
||||||
|
color: AppColors.doctor,
|
||||||
|
lightColor: AppColors.doctorLight,
|
||||||
|
borderColor: AppColors.doctorBorder,
|
||||||
|
gradient: AppColors.doctorCareGradient,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const calendar = AppModuleVisual(
|
||||||
|
module: AppModule.calendar,
|
||||||
|
label: '日历',
|
||||||
|
icon: LucideIcons.calendarDays,
|
||||||
|
color: AppColors.calendar,
|
||||||
|
lightColor: AppColors.calendarLight,
|
||||||
|
borderColor: AppColors.calendarBorder,
|
||||||
|
gradient: AppColors.calendarGradient,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const followup = AppModuleVisual(
|
||||||
|
module: AppModule.followup,
|
||||||
|
label: '随访',
|
||||||
|
icon: LucideIcons.calendarCheck,
|
||||||
|
color: AppColors.followup,
|
||||||
|
lightColor: AppColors.followupLight,
|
||||||
|
borderColor: AppColors.followupBorder,
|
||||||
|
gradient: AppColors.followupGradient,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const values = <AppModule, AppModuleVisual>{
|
||||||
|
AppModule.ai: ai,
|
||||||
|
AppModule.health: health,
|
||||||
|
AppModule.medication: medication,
|
||||||
|
AppModule.exercise: exercise,
|
||||||
|
AppModule.report: report,
|
||||||
|
AppModule.diet: diet,
|
||||||
|
AppModule.device: device,
|
||||||
|
AppModule.notification: notification,
|
||||||
|
AppModule.doctor: doctor,
|
||||||
|
AppModule.calendar: calendar,
|
||||||
|
AppModule.followup: followup,
|
||||||
|
};
|
||||||
|
|
||||||
|
static AppModuleVisual of(AppModule module) => values[module] ?? ai;
|
||||||
|
}
|
||||||
@@ -28,6 +28,18 @@ import '../pages/doctor/doctor_profile_page.dart';
|
|||||||
import '../pages/admin/admin_home_page.dart';
|
import '../pages/admin/admin_home_page.dart';
|
||||||
import '../pages/admin/admin_add_doctor_page.dart';
|
import '../pages/admin/admin_add_doctor_page.dart';
|
||||||
import '../providers/auth_provider.dart' show userRoleProvider;
|
import '../providers/auth_provider.dart' show userRoleProvider;
|
||||||
|
import '../widgets/app_error_state.dart';
|
||||||
|
|
||||||
|
Widget _missingParamPage() {
|
||||||
|
return const Scaffold(
|
||||||
|
body: AppErrorState(title: '页面参数错误', subtitle: '缺少打开页面所需的信息,请返回后重试'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _requiredParam(Map<String, String> params, String key) {
|
||||||
|
final value = params[key]?.trim();
|
||||||
|
return value == null || value.isEmpty ? null : value;
|
||||||
|
}
|
||||||
|
|
||||||
/// 根据路由信息返回对应页面
|
/// 根据路由信息返回对应页面
|
||||||
Widget buildPage(RouteInfo route, WidgetRef ref) {
|
Widget buildPage(RouteInfo route, WidgetRef ref) {
|
||||||
@@ -59,7 +71,8 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
|||||||
case 'reports':
|
case 'reports':
|
||||||
return const ReportListPage();
|
return const ReportListPage();
|
||||||
case 'aiAnalysis':
|
case 'aiAnalysis':
|
||||||
return AiAnalysisPage(id: params['id']!);
|
final id = _requiredParam(params, 'id');
|
||||||
|
return id == null ? _missingParamPage() : AiAnalysisPage(id: id);
|
||||||
case 'reportOriginal':
|
case 'reportOriginal':
|
||||||
return ReportOriginalPage(
|
return ReportOriginalPage(
|
||||||
url: params['url'] ?? '',
|
url: params['url'] ?? '',
|
||||||
@@ -82,13 +95,17 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
|||||||
case 'doctorSettings':
|
case 'doctorSettings':
|
||||||
return const DoctorSettingsPage();
|
return const DoctorSettingsPage();
|
||||||
case 'doctorPatientDetail':
|
case 'doctorPatientDetail':
|
||||||
return DoctorPatientDetailPage(id: params['id']!);
|
final id = _requiredParam(params, 'id');
|
||||||
|
return id == null ? _missingParamPage() : DoctorPatientDetailPage(id: id);
|
||||||
case 'doctorChat':
|
case 'doctorChat':
|
||||||
return DoctorChatPage(id: params['id']!);
|
final id = _requiredParam(params, 'id');
|
||||||
|
return id == null ? _missingParamPage() : DoctorChatPage(id: id);
|
||||||
case 'doctorReportDetail':
|
case 'doctorReportDetail':
|
||||||
return DoctorReportDetailPage(id: params['id']!);
|
final id = _requiredParam(params, 'id');
|
||||||
|
return id == null ? _missingParamPage() : DoctorReportDetailPage(id: id);
|
||||||
case 'doctorFollowUpEdit':
|
case 'doctorFollowUpEdit':
|
||||||
return DoctorFollowUpEditPage(id: params['id']!);
|
final id = _requiredParam(params, 'id');
|
||||||
|
return id == null ? _missingParamPage() : DoctorFollowUpEditPage(id: id);
|
||||||
case 'devices':
|
case 'devices':
|
||||||
return const DeviceManagementPage();
|
return const DeviceManagementPage();
|
||||||
case 'deviceScan':
|
case 'deviceScan':
|
||||||
@@ -106,9 +123,11 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
|||||||
case 'conversationHistory':
|
case 'conversationHistory':
|
||||||
return const ConversationHistoryPage();
|
return const ConversationHistoryPage();
|
||||||
case 'staticText':
|
case 'staticText':
|
||||||
return StaticTextPage(type: params['type']!);
|
final type = _requiredParam(params, 'type');
|
||||||
|
return type == null ? _missingParamPage() : StaticTextPage(type: type);
|
||||||
case 'dietDetail':
|
case 'dietDetail':
|
||||||
return DietRecordDetailPage(id: params['id']!);
|
final id = _requiredParam(params, 'id');
|
||||||
|
return id == null ? _missingParamPage() : DietRecordDetailPage(id: id);
|
||||||
default:
|
default:
|
||||||
return const LoginPage();
|
return const LoginPage();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/data_providers.dart' show adminServiceProvider;
|
import '../../providers/data_providers.dart' show adminServiceProvider;
|
||||||
|
import '../../widgets/app_toast.dart';
|
||||||
|
|
||||||
class AdminAddDoctorPage extends ConsumerStatefulWidget {
|
class AdminAddDoctorPage extends ConsumerStatefulWidget {
|
||||||
const AdminAddDoctorPage({super.key});
|
const AdminAddDoctorPage({super.key});
|
||||||
@@ -30,15 +31,11 @@ class _AdminAddDoctorPageState extends ConsumerState<AdminAddDoctorPage> {
|
|||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
if (_phoneCtrl.text.trim().isEmpty) {
|
if (_phoneCtrl.text.trim().isEmpty) {
|
||||||
ScaffoldMessenger.of(
|
AppToast.show(context, '手机号不能为空', type: AppToastType.warning);
|
||||||
context,
|
|
||||||
).showSnackBar(const SnackBar(content: Text('手机号不能为空')));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_nameCtrl.text.trim().isEmpty) {
|
if (_nameCtrl.text.trim().isEmpty) {
|
||||||
ScaffoldMessenger.of(
|
AppToast.show(context, '姓名不能为空', type: AppToastType.warning);
|
||||||
context,
|
|
||||||
).showSnackBar(const SnackBar(content: Text('姓名不能为空')));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() => _saving = true);
|
setState(() => _saving = true);
|
||||||
@@ -51,19 +48,12 @@ class _AdminAddDoctorPageState extends ConsumerState<AdminAddDoctorPage> {
|
|||||||
'professionalDirection': _directionCtrl.text.trim(),
|
'professionalDirection': _directionCtrl.text.trim(),
|
||||||
});
|
});
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppToast.show(context, '添加成功', type: AppToastType.success);
|
||||||
const SnackBar(
|
|
||||||
content: Text('添加成功'),
|
|
||||||
backgroundColor: AppColors.success,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
popRoute(ref);
|
popRoute(ref);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppToast.show(context, '添加失败: $e', type: AppToastType.error);
|
||||||
SnackBar(content: Text('添加失败: $e'), backgroundColor: AppColors.error),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _saving = false);
|
if (mounted) setState(() => _saving = false);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:fl_chart/fl_chart.dart';
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -5,6 +7,7 @@ import '../../core/app_colors.dart';
|
|||||||
import '../../core/app_theme.dart';
|
import '../../core/app_theme.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
|
import '../../widgets/app_toast.dart';
|
||||||
import '../../providers/data_providers.dart';
|
import '../../providers/data_providers.dart';
|
||||||
|
|
||||||
/// 健康概览趋势页 — 五大指标合到一个页面
|
/// 健康概览趋势页 — 五大指标合到一个页面
|
||||||
@@ -21,6 +24,37 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
List<Map<String, dynamic>> _allRecords = [];
|
List<Map<String, dynamic>> _allRecords = [];
|
||||||
List<Map<String, dynamic>> _filtered = [];
|
List<Map<String, dynamic>> _filtered = [];
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
|
int? _selectedIdx; // 当前选中的数据点(_filtered 索引),null = 未选中
|
||||||
|
|
||||||
|
/// 计算"好看"的坐标轴步长,最小为 1(保证标签都是整数)。
|
||||||
|
/// 目标 3 段 = 4 个标签;若吸附后超过 3 段,自动放大步长。
|
||||||
|
double _niceStep(double range) {
|
||||||
|
if (range <= 0) return 1;
|
||||||
|
final target = range / 3;
|
||||||
|
if (target < 1) return 1;
|
||||||
|
final magnitude = pow(10, (log(target) / log(10)).floor()).toDouble();
|
||||||
|
final normalized = target / magnitude;
|
||||||
|
double nice;
|
||||||
|
if (normalized <= 1) {
|
||||||
|
nice = 1;
|
||||||
|
} else if (normalized <= 2) {
|
||||||
|
nice = 2;
|
||||||
|
} else if (normalized <= 5) {
|
||||||
|
nice = 5;
|
||||||
|
} else {
|
||||||
|
nice = 10;
|
||||||
|
}
|
||||||
|
return nice * magnitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 步长过大时,跳到下一个"好看"的步长(1→2→5→10→20→50…)
|
||||||
|
double _bumpStep(double step) {
|
||||||
|
final magnitude = pow(10, (log(step) / log(10)).floor()).toDouble();
|
||||||
|
final normalized = step / magnitude;
|
||||||
|
if (normalized < 1.5) return 2 * magnitude;
|
||||||
|
if (normalized < 4) return 5 * magnitude;
|
||||||
|
return 10 * magnitude;
|
||||||
|
}
|
||||||
|
|
||||||
static const _metrics = [
|
static const _metrics = [
|
||||||
{'key': 'blood_pressure', 'label': '血压', 'color': Color(0xFFEF4444)},
|
{'key': 'blood_pressure', 'label': '血压', 'color': Color(0xFFEF4444)},
|
||||||
@@ -127,7 +161,10 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _switchMetric(String key) {
|
void _switchMetric(String key) {
|
||||||
setState(() => _selected = key);
|
setState(() {
|
||||||
|
_selected = key;
|
||||||
|
_selectedIdx = null;
|
||||||
|
});
|
||||||
_filter();
|
_filter();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,9 +268,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ScaffoldMessenger.of(
|
AppToast.show(context, '录入失败', type: AppToastType.error);
|
||||||
context,
|
|
||||||
).showSnackBar(const SnackBar(content: Text('录入失败')));
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
@@ -385,6 +420,20 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
maxV += padding;
|
maxV += padding;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Y 轴:吸附到"好看"的整数刻度,标签全部为整数,最多 4 个(3 段)
|
||||||
|
var yStep = _niceStep(maxV - minV);
|
||||||
|
minV = (minV / yStep).floor() * yStep;
|
||||||
|
maxV = (maxV / yStep).ceil() * yStep;
|
||||||
|
while ((maxV - minV) / yStep > 3) {
|
||||||
|
yStep = _bumpStep(yStep);
|
||||||
|
minV = (minV / yStep).floor() * yStep;
|
||||||
|
maxV = (maxV / yStep).ceil() * yStep;
|
||||||
|
}
|
||||||
|
|
||||||
|
// X 轴刻度间隔:日标签短,可以排密一点
|
||||||
|
final xInterval =
|
||||||
|
spots.length > 60 ? 5.0 : spots.length > 30 ? 2.0 : 1.0;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.fromLTRB(8, 20, 16, 12),
|
padding: const EdgeInsets.fromLTRB(8, 20, 16, 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -424,34 +473,62 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 200,
|
height: 200,
|
||||||
child: LineChart(
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final chartWidth = constraints.maxWidth;
|
||||||
|
final paintWidth = chartWidth - 40.0; // 减左侧轴
|
||||||
|
final paintHeight = 200.0 - 36.0; // 减底部轴
|
||||||
|
// 计算选中点的像素位置
|
||||||
|
Offset? tooltipPos;
|
||||||
|
String? tooltipText1;
|
||||||
|
String? tooltipText2;
|
||||||
|
if (_selectedIdx != null &&
|
||||||
|
_selectedIdx! >= 0 &&
|
||||||
|
_selectedIdx! < _filtered.length) {
|
||||||
|
final r = _filtered[_selectedIdx!];
|
||||||
|
final v = _isBP
|
||||||
|
? (r['systolic'] as num?)?.toDouble()
|
||||||
|
: (r['value'] as num?)?.toDouble();
|
||||||
|
if (v != null && spots.length > 1) {
|
||||||
|
final px = 40.0 +
|
||||||
|
(_selectedIdx! / (spots.length - 1)) * paintWidth;
|
||||||
|
final py =
|
||||||
|
paintHeight * (1 - (v - minV) / (maxV - minV));
|
||||||
|
final d = r['date'] as DateTime;
|
||||||
|
tooltipPos = Offset(px, py);
|
||||||
|
tooltipText1 =
|
||||||
|
'${d.month}/${d.day} ${d.hour}:${d.minute.toString().padLeft(2, '0')}';
|
||||||
|
tooltipText2 = _isBP
|
||||||
|
? '${r['systolic']}/${r['diastolic']} $_unit'
|
||||||
|
: '$v $_unit';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
LineChart(
|
||||||
LineChartData(
|
LineChartData(
|
||||||
minX: 0,
|
minX: 0,
|
||||||
maxX: (spots.length - 1).toDouble(),
|
maxX: (spots.length - 1).toDouble(),
|
||||||
minY: minV,
|
minY: minV,
|
||||||
maxY: maxV,
|
maxY: maxV,
|
||||||
gridData: FlGridData(
|
gridData: const FlGridData(show: false),
|
||||||
show: true,
|
|
||||||
drawVerticalLine: false,
|
|
||||||
horizontalInterval: range > 50
|
|
||||||
? 10
|
|
||||||
: range > 20
|
|
||||||
? 5
|
|
||||||
: range > 5
|
|
||||||
? 2
|
|
||||||
: 1,
|
|
||||||
),
|
|
||||||
borderData: FlBorderData(show: false),
|
borderData: FlBorderData(show: false),
|
||||||
titlesData: FlTitlesData(
|
titlesData: FlTitlesData(
|
||||||
leftTitles: AxisTitles(
|
leftTitles: AxisTitles(
|
||||||
sideTitles: SideTitles(
|
sideTitles: SideTitles(
|
||||||
showTitles: true,
|
showTitles: true,
|
||||||
reservedSize: 40,
|
reservedSize: 40,
|
||||||
getTitlesWidget: (v, meta) => Text(
|
interval: yStep,
|
||||||
v.toStringAsFixed(v.truncateToDouble() == v ? 0 : 1),
|
getTitlesWidget: (v, meta) => Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 6),
|
||||||
|
child: Text(
|
||||||
|
v.toStringAsFixed(0),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
color: AppColors.textHint,
|
color: AppColors.textHint,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -459,24 +536,43 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
bottomTitles: AxisTitles(
|
bottomTitles: AxisTitles(
|
||||||
sideTitles: SideTitles(
|
sideTitles: SideTitles(
|
||||||
showTitles: true,
|
showTitles: true,
|
||||||
reservedSize: 24,
|
reservedSize: 36,
|
||||||
interval: spots.length > 30
|
interval: xInterval,
|
||||||
? 7
|
|
||||||
: spots.length > 14
|
|
||||||
? 3
|
|
||||||
: 1,
|
|
||||||
getTitlesWidget: (v, meta) {
|
getTitlesWidget: (v, meta) {
|
||||||
final idx = v.toInt();
|
final idx = v.toInt();
|
||||||
if (idx < 0 || idx >= _filtered.length) {
|
if (idx < 0 || idx >= _filtered.length) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
final d = _filtered[idx]['date'] as DateTime;
|
final d = _filtered[idx]['date'] as DateTime;
|
||||||
return Text(
|
// 当前可见刻度与上一个可见刻度相比,月份是否变化
|
||||||
'${d.month}/${d.day}',
|
final prevIdx = idx - xInterval.toInt();
|
||||||
|
final showMonth = idx == 0 ||
|
||||||
|
prevIdx < 0 ||
|
||||||
|
(_filtered[prevIdx]['date'] as DateTime).month !=
|
||||||
|
d.month;
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${d.day}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: AppColors.textHint,
|
color: AppColors.textHint,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
if (showMonth) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'${d.month}月',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -491,46 +587,120 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
lineBarsData: [
|
lineBarsData: [
|
||||||
LineChartBarData(
|
LineChartBarData(
|
||||||
spots: spots,
|
spots: spots,
|
||||||
isCurved: true,
|
isCurved: false,
|
||||||
color: _color,
|
color: _color,
|
||||||
barWidth: 2.5,
|
barWidth: 2.5,
|
||||||
|
isStrokeCapRound: true,
|
||||||
dotData: FlDotData(
|
dotData: FlDotData(
|
||||||
show: spots.length <= 60,
|
show: spots.length <= 30,
|
||||||
getDotPainter: (spot, _, _, _) => FlDotCirclePainter(
|
getDotPainter: (spot, _, _, _) {
|
||||||
radius: 3,
|
final isSelected = spot.x.toInt() == _selectedIdx;
|
||||||
|
if (isSelected) {
|
||||||
|
return FlDotCirclePainter(
|
||||||
|
radius: 5.5,
|
||||||
color: _color,
|
color: _color,
|
||||||
strokeWidth: 1,
|
strokeWidth: 2.5,
|
||||||
strokeColor: Colors.white,
|
strokeColor: Colors.white,
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
return FlDotCirclePainter(
|
||||||
|
radius: 3.5,
|
||||||
|
color: Colors.white,
|
||||||
|
strokeWidth: 2,
|
||||||
|
strokeColor: _color,
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
belowBarData: BarAreaData(
|
belowBarData: BarAreaData(
|
||||||
show: true,
|
show: true,
|
||||||
color: _color.withAlpha(20),
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
_color.withValues(alpha: 0.22),
|
||||||
|
_color.withValues(alpha: 0.02),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
shadow: Shadow(
|
||||||
|
color: _color.withValues(alpha: 0.18),
|
||||||
|
blurRadius: 6,
|
||||||
|
offset: const Offset(0, 3),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
lineTouchData: LineTouchData(
|
lineTouchData: LineTouchData(
|
||||||
touchTooltipData: LineTouchTooltipData(
|
handleBuiltInTouches: false,
|
||||||
getTooltipItems: (spots) => spots.map((s) {
|
touchCallback: (event, response) {
|
||||||
final idx = s.x.toInt();
|
if (event is FlTapUpEvent) {
|
||||||
if (idx < 0 || idx >= _filtered.length) return null;
|
final spots = response?.lineBarSpots;
|
||||||
final r = _filtered[idx];
|
if (spots != null && spots.isNotEmpty) {
|
||||||
final d = r['date'] as DateTime;
|
final idx = spots.first.x.toInt();
|
||||||
final v = _isBP
|
setState(() =>
|
||||||
? '${r['systolic']}/${r['diastolic']}'
|
_selectedIdx = (_selectedIdx == idx) ? null : idx);
|
||||||
: '${r['value']}';
|
} else {
|
||||||
return LineTooltipItem(
|
setState(() => _selectedIdx = null);
|
||||||
'${d.month}/${d.day} ${d.hour}:${d.minute.toString().padLeft(2, '0')}\n$v $_unit',
|
}
|
||||||
TextStyle(
|
}
|
||||||
color: _color,
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
duration: Duration.zero,
|
||||||
|
),
|
||||||
|
if (tooltipPos != null)
|
||||||
|
Positioned(
|
||||||
|
left: tooltipPos.dx.clamp(60.0, chartWidth - 60.0),
|
||||||
|
top: tooltipPos.dy - 56,
|
||||||
|
child: FractionalTranslation(
|
||||||
|
translation: const Offset(-0.5, 0),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: _color.withValues(alpha: 0.35),
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF101828)
|
||||||
|
.withValues(alpha: 0.14),
|
||||||
|
blurRadius: 16,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
tooltipText1!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
tooltipText2!,
|
||||||
|
style: TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w600,
|
color: _color,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}).toList(),
|
},
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -547,7 +717,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const Text(
|
||||||
'历史记录',
|
'录入记录',
|
||||||
style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700),
|
style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
@@ -565,6 +735,8 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
final date = r['date'] as DateTime;
|
final date = r['date'] as DateTime;
|
||||||
final abnormal = r['isAbnormal'] == true;
|
final abnormal = r['isAbnormal'] == true;
|
||||||
final id = r['id']?.toString() ?? '';
|
final id = r['id']?.toString() ?? '';
|
||||||
|
final idx = _filtered.indexOf(r);
|
||||||
|
final isSelected = idx == _selectedIdx;
|
||||||
String display;
|
String display;
|
||||||
if (_isBP) {
|
if (_isBP) {
|
||||||
display = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}';
|
display = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}';
|
||||||
@@ -591,19 +763,35 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_allRecords.removeWhere((x) => x['id']?.toString() == id);
|
_allRecords.removeWhere((x) => x['id']?.toString() == id);
|
||||||
_filtered.removeWhere((x) => x['id']?.toString() == id);
|
_filtered.removeWhere((x) => x['id']?.toString() == id);
|
||||||
|
_selectedIdx = null;
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Container(
|
child: GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
if (idx < 0) return;
|
||||||
|
setState(() =>
|
||||||
|
_selectedIdx = isSelected ? null : idx);
|
||||||
|
},
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
margin: const EdgeInsets.only(bottom: 6),
|
margin: const EdgeInsets.only(bottom: 6),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: isSelected
|
||||||
|
? _color.withValues(alpha: 0.15)
|
||||||
|
: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: AppColors.border),
|
border: Border.all(
|
||||||
|
color: isSelected
|
||||||
|
? _color.withValues(alpha: 0.55)
|
||||||
|
: AppColors.border,
|
||||||
|
width: isSelected ? 1.5 : 1,
|
||||||
|
),
|
||||||
boxShadow: AppColors.cardShadowLight,
|
boxShadow: AppColors.cardShadowLight,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -671,6 +859,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
|
import '../../core/app_module_visuals.dart';
|
||||||
import '../../core/app_theme.dart';
|
import '../../core/app_theme.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../models/ble_device.dart';
|
import '../../models/ble_device.dart';
|
||||||
@@ -15,6 +16,9 @@ import '../../providers/auth_provider.dart';
|
|||||||
import '../../providers/omron_device_provider.dart';
|
import '../../providers/omron_device_provider.dart';
|
||||||
import '../../services/health_ble_service.dart';
|
import '../../services/health_ble_service.dart';
|
||||||
import '../../widgets/ble_sync_dialog.dart';
|
import '../../widgets/ble_sync_dialog.dart';
|
||||||
|
import '../../widgets/app_toast.dart';
|
||||||
|
|
||||||
|
const _deviceVisual = AppModuleVisuals.device;
|
||||||
|
|
||||||
class DeviceManagementPage extends ConsumerStatefulWidget {
|
class DeviceManagementPage extends ConsumerStatefulWidget {
|
||||||
const DeviceManagementPage({super.key});
|
const DeviceManagementPage({super.key});
|
||||||
@@ -39,6 +43,7 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|||||||
DateTime? _scanStartedAt;
|
DateTime? _scanStartedAt;
|
||||||
String _activeScanBoundKey = '';
|
String _activeScanBoundKey = '';
|
||||||
bool _scanning = false;
|
bool _scanning = false;
|
||||||
|
bool _permissionBlocked = false;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -66,18 +71,20 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _startForegroundScan() async {
|
Future<void> _startForegroundScan() async {
|
||||||
await [Permission.bluetoothScan, Permission.bluetoothConnect].request();
|
if (!await _ensureBlePermissions()) {
|
||||||
|
if (mounted) setState(() => _scanning = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (_disposed || !mounted) return;
|
if (_disposed || !mounted) return;
|
||||||
|
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
final locStatus = await Permission.locationWhenInUse.serviceStatus;
|
final locStatus = await Permission.locationWhenInUse.serviceStatus;
|
||||||
if (_disposed || !mounted) return;
|
if (_disposed || !mounted) return;
|
||||||
if (locStatus != ServiceStatus.enabled && mounted) {
|
if (locStatus != ServiceStatus.enabled && mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppToast.show(
|
||||||
const SnackBar(
|
context,
|
||||||
content: Text('请先打开定位服务,否则可能无法发现蓝牙设备'),
|
'请先打开定位服务,否则可能无法发现蓝牙设备',
|
||||||
backgroundColor: AppColors.warning,
|
type: AppToastType.warning,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,6 +127,42 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> _ensureBlePermissions() async {
|
||||||
|
final statuses = await [
|
||||||
|
Permission.bluetoothScan,
|
||||||
|
Permission.bluetoothConnect,
|
||||||
|
].request();
|
||||||
|
final granted = statuses.values.every((status) => status.isGranted);
|
||||||
|
if (granted) {
|
||||||
|
_permissionBlocked = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
_permissionBlocked = true;
|
||||||
|
if (_disposed || !mounted) return false;
|
||||||
|
AppToast.show(context, '请开启蓝牙权限后再同步设备', type: AppToastType.warning);
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('需要蓝牙权限'),
|
||||||
|
content: const Text('同步已绑定设备需要蓝牙权限,请在系统设置中开启后重试。'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('稍后再说'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
openAppSettings();
|
||||||
|
},
|
||||||
|
child: const Text('去设置'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
void _onScanResults(List<ScanResult> results) {
|
void _onScanResults(List<ScanResult> results) {
|
||||||
if (!mounted || _busyDeviceId != null) return;
|
if (!mounted || _busyDeviceId != null) return;
|
||||||
final scanStartedAt = _scanStartedAt;
|
final scanStartedAt = _scanStartedAt;
|
||||||
@@ -213,21 +256,11 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|||||||
}
|
}
|
||||||
} on TimeoutException {
|
} on TimeoutException {
|
||||||
if (!_disposed && mounted && _connectedDeviceId == bound.id) {
|
if (!_disposed && mounted && _connectedDeviceId == bound.id) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppToast.show(context, '已连接设备,但本次未收到测量数据', type: AppToastType.warning);
|
||||||
const SnackBar(
|
|
||||||
content: Text('已连接设备,但本次未收到测量数据'),
|
|
||||||
backgroundColor: AppColors.warning,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} on Exception {
|
} on Exception {
|
||||||
if (!_disposed && mounted) {
|
if (!_disposed && mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppToast.show(context, '数据录入失败,请稍后重试', type: AppToastType.error);
|
||||||
const SnackBar(
|
|
||||||
content: Text('数据录入失败,请稍后重试'),
|
|
||||||
backgroundColor: AppColors.error,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await _bleService.disconnect();
|
await _bleService.disconnect();
|
||||||
@@ -272,6 +305,7 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|||||||
if (devices.isNotEmpty &&
|
if (devices.isNotEmpty &&
|
||||||
!_scanning &&
|
!_scanning &&
|
||||||
_busyDeviceId == null &&
|
_busyDeviceId == null &&
|
||||||
|
!_permissionBlocked &&
|
||||||
_activeScanBoundKey != boundKey) {
|
_activeScanBoundKey != boundKey) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!_disposed && mounted) unawaited(_startForegroundScan());
|
if (!_disposed && mounted) unawaited(_startForegroundScan());
|
||||||
@@ -288,6 +322,7 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (nextKey != _activeScanBoundKey && _busyDeviceId == null) {
|
if (nextKey != _activeScanBoundKey && _busyDeviceId == null) {
|
||||||
|
if (_permissionBlocked) return;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!_disposed && mounted) unawaited(_startForegroundScan());
|
if (!_disposed && mounted) unawaited(_startForegroundScan());
|
||||||
});
|
});
|
||||||
@@ -308,8 +343,8 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|||||||
tooltip: '新增设备',
|
tooltip: '新增设备',
|
||||||
onPressed: () => pushRoute(ref, 'deviceScan'),
|
onPressed: () => pushRoute(ref, 'deviceScan'),
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
backgroundColor: const Color(0xFFEFF6FF),
|
backgroundColor: _deviceVisual.lightColor,
|
||||||
foregroundColor: const Color(0xFF2563EB),
|
foregroundColor: _deviceVisual.color,
|
||||||
fixedSize: const Size(40, 40),
|
fixedSize: const Size(40, 40),
|
||||||
),
|
),
|
||||||
icon: const Icon(Icons.add_rounded),
|
icon: const Icon(Icons.add_rounded),
|
||||||
@@ -402,11 +437,7 @@ class _DevicesHeader extends StatelessWidget {
|
|||||||
width: 50,
|
width: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: const LinearGradient(
|
gradient: _deviceVisual.gradient,
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [Color(0xFF60A5FA), Color(0xFF8B5CF6)],
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
@@ -474,15 +505,11 @@ class _DeviceSection extends StatelessWidget {
|
|||||||
width: 34,
|
width: 34,
|
||||||
height: 34,
|
height: 34,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFEFF6FF),
|
color: _deviceVisual.lightColor,
|
||||||
borderRadius: BorderRadius.circular(11),
|
borderRadius: BorderRadius.circular(11),
|
||||||
border: Border.all(color: AppColors.borderLight),
|
border: Border.all(color: AppColors.borderLight),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(type.icon, size: 19, color: _deviceVisual.color),
|
||||||
type.icon,
|
|
||||||
size: 19,
|
|
||||||
color: const Color(0xFF2563EB),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import '../../models/bp_reading.dart';
|
|||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../providers/omron_device_provider.dart';
|
import '../../providers/omron_device_provider.dart';
|
||||||
import '../../services/health_ble_service.dart';
|
import '../../services/health_ble_service.dart';
|
||||||
|
import '../../widgets/app_toast.dart';
|
||||||
import '../../widgets/ble_sync_dialog.dart';
|
import '../../widgets/ble_sync_dialog.dart';
|
||||||
|
|
||||||
class DeviceScanPage extends ConsumerStatefulWidget {
|
class DeviceScanPage extends ConsumerStatefulWidget {
|
||||||
@@ -71,16 +72,18 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
|||||||
_results.clear();
|
_results.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
await [Permission.bluetoothScan, Permission.bluetoothConnect].request();
|
if (!await _ensureBlePermissions()) {
|
||||||
|
if (mounted) setState(() => _scanning = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
final locStatus = await Permission.locationWhenInUse.serviceStatus;
|
final locStatus = await Permission.locationWhenInUse.serviceStatus;
|
||||||
if (locStatus != ServiceStatus.enabled && mounted) {
|
if (locStatus != ServiceStatus.enabled && mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppToast.show(
|
||||||
const SnackBar(
|
context,
|
||||||
content: Text('请先打开定位服务,否则可能无法发现蓝牙设备'),
|
'请先打开定位服务,否则可能无法发现蓝牙设备',
|
||||||
backgroundColor: AppColors.warning,
|
type: AppToastType.warning,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,16 +186,12 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
|||||||
result.timeStamp.isBefore(scanStartedAt) ||
|
result.timeStamp.isBefore(scanStartedAt) ||
|
||||||
!result.advertisementData.connectable ||
|
!result.advertisementData.connectable ||
|
||||||
DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) {
|
DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) {
|
||||||
ScaffoldMessenger.of(
|
AppToast.show(context, '设备已离线,请重新进入通信状态', type: AppToastType.warning);
|
||||||
context,
|
|
||||||
).showSnackBar(const SnackBar(content: Text('设备已离线,请重新进入通信状态')));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ref.read(omronDeviceProvider).isBound &&
|
if (ref.read(omronDeviceProvider).isBound &&
|
||||||
ref.read(omronDeviceProvider).findById(remoteId) != null) {
|
ref.read(omronDeviceProvider).findById(remoteId) != null) {
|
||||||
ScaffoldMessenger.of(
|
AppToast.show(context, '该设备已绑定', type: AppToastType.info);
|
||||||
context,
|
|
||||||
).showSnackBar(const SnackBar(content: Text('该设备已绑定')));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() => _connectingId = remoteId);
|
setState(() => _connectingId = remoteId);
|
||||||
@@ -231,31 +230,24 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
|||||||
if (mounted) popRoute(ref);
|
if (mounted) popRoute(ref);
|
||||||
} on TimeoutException {
|
} on TimeoutException {
|
||||||
if (mounted && boundDevice != null) {
|
if (mounted && boundDevice != null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppToast.show(
|
||||||
SnackBar(content: Text('${boundDevice.type.label}已绑定,但本次未收到数据')),
|
context,
|
||||||
|
'${boundDevice.type.label}已绑定,但本次未收到数据',
|
||||||
|
type: AppToastType.warning,
|
||||||
);
|
);
|
||||||
popRoute(ref);
|
popRoute(ref);
|
||||||
} else if (mounted) {
|
} else if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppToast.show(context, '连接超时,请确认设备仍处于通信状态', type: AppToastType.error);
|
||||||
const SnackBar(
|
|
||||||
content: Text('连接超时,请确认设备仍处于通信状态'),
|
|
||||||
backgroundColor: AppColors.error,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
unawaited(_startScan());
|
unawaited(_startScan());
|
||||||
}
|
}
|
||||||
} on UnsupportedBleDeviceException catch (e) {
|
} on UnsupportedBleDeviceException catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppToast.show(context, e.message, type: AppToastType.error);
|
||||||
SnackBar(content: Text(e.message), backgroundColor: AppColors.error),
|
|
||||||
);
|
|
||||||
unawaited(_startScan());
|
unawaited(_startScan());
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppToast.show(context, '连接失败:$e', type: AppToastType.error);
|
||||||
SnackBar(content: Text('连接失败:$e'), backgroundColor: AppColors.error),
|
|
||||||
);
|
|
||||||
unawaited(_startScan());
|
unawaited(_startScan());
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -334,6 +326,38 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> _ensureBlePermissions() async {
|
||||||
|
final statuses = await [
|
||||||
|
Permission.bluetoothScan,
|
||||||
|
Permission.bluetoothConnect,
|
||||||
|
].request();
|
||||||
|
final granted = statuses.values.every((status) => status.isGranted);
|
||||||
|
if (granted) return true;
|
||||||
|
if (!mounted) return false;
|
||||||
|
AppToast.show(context, '请开启蓝牙权限后再搜索设备', type: AppToastType.warning);
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('需要蓝牙权限'),
|
||||||
|
content: const Text('搜索和连接健康设备需要蓝牙权限,请在系统设置中开启后重试。'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('稍后再说'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
openAppSettings();
|
||||||
|
},
|
||||||
|
child: const Text('去设置'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ScanningEmpty extends StatelessWidget {
|
class _ScanningEmpty extends StatelessWidget {
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
|
import '../../core/app_module_visuals.dart';
|
||||||
import '../../core/app_theme.dart';
|
import '../../core/app_theme.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../utils/sse_handler.dart';
|
import '../../utils/sse_handler.dart';
|
||||||
|
import '../../widgets/app_toast.dart';
|
||||||
|
|
||||||
final dietProvider = NotifierProvider<DietNotifier, DietState>(
|
final dietProvider = NotifierProvider<DietNotifier, DietState>(
|
||||||
DietNotifier.new,
|
DietNotifier.new,
|
||||||
@@ -303,14 +305,11 @@ class DietNotifier extends Notifier<DietState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─────────── 饮食主题色(暖橙系,不再用紫色)───────────
|
// ─────────── 饮食主题色(暖橙系,不再用紫色)───────────
|
||||||
const _dietAccent = Color(0xFFF97316);
|
const _dietVisual = AppModuleVisuals.diet;
|
||||||
const _dietAccentLight = Color(0xFFFFF3E0);
|
const _dietAccent = AppColors.diet;
|
||||||
|
const _dietAccentLight = AppColors.dietLight;
|
||||||
const _dietKcalText = Color(0xFF9A3412);
|
const _dietKcalText = Color(0xFF9A3412);
|
||||||
const _dietGradient = LinearGradient(
|
const _dietGradient = AppColors.dietGradient;
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [Color(0xFFF6D365), Color(0xFFFDA085)],
|
|
||||||
);
|
|
||||||
|
|
||||||
class DietCapturePage extends ConsumerStatefulWidget {
|
class DietCapturePage extends ConsumerStatefulWidget {
|
||||||
const DietCapturePage({super.key});
|
const DietCapturePage({super.key});
|
||||||
@@ -931,11 +930,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
|||||||
color: _dietAccentLight,
|
color: _dietAccentLight,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: Icon(_dietVisual.icon, size: 20, color: _dietAccent),
|
||||||
Icons.auto_awesome,
|
|
||||||
size: 20,
|
|
||||||
color: _dietAccent,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -981,6 +976,13 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
|||||||
if (mounted) popRoute(ref);
|
if (mounted) popRoute(ref);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[Diet] 保存记录失败: $e');
|
debugPrint('[Diet] 保存记录失败: $e');
|
||||||
|
if (mounted) {
|
||||||
|
AppToast.show(
|
||||||
|
context,
|
||||||
|
'保存失败,请检查网络后重试',
|
||||||
|
type: AppToastType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Row(
|
child: const Row(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import '../../core/app_colors.dart';
|
|||||||
import '../../core/app_theme.dart';
|
import '../../core/app_theme.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
|
import '../../widgets/app_toast.dart';
|
||||||
|
|
||||||
final _ptsSimple = FutureProvider<List<Map<String, dynamic>>>((ref) async {
|
final _ptsSimple = FutureProvider<List<Map<String, dynamic>>>((ref) async {
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
@@ -275,11 +276,10 @@ class _DoctorFollowUpEditPageState
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _snack(String msg, {bool ok = false}) {
|
void _snack(String msg, {bool ok = false}) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppToast.show(
|
||||||
SnackBar(
|
context,
|
||||||
content: Text(msg),
|
msg,
|
||||||
backgroundColor: ok ? AppColors.success : AppColors.error,
|
type: ok ? AppToastType.success : AppToastType.error,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
|
import '../../widgets/app_toast.dart';
|
||||||
|
|
||||||
final _docProfileProvider = FutureProvider<Map<String, dynamic>?>((ref) async {
|
final _docProfileProvider = FutureProvider<Map<String, dynamic>?>((ref) async {
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
@@ -112,21 +113,11 @@ class _DoctorProfileEditPageState extends ConsumerState<DoctorProfileEditPage> {
|
|||||||
);
|
);
|
||||||
ref.invalidate(_docProfileProvider);
|
ref.invalidate(_docProfileProvider);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppToast.show(context, '保存成功', type: AppToastType.success);
|
||||||
const SnackBar(
|
|
||||||
content: Text('保存成功'),
|
|
||||||
backgroundColor: AppColors.success,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppToast.show(context, '保存失败', type: AppToastType.error);
|
||||||
const SnackBar(
|
|
||||||
content: Text('保存失败'),
|
|
||||||
backgroundColor: AppColors.error,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _saving = false);
|
if (mounted) setState(() => _saving = false);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
|
import '../../widgets/app_toast.dart';
|
||||||
|
|
||||||
final _reportDetailProvider =
|
final _reportDetailProvider =
|
||||||
FutureProvider.family<Map<String, dynamic>?, String>((ref, id) async {
|
FutureProvider.family<Map<String, dynamic>?, String>((ref, id) async {
|
||||||
@@ -54,9 +55,7 @@ class _DoctorReportDetailPageState
|
|||||||
|
|
||||||
Future<void> _submitReview() async {
|
Future<void> _submitReview() async {
|
||||||
if (_severity.isEmpty) {
|
if (_severity.isEmpty) {
|
||||||
ScaffoldMessenger.of(
|
AppToast.show(context, '请选择严重程度', type: AppToastType.warning);
|
||||||
context,
|
|
||||||
).showSnackBar(const SnackBar(content: Text('请选择严重程度')));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() => _submitting = true);
|
setState(() => _submitting = true);
|
||||||
@@ -73,18 +72,11 @@ class _DoctorReportDetailPageState
|
|||||||
setState(() => _submitted = true);
|
setState(() => _submitted = true);
|
||||||
ref.invalidate(_reportDetailProvider(widget.id));
|
ref.invalidate(_reportDetailProvider(widget.id));
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppToast.show(context, '审核提交成功', type: AppToastType.success);
|
||||||
const SnackBar(
|
|
||||||
content: Text('审核提交成功'),
|
|
||||||
backgroundColor: AppColors.success,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppToast.show(context, '提交失败: $e', type: AppToastType.error);
|
||||||
SnackBar(content: Text('提交失败: $e'), backgroundColor: AppColors.error),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _submitting = false);
|
if (mounted) setState(() => _submitting = false);
|
||||||
|
|||||||
@@ -6,11 +6,8 @@ import '../../core/app_theme.dart';
|
|||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/chat_provider.dart';
|
import '../../providers/chat_provider.dart';
|
||||||
import '../../providers/conversation_history_provider.dart';
|
import '../../providers/conversation_history_provider.dart';
|
||||||
|
import '../../widgets/app_toast.dart';
|
||||||
|
|
||||||
/// 对话历史完整列表页。
|
|
||||||
/// - 左滑删除(真删,删后 ScaffoldMessenger 提示)
|
|
||||||
/// - 顶部"清空全部"按钮(确认弹窗 → 调 deleteAll)
|
|
||||||
/// - 点击一条 → loadConversation → popRoute 回首页
|
|
||||||
class ConversationHistoryPage extends ConsumerWidget {
|
class ConversationHistoryPage extends ConsumerWidget {
|
||||||
const ConversationHistoryPage({super.key});
|
const ConversationHistoryPage({super.key});
|
||||||
|
|
||||||
@@ -21,7 +18,7 @@ class ConversationHistoryPage extends ConsumerWidget {
|
|||||||
return GradientScaffold(
|
return GradientScaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.white.withValues(alpha: 0.9),
|
backgroundColor: Colors.white.withValues(alpha: 0.9),
|
||||||
title: const Text('最近 7 次对话'),
|
title: const Text('对话记录'),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () => popRoute(ref),
|
onPressed: () => popRoute(ref),
|
||||||
@@ -82,18 +79,11 @@ class ConversationHistoryPage extends ConsumerWidget {
|
|||||||
try {
|
try {
|
||||||
await ref.read(conversationHistoryProvider.notifier).deleteOne(id);
|
await ref.read(conversationHistoryProvider.notifier).deleteOne(id);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppToast.show(context, '已删除', type: AppToastType.success);
|
||||||
const SnackBar(content: Text('已删除'), duration: Duration(seconds: 1)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppToast.show(context, '删除失败,请稍后重试', type: AppToastType.error);
|
||||||
const SnackBar(
|
|
||||||
content: Text('删除失败,请稍后重试'),
|
|
||||||
backgroundColor: AppColors.error,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,18 +113,11 @@ class ConversationHistoryPage extends ConsumerWidget {
|
|||||||
.read(conversationHistoryProvider.notifier)
|
.read(conversationHistoryProvider.notifier)
|
||||||
.clearAll();
|
.clearAll();
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(
|
AppToast.show(context, '已清空 $count 条对话', type: AppToastType.success);
|
||||||
context,
|
|
||||||
).showSnackBar(SnackBar(content: Text('已清空 $count 条对话')));
|
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppToast.show(context, '清空失败,请稍后重试', type: AppToastType.error);
|
||||||
const SnackBar(
|
|
||||||
content: Text('清空失败,请稍后重试'),
|
|
||||||
backgroundColor: AppColors.error,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,6 +127,7 @@ class _HistoryTile extends StatelessWidget {
|
|||||||
final ConversationListItem item;
|
final ConversationListItem item;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final VoidCallback onDelete;
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
const _HistoryTile({
|
const _HistoryTile({
|
||||||
required this.item,
|
required this.item,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
@@ -157,25 +141,12 @@ class _HistoryTile extends StatelessWidget {
|
|||||||
direction: DismissDirection.endToStart,
|
direction: DismissDirection.endToStart,
|
||||||
background: Container(
|
background: Container(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
padding: const EdgeInsets.only(right: 22),
|
padding: const EdgeInsets.only(right: 18),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.error,
|
color: AppColors.error,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
),
|
||||||
child: const Row(
|
child: const Icon(Icons.delete_outline, color: Colors.white),
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.delete_outline, color: Colors.white),
|
|
||||||
SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
'删除',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
confirmDismiss: (_) async {
|
confirmDismiss: (_) async {
|
||||||
onDelete();
|
onDelete();
|
||||||
@@ -185,7 +156,7 @@ class _HistoryTile extends StatelessWidget {
|
|||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
@@ -194,59 +165,27 @@ class _HistoryTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
|
||||||
width: 38,
|
|
||||||
height: 38,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFEFF6FF),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.forum_rounded,
|
|
||||||
color: Color(0xFF2563EB),
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Text(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
_displaySummary(item),
|
_displaySummary(item),
|
||||||
maxLines: 2,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 3),
|
),
|
||||||
if (_displayOriginalQuestion(item).isNotEmpty) ...[
|
const SizedBox(width: 12),
|
||||||
Text(
|
Text(
|
||||||
'首问:${_displayOriginalQuestion(item)}',
|
_shortDate(item.updatedAt),
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textHint,
|
color: AppColors.textHint,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
|
||||||
],
|
|
||||||
Text(
|
|
||||||
'${item.messageCount} 条 · ${_relativeTime(item.updatedAt)}',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textHint,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -254,39 +193,29 @@ class _HistoryTile extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _displayTitle(ConversationListItem item) {
|
|
||||||
final s = item.summary?.trim();
|
|
||||||
if (s != null && s.isNotEmpty) return s;
|
|
||||||
final t = item.title?.trim();
|
|
||||||
if (t != null && t.isNotEmpty) return t;
|
|
||||||
return '未命名对话';
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _displaySummary(ConversationListItem item) {
|
static String _displaySummary(ConversationListItem item) {
|
||||||
final s = item.summary?.trim();
|
final s = item.summary?.trim();
|
||||||
if (s != null && s.isNotEmpty) return s.replaceAll(RegExp(r'\s+'), ' ');
|
if (s != null && s.isNotEmpty) return s.replaceAll(RegExp(r'\s+'), ' ');
|
||||||
return _displayTitle(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _displayOriginalQuestion(ConversationListItem item) {
|
|
||||||
final t = item.title?.trim();
|
final t = item.title?.trim();
|
||||||
if (t == null || t.isEmpty) return '';
|
if (t != null && t.isNotEmpty) return t.replaceAll(RegExp(r'\s+'), ' ');
|
||||||
final summary = item.summary?.trim();
|
return '未命名对话';
|
||||||
if (summary != null && summary.isNotEmpty && summary != t) {
|
|
||||||
return t.replaceAll(RegExp(r'\s+'), ' ');
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _relativeTime(DateTime time) {
|
static String _shortDate(DateTime time) {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final local = time.toLocal();
|
final local = time.toLocal();
|
||||||
final diff = now.difference(local);
|
if (local.year == now.year &&
|
||||||
if (diff.inMinutes < 1) return '刚刚';
|
local.month == now.month &&
|
||||||
if (diff.inHours < 1) return '${diff.inMinutes} 分钟前';
|
local.day == now.day) {
|
||||||
if (diff.inDays < 1) return '${diff.inHours} 小时前';
|
return '今天';
|
||||||
if (diff.inDays < 7) return '${diff.inDays} 天前';
|
}
|
||||||
return '${local.year}/${local.month}/${local.day}';
|
final yesterday = now.subtract(const Duration(days: 1));
|
||||||
|
if (local.year == yesterday.year &&
|
||||||
|
local.month == yesterday.month &&
|
||||||
|
local.day == yesterday.day) {
|
||||||
|
return '昨天';
|
||||||
|
}
|
||||||
|
return '${local.month}/${local.day}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +243,7 @@ class _EmptyHint extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
SizedBox(height: 6),
|
SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
'在首页和 AI 健康助手聊聊吧',
|
'在首页和 AI 健康助手聊天后会显示在这里',
|
||||||
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
|
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -328,6 +257,7 @@ class _EmptyHint extends StatelessWidget {
|
|||||||
class _ErrorBlock extends StatelessWidget {
|
class _ErrorBlock extends StatelessWidget {
|
||||||
final String message;
|
final String message;
|
||||||
final VoidCallback onRetry;
|
final VoidCallback onRetry;
|
||||||
|
|
||||||
const _ErrorBlock({required this.message, required this.onRetry});
|
const _ErrorBlock({required this.message, required this.onRetry});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:image_picker/image_picker.dart';
|
|||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
|
import '../../core/app_module_visuals.dart';
|
||||||
import '../../core/app_theme.dart';
|
import '../../core/app_theme.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
@@ -26,6 +27,8 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
final _textCtrl = TextEditingController();
|
final _textCtrl = TextEditingController();
|
||||||
final _scrollCtrl = ScrollController();
|
final _scrollCtrl = ScrollController();
|
||||||
final _focusNode = FocusNode();
|
final _focusNode = FocusNode();
|
||||||
|
final _scaffoldKey = GlobalKey<ScaffoldState>();
|
||||||
|
double? _drawerDragStartX;
|
||||||
String? _pickedImagePath;
|
String? _pickedImagePath;
|
||||||
int _lastMsgCount = 0;
|
int _lastMsgCount = 0;
|
||||||
Timer? _notificationTimer;
|
Timer? _notificationTimer;
|
||||||
@@ -38,11 +41,18 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
(_) => ref.invalidate(notificationUnreadCountProvider),
|
(_) => ref.invalidate(notificationUnreadCountProvider),
|
||||||
);
|
);
|
||||||
_notificationTimer = Timer.periodic(
|
_notificationTimer = Timer.periodic(
|
||||||
const Duration(seconds: 30),
|
const Duration(minutes: 2),
|
||||||
(_) => ref.invalidate(notificationUnreadCountProvider),
|
(_) => ref.invalidate(notificationUnreadCountProvider),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
ref.invalidate(notificationUnreadCountProvider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeMetrics() {
|
void didChangeMetrics() {
|
||||||
// 键盘动画期间每帧都会回调,让列表底部始终贴住输入区上沿
|
// 键盘动画期间每帧都会回调,让列表底部始终贴住输入区上沿
|
||||||
@@ -113,21 +123,47 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
_lastMsgCount = currentCount;
|
_lastMsgCount = currentCount;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
key: _scaffoldKey,
|
||||||
backgroundColor: const Color(0xFFFFFCFF),
|
backgroundColor: const Color(0xFFFFFCFF),
|
||||||
drawer: const HealthDrawer(),
|
drawer: const HealthDrawer(),
|
||||||
drawerEdgeDragWidth: MediaQuery.sizeOf(context).width * 0.65,
|
drawerEnableOpenDragGesture: false,
|
||||||
body: AppBackground(
|
body: AppBackground(
|
||||||
safeArea: true,
|
safeArea: true,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_buildHeader(user),
|
_buildHeader(user),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: RepaintBoundary(
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
RepaintBoundary(
|
||||||
child: ChatMessagesView(
|
child: ChatMessagesView(
|
||||||
scrollCtrl: _scrollCtrl,
|
scrollCtrl: _scrollCtrl,
|
||||||
messages: chatState.messages,
|
messages: chatState.messages,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: 44,
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onHorizontalDragStart: (details) {
|
||||||
|
_drawerDragStartX = details.globalPosition.dx;
|
||||||
|
},
|
||||||
|
onHorizontalDragUpdate: (details) {
|
||||||
|
final startX = _drawerDragStartX;
|
||||||
|
if (startX == null) return;
|
||||||
|
if (details.globalPosition.dx - startX < 28) return;
|
||||||
|
_drawerDragStartX = null;
|
||||||
|
_scaffoldKey.currentState?.openDrawer();
|
||||||
|
},
|
||||||
|
onHorizontalDragEnd: (_) => _drawerDragStartX = null,
|
||||||
|
onHorizontalDragCancel: () => _drawerDragStartX = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_buildBottomBar(context),
|
_buildBottomBar(context),
|
||||||
],
|
],
|
||||||
@@ -210,12 +246,12 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
static final _agentDefs = [
|
static final _agentDefs = [
|
||||||
(ActiveAgent.consultation, 'AI问诊', LucideIcons.messageCircle),
|
ActiveAgent.consultation,
|
||||||
(ActiveAgent.health, '记数据', LucideIcons.heartPulse),
|
ActiveAgent.health,
|
||||||
(ActiveAgent.diet, '拍饮食', LucideIcons.utensils),
|
ActiveAgent.diet,
|
||||||
(ActiveAgent.medication, '药管家', LucideIcons.pill),
|
ActiveAgent.medication,
|
||||||
(ActiveAgent.report, '报告分析', LucideIcons.fileText),
|
ActiveAgent.report,
|
||||||
(ActiveAgent.exercise, '运动', LucideIcons.activity),
|
ActiveAgent.exercise,
|
||||||
];
|
];
|
||||||
|
|
||||||
Widget _buildAgentBar() {
|
Widget _buildAgentBar() {
|
||||||
@@ -227,24 +263,18 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
itemCount: _agentDefs.length,
|
itemCount: _agentDefs.length,
|
||||||
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||||
itemBuilder: (_, i) {
|
itemBuilder: (_, i) {
|
||||||
final (agent, label, icon) = _agentDefs[i];
|
final agent = _agentDefs[i];
|
||||||
final gradient = _agentGradient(agent);
|
final visual = _agentVisual(agent);
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () =>
|
onTap: () => ref
|
||||||
ref.read(chatProvider.notifier).triggerAgent(agent, label),
|
.read(chatProvider.notifier)
|
||||||
|
.triggerAgent(agent, visual.label),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9),
|
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withValues(alpha: 0.86),
|
color: Colors.white.withValues(alpha: 0.86),
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
border: Border.all(color: Colors.white, width: 1.2),
|
border: Border.all(color: Colors.white, width: 1.2),
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: const Color(0xFF6366F1).withValues(alpha: 0.10),
|
|
||||||
blurRadius: 14,
|
|
||||||
offset: const Offset(0, 7),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -253,14 +283,14 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
width: 16,
|
width: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: gradient,
|
gradient: visual.gradient,
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
),
|
),
|
||||||
child: Icon(icon, size: 11, color: Colors.white),
|
child: Icon(visual.icon, size: 11, color: Colors.white),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
label,
|
visual.label,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
@@ -276,39 +306,41 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
LinearGradient _agentGradient(ActiveAgent agent) {
|
({String label, IconData icon, LinearGradient gradient}) _agentVisual(
|
||||||
|
ActiveAgent agent,
|
||||||
|
) {
|
||||||
|
({String label, AppModuleVisual visual}) fromModule(
|
||||||
|
String label,
|
||||||
|
AppModuleVisual visual,
|
||||||
|
) => (label: label, visual: visual);
|
||||||
|
|
||||||
|
final module = switch (agent) {
|
||||||
|
ActiveAgent.health => fromModule('记数据', AppModuleVisuals.health),
|
||||||
|
ActiveAgent.diet => fromModule('拍饮食', AppModuleVisuals.diet),
|
||||||
|
ActiveAgent.medication => fromModule('药管家', AppModuleVisuals.medication),
|
||||||
|
ActiveAgent.report => fromModule('报告分析', AppModuleVisuals.report),
|
||||||
|
ActiveAgent.exercise => fromModule('运动', AppModuleVisuals.exercise),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
if (module != null) {
|
||||||
|
return (
|
||||||
|
label: module.label,
|
||||||
|
icon: module.visual.icon,
|
||||||
|
gradient: module.visual.gradient,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return switch (agent) {
|
return switch (agent) {
|
||||||
ActiveAgent.consultation => const LinearGradient(
|
ActiveAgent.consultation => (
|
||||||
begin: Alignment.topCenter,
|
label: 'AI问诊',
|
||||||
end: Alignment.bottomCenter,
|
icon: LucideIcons.messageCircle,
|
||||||
colors: [Color(0xFFFFCAD4), Color(0xFFFF9AAE)],
|
gradient: AppColors.doctorGradient,
|
||||||
),
|
),
|
||||||
ActiveAgent.health => const LinearGradient(
|
_ => (
|
||||||
begin: Alignment.topCenter,
|
label: 'AI问诊',
|
||||||
end: Alignment.bottomCenter,
|
icon: LucideIcons.messageCircle,
|
||||||
colors: [Color(0xFFA1FFCE), Color(0xFF69DB8F)],
|
gradient: AppColors.primaryGradient,
|
||||||
),
|
),
|
||||||
ActiveAgent.diet => const LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [Color(0xFFF6D365), Color(0xFFFDA085)],
|
|
||||||
),
|
|
||||||
ActiveAgent.medication => const LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [Color(0xFF89F7FE), Color(0xFF66A6FF)],
|
|
||||||
),
|
|
||||||
ActiveAgent.report => const LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [Color(0xFFE0C3FC), Color(0xFF8EC5FC)],
|
|
||||||
),
|
|
||||||
ActiveAgent.exercise => const LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [Color(0xFFB6F2FF), Color(0xFF7DD3FC)],
|
|
||||||
),
|
|
||||||
_ => AppColors.primaryGradient,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,20 +351,13 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
border: Border(
|
border: Border(
|
||||||
top: BorderSide(color: Colors.white.withValues(alpha: 0.62)),
|
top: BorderSide(color: Colors.white.withValues(alpha: 0.62)),
|
||||||
),
|
),
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: const Color(0xFF101828).withValues(alpha: 0.045),
|
|
||||||
blurRadius: 20,
|
|
||||||
offset: const Offset(0, -8),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 8),
|
||||||
_buildAgentBar(),
|
_buildAgentBar(),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 12),
|
||||||
if (_pickedImagePath != null) _buildImagePreview(),
|
if (_pickedImagePath != null) _buildImagePreview(),
|
||||||
_buildInputBar(),
|
_buildInputBar(),
|
||||||
],
|
],
|
||||||
@@ -503,11 +528,9 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
final path = pdfFile.path;
|
final path = pdfFile.path;
|
||||||
if (path == null || path.isEmpty) return;
|
if (path == null || path.isEmpty) return;
|
||||||
// 上传 + 让 AI 看 PDF 内容
|
// 上传 + 让 AI 看 PDF 内容
|
||||||
await ref.read(chatProvider.notifier).sendPdf(
|
await ref
|
||||||
path,
|
.read(chatProvider.notifier)
|
||||||
pdfFile.name,
|
.sendPdf(path, pdfFile.name, _textCtrl.text.trim());
|
||||||
_textCtrl.text.trim(),
|
|
||||||
);
|
|
||||||
_textCtrl.clear();
|
_textCtrl.clear();
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
import '../../../core/app_colors.dart';
|
import '../../../core/app_colors.dart';
|
||||||
|
import '../../../core/app_design_tokens.dart';
|
||||||
|
import '../../../core/app_module_visuals.dart';
|
||||||
import '../../../core/app_theme.dart';
|
import '../../../core/app_theme.dart';
|
||||||
import '../../../core/api_client.dart' show baseUrl;
|
import '../../../core/api_client.dart' show baseUrl;
|
||||||
import '../../../core/navigation_provider.dart';
|
import '../../../core/navigation_provider.dart';
|
||||||
import '../../../providers/chat_provider.dart';
|
import '../../../providers/chat_provider.dart';
|
||||||
import '../../../providers/data_providers.dart';
|
import '../../../providers/data_providers.dart';
|
||||||
|
import '../../../widgets/ai_content.dart';
|
||||||
|
import '../../../widgets/app_toast.dart';
|
||||||
|
|
||||||
/// 卡片入场动画包装(从下往上滑入 + 淡入)
|
/// 卡片入场动画包装(从下往上滑入 + 淡入)
|
||||||
class _AnimatedCardEntry extends StatefulWidget {
|
class _AnimatedCardEntry extends StatefulWidget {
|
||||||
@@ -260,20 +263,15 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
const SizedBox(height: 9),
|
const SizedBox(height: 9),
|
||||||
Text(
|
Text(
|
||||||
info.$2,
|
info.$2,
|
||||||
style: const TextStyle(
|
style: AppTextStyles.chatTitle.copyWith(
|
||||||
fontSize: 24,
|
fontSize: 22,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w900,
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
info.$3,
|
info.$3,
|
||||||
style: const TextStyle(
|
style: AppTextStyles.summarySubtitle,
|
||||||
fontSize: 16,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
height: 1.4,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -514,7 +512,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
} else if (isExercise) {
|
} else if (isExercise) {
|
||||||
// 运动计划 — 主展示区大号显示运动名,字段列时长/频率/天数
|
// 运动计划 — 主展示区大号显示运动名,字段列时长/频率/天数
|
||||||
title = '运动计划确认';
|
title = '运动计划确认';
|
||||||
titleIcon = Icons.directions_run_outlined;
|
titleIcon = LucideIcons.footprints;
|
||||||
final exName =
|
final exName =
|
||||||
(meta['value'] ??
|
(meta['value'] ??
|
||||||
meta['name'] ??
|
meta['name'] ??
|
||||||
@@ -706,7 +704,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
: Icon(
|
: Icon(
|
||||||
isMedication
|
isMedication
|
||||||
? Icons.medication_liquid_outlined
|
? Icons.medication_liquid_outlined
|
||||||
: Icons.directions_run_outlined,
|
: LucideIcons.footprints,
|
||||||
size: 36,
|
size: 36,
|
||||||
color: AppColors.iconColor,
|
color: AppColors.iconColor,
|
||||||
),
|
),
|
||||||
@@ -895,9 +893,11 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
.read(chatProvider.notifier)
|
.read(chatProvider.notifier)
|
||||||
.confirmMessage(msg.id);
|
.confirmMessage(msg.id);
|
||||||
if (error != null && context.mounted) {
|
if (error != null && context.mounted) {
|
||||||
ScaffoldMessenger.of(
|
AppToast.show(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(SnackBar(content: Text(error)));
|
error,
|
||||||
|
type: AppToastType.error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(18),
|
||||||
@@ -1098,63 +1098,13 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
if (isUser)
|
if (isUser)
|
||||||
SelectableText(
|
SelectableText(
|
||||||
msg.content,
|
msg.content,
|
||||||
style: const TextStyle(
|
style: AppTextStyles.chatBody.copyWith(color: Colors.white),
|
||||||
fontSize: 19,
|
|
||||||
color: Colors.white,
|
|
||||||
height: 1.5,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
MarkdownBody(
|
AiMarkdownView(
|
||||||
data: _cleanAiText(msg.content),
|
data: msg.content,
|
||||||
selectable: true,
|
|
||||||
onTapLink: (text, href, title) =>
|
onTapLink: (text, href, title) =>
|
||||||
_handleMarkdownLink(context, ref, href),
|
_handleMarkdownLink(context, ref, href),
|
||||||
styleSheet: MarkdownStyleSheet(
|
|
||||||
p: const TextStyle(
|
|
||||||
fontSize: 19,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
height: 1.5,
|
|
||||||
),
|
|
||||||
a: const TextStyle(
|
|
||||||
fontSize: 19,
|
|
||||||
color: AppColors.primary,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
decoration: TextDecoration.underline,
|
|
||||||
decorationColor: AppColors.primary,
|
|
||||||
),
|
|
||||||
strong: const TextStyle(
|
|
||||||
fontSize: 19,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
height: 1.5,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
h1: const TextStyle(
|
|
||||||
fontSize: 22,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
height: 1.35,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
h2: const TextStyle(
|
|
||||||
fontSize: 21,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
height: 1.4,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
h3: const TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
height: 1.4,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
listBullet: const TextStyle(
|
|
||||||
fontSize: 24,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
height: 1.2,
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
),
|
|
||||||
listBulletPadding: const EdgeInsets.only(right: 8),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// 图片缩略图(在文字下方)
|
// 图片缩略图(在文字下方)
|
||||||
@@ -1321,6 +1271,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 清理 AI 返回文本中的异常占位符(Markdown 格式交给 MarkdownBody 渲染)
|
/// 清理 AI 返回文本中的异常占位符(Markdown 格式交给 MarkdownBody 渲染)
|
||||||
|
// ignore: unused_element
|
||||||
static String _cleanAiText(String text) {
|
static String _cleanAiText(String text) {
|
||||||
var t = text;
|
var t = text;
|
||||||
// 移除 $1、$2 等占位符
|
// 移除 $1、$2 等占位符
|
||||||
@@ -1449,7 +1400,21 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static _AgentColors _agentColors(ActiveAgent agent) {
|
static _AgentColors _agentColors(ActiveAgent agent) {
|
||||||
|
_AgentColors fromModule(AppModuleVisual visual) => _AgentColors(
|
||||||
|
gradient: [visual.gradient.colors.first, visual.gradient.colors.last],
|
||||||
|
bg: visual.lightColor,
|
||||||
|
border: visual.borderColor,
|
||||||
|
iconBg: visual.lightColor,
|
||||||
|
accent: visual.color,
|
||||||
|
verticalGradient: true,
|
||||||
|
);
|
||||||
|
|
||||||
return switch (agent) {
|
return switch (agent) {
|
||||||
|
ActiveAgent.health => fromModule(AppModuleVisuals.health),
|
||||||
|
ActiveAgent.diet => fromModule(AppModuleVisuals.diet),
|
||||||
|
ActiveAgent.medication => fromModule(AppModuleVisuals.medication),
|
||||||
|
ActiveAgent.report => fromModule(AppModuleVisuals.report),
|
||||||
|
ActiveAgent.exercise => fromModule(AppModuleVisuals.exercise),
|
||||||
ActiveAgent.consultation => _AgentColors(
|
ActiveAgent.consultation => _AgentColors(
|
||||||
gradient: [Color(0xFFFFCAD4), Color(0xFFFF9AAE)],
|
gradient: [Color(0xFFFFCAD4), Color(0xFFFF9AAE)],
|
||||||
bg: const Color(0xFFFFF4F6),
|
bg: const Color(0xFFFFF4F6),
|
||||||
@@ -1458,46 +1423,6 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
accent: const Color(0xFFFF7F98),
|
accent: const Color(0xFFFF7F98),
|
||||||
verticalGradient: true,
|
verticalGradient: true,
|
||||||
),
|
),
|
||||||
ActiveAgent.health => _AgentColors(
|
|
||||||
gradient: [Color(0xFFA1FFCE), Color(0xFF69DB8F)],
|
|
||||||
bg: const Color(0xFFF4FEF2),
|
|
||||||
border: const Color(0xFFD4F0C8),
|
|
||||||
iconBg: const Color(0xFFF0FCEF),
|
|
||||||
accent: const Color(0xFF52B87A),
|
|
||||||
verticalGradient: true,
|
|
||||||
),
|
|
||||||
ActiveAgent.diet => _AgentColors(
|
|
||||||
gradient: [Color(0xFFF6D365), Color(0xFFFDA085)],
|
|
||||||
bg: const Color(0xFFFFF7F0),
|
|
||||||
border: const Color(0xFFFFE8D4),
|
|
||||||
iconBg: const Color(0xFFFFF0E4),
|
|
||||||
accent: const Color(0xFFF89C5B),
|
|
||||||
verticalGradient: true,
|
|
||||||
),
|
|
||||||
ActiveAgent.medication => _AgentColors(
|
|
||||||
gradient: [Color(0xFF89F7FE), Color(0xFF66A6FF)],
|
|
||||||
bg: const Color(0xFFF4F6FF),
|
|
||||||
border: const Color(0xFFDDE6FF),
|
|
||||||
iconBg: const Color(0xFFEFF3FF),
|
|
||||||
accent: const Color(0xFF4F8FF7),
|
|
||||||
verticalGradient: true,
|
|
||||||
),
|
|
||||||
ActiveAgent.report => _AgentColors(
|
|
||||||
gradient: [Color(0xFFE0C3FC), Color(0xFF8EC5FC)],
|
|
||||||
bg: const Color(0xFFF8F5FF),
|
|
||||||
border: const Color(0xFFE9E0FF),
|
|
||||||
iconBg: const Color(0xFFF2EDFF),
|
|
||||||
accent: const Color(0xFF9D8AF8),
|
|
||||||
verticalGradient: true,
|
|
||||||
),
|
|
||||||
ActiveAgent.exercise => _AgentColors(
|
|
||||||
gradient: [Color(0xFFB6F2FF), Color(0xFF7DD3FC)],
|
|
||||||
bg: const Color(0xFFF0FCFF),
|
|
||||||
border: const Color(0xFFD8F5FC),
|
|
||||||
iconBg: const Color(0xFFEAFBFF),
|
|
||||||
accent: const Color(0xFF38BDE8),
|
|
||||||
verticalGradient: true,
|
|
||||||
),
|
|
||||||
_ => _AgentColors(
|
_ => _AgentColors(
|
||||||
gradient: [AppColors.primary, AppColors.blueMeasure],
|
gradient: [AppColors.primary, AppColors.blueMeasure],
|
||||||
bg: const Color(0xFFF6F3FF),
|
bg: const Color(0xFFF6F3FF),
|
||||||
@@ -1519,7 +1444,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
'AI智能问诊,描述症状获取建议',
|
'AI智能问诊,描述症状获取建议',
|
||||||
),
|
),
|
||||||
ActiveAgent.report => (LucideIcons.fileText, '报告分析', '上传体检报告,AI 辅助解读'),
|
ActiveAgent.report => (LucideIcons.fileText, '报告分析', '上传体检报告,AI 辅助解读'),
|
||||||
ActiveAgent.exercise => (LucideIcons.activity, '运动', '制定运动计划,打卡记录进度'),
|
ActiveAgent.exercise => (LucideIcons.footprints, '运动', '制定运动计划,打卡记录进度'),
|
||||||
_ => (Icons.forum_outlined, 'AI 助手', '血管病患者的 AI 健康管理助手'),
|
_ => (Icons.forum_outlined, 'AI 助手', '血管病患者的 AI 健康管理助手'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1621,8 +1546,8 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
|
|
||||||
// ── 1. 健康指标 ──
|
// ── 1. 健康指标 ──
|
||||||
// 没记录则整行不显示(避免每天唠叨提醒)
|
// 没记录则整行不显示(避免每天唠叨提醒)
|
||||||
const healthIconColor = Color(0xFF3B82F6);
|
final healthIconColor = AppModuleVisuals.health.color;
|
||||||
const healthIconBg = Color(0xFFDBEAFE);
|
final healthIconBg = AppModuleVisuals.health.lightColor;
|
||||||
if (allNull) {
|
if (allNull) {
|
||||||
// 不显示
|
// 不显示
|
||||||
} else if (hasAbnormal) {
|
} else if (hasAbnormal) {
|
||||||
@@ -1632,7 +1557,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
tasks.add(
|
tasks.add(
|
||||||
_taskRow(
|
_taskRow(
|
||||||
context,
|
context,
|
||||||
Icons.warning_amber_rounded,
|
LucideIcons.triangleAlert,
|
||||||
'健康指标',
|
'健康指标',
|
||||||
trailing: trailing,
|
trailing: trailing,
|
||||||
status: 'warning',
|
status: 'warning',
|
||||||
@@ -1646,7 +1571,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
tasks.add(
|
tasks.add(
|
||||||
_taskRow(
|
_taskRow(
|
||||||
context,
|
context,
|
||||||
Icons.check_circle,
|
LucideIcons.circleCheck,
|
||||||
'健康指标',
|
'健康指标',
|
||||||
trailing: '指标正常',
|
trailing: '指标正常',
|
||||||
status: 'done',
|
status: 'done',
|
||||||
@@ -1658,8 +1583,8 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 2. 运动 ──
|
// ── 2. 运动 ──
|
||||||
const exIconColor = Color(0xFFF59E0B);
|
final exIconColor = AppModuleVisuals.exercise.color;
|
||||||
const exIconBg = Color(0xFFFEF3C7);
|
final exIconBg = AppModuleVisuals.exercise.lightColor;
|
||||||
final exercisePlan = ref.watch(currentExercisePlanProvider);
|
final exercisePlan = ref.watch(currentExercisePlanProvider);
|
||||||
var hasExercise = false;
|
var hasExercise = false;
|
||||||
exercisePlan.when(
|
exercisePlan.when(
|
||||||
@@ -1685,7 +1610,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
tasks.add(
|
tasks.add(
|
||||||
_taskRow(
|
_taskRow(
|
||||||
context,
|
context,
|
||||||
Icons.directions_run,
|
LucideIcons.footprints,
|
||||||
'$name $dur分钟',
|
'$name $dur分钟',
|
||||||
status: done
|
status: done
|
||||||
? 'done'
|
? 'done'
|
||||||
@@ -1703,7 +1628,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
tasks.add(
|
tasks.add(
|
||||||
_taskRow(
|
_taskRow(
|
||||||
context,
|
context,
|
||||||
Icons.directions_run,
|
LucideIcons.footprints,
|
||||||
'运动',
|
'运动',
|
||||||
trailing: '正在加载运动计划',
|
trailing: '正在加载运动计划',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
@@ -1718,7 +1643,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
tasks.add(
|
tasks.add(
|
||||||
_taskRow(
|
_taskRow(
|
||||||
context,
|
context,
|
||||||
Icons.directions_run,
|
LucideIcons.footprints,
|
||||||
'运动',
|
'运动',
|
||||||
trailing: '运动计划加载失败',
|
trailing: '运动计划加载失败',
|
||||||
status: 'overdue',
|
status: 'overdue',
|
||||||
@@ -1733,7 +1658,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
tasks.add(
|
tasks.add(
|
||||||
_taskRow(
|
_taskRow(
|
||||||
context,
|
context,
|
||||||
Icons.directions_run,
|
LucideIcons.footprints,
|
||||||
'运动',
|
'运动',
|
||||||
trailing: '暂无今日运动计划',
|
trailing: '暂无今日运动计划',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
@@ -1746,8 +1671,8 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
|
|
||||||
// ── 3. 用药打卡 ──
|
// ── 3. 用药打卡 ──
|
||||||
// 漏服时突出提醒;未到服药时间时也保留一行轻提示,避免今日健康缺少用药状态。
|
// 漏服时突出提醒;未到服药时间时也保留一行轻提示,避免今日健康缺少用药状态。
|
||||||
const medIconColor = Color(0xFFEC4899);
|
final medIconColor = AppModuleVisuals.medication.color;
|
||||||
const medIconBg = Color(0xFFFCE7F3);
|
final medIconBg = AppModuleVisuals.medication.lightColor;
|
||||||
reminders.whenOrNull(
|
reminders.whenOrNull(
|
||||||
data: (meds) {
|
data: (meds) {
|
||||||
final overdueMeds = meds
|
final overdueMeds = meds
|
||||||
@@ -1757,7 +1682,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
tasks.add(
|
tasks.add(
|
||||||
_taskRow(
|
_taskRow(
|
||||||
context,
|
context,
|
||||||
Icons.medication_rounded,
|
LucideIcons.pill,
|
||||||
'用药',
|
'用药',
|
||||||
trailing: meds.isEmpty ? '暂无用药安排' : '暂时不用吃药',
|
trailing: meds.isEmpty ? '暂无用药安排' : '暂时不用吃药',
|
||||||
status: 'done',
|
status: 'done',
|
||||||
@@ -1815,7 +1740,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
tasks.add(
|
tasks.add(
|
||||||
_taskRow(
|
_taskRow(
|
||||||
context,
|
context,
|
||||||
Icons.medication_rounded,
|
LucideIcons.pill,
|
||||||
title,
|
title,
|
||||||
trailing: trailing,
|
trailing: trailing,
|
||||||
status: status,
|
status: status,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '../../providers/auth_provider.dart';
|
|||||||
import '../../providers/data_providers.dart';
|
import '../../providers/data_providers.dart';
|
||||||
import '../../widgets/app_empty_state.dart';
|
import '../../widgets/app_empty_state.dart';
|
||||||
import '../../widgets/app_error_state.dart';
|
import '../../widgets/app_error_state.dart';
|
||||||
|
import '../../widgets/app_toast.dart';
|
||||||
|
|
||||||
class MedicationCheckInPage extends ConsumerStatefulWidget {
|
class MedicationCheckInPage extends ConsumerStatefulWidget {
|
||||||
final String? medId;
|
final String? medId;
|
||||||
@@ -66,11 +67,10 @@ class _MedicationCheckInPageState extends ConsumerState<MedicationCheckInPage> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[MedCheckIn] 打卡失败: $e');
|
debugPrint('[MedCheckIn] 打卡失败: $e');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppToast.show(
|
||||||
const SnackBar(
|
context,
|
||||||
content: Text('打卡失败,请稍后重试'),
|
'打卡失败,请稍后重试',
|
||||||
backgroundColor: AppTheme.error,
|
type: AppToastType.error,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../core/app_colors.dart';
|
|
||||||
import '../../core/app_theme.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../core/app_colors.dart';
|
||||||
|
import '../../core/app_design_tokens.dart';
|
||||||
|
import '../../core/app_module_visuals.dart';
|
||||||
|
import '../../core/app_theme.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/data_providers.dart';
|
import '../../providers/data_providers.dart';
|
||||||
|
import '../../widgets/app_toast.dart';
|
||||||
|
|
||||||
class MedicationEditPage extends ConsumerStatefulWidget {
|
class MedicationEditPage extends ConsumerStatefulWidget {
|
||||||
final String? id;
|
final String? id;
|
||||||
|
|
||||||
const MedicationEditPage({super.key, this.id});
|
const MedicationEditPage({super.key, this.id});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState();
|
ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
||||||
|
static const _visual = AppModuleVisuals.medication;
|
||||||
|
|
||||||
final _nameCtrl = TextEditingController();
|
final _nameCtrl = TextEditingController();
|
||||||
final _dosageCtrl = TextEditingController();
|
final _dosageCtrl = TextEditingController();
|
||||||
final _notesCtrl = TextEditingController();
|
final _notesCtrl = TextEditingController();
|
||||||
@@ -74,9 +82,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
|||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
final name = _nameCtrl.text.trim();
|
final name = _nameCtrl.text.trim();
|
||||||
if (name.isEmpty) {
|
if (name.isEmpty) {
|
||||||
ScaffoldMessenger.of(
|
AppToast.show(context, '请输入药品名称', type: AppToastType.warning);
|
||||||
context,
|
|
||||||
).showSnackBar(const SnackBar(content: Text('请输入药品名称')));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
@@ -107,17 +113,10 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
|||||||
}
|
}
|
||||||
ref.invalidate(medicationListProvider);
|
ref.invalidate(medicationListProvider);
|
||||||
ref.invalidate(medicationReminderProvider);
|
ref.invalidate(medicationReminderProvider);
|
||||||
if (mounted) {
|
if (mounted) popRoute(ref);
|
||||||
popRoute(ref);
|
|
||||||
}
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppToast.show(context, '保存失败', type: AppToastType.error);
|
||||||
const SnackBar(
|
|
||||||
content: Text('保存失败'),
|
|
||||||
backgroundColor: AppTheme.error,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
@@ -148,23 +147,22 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
|||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
// 名称+剂量 一行
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 3,
|
flex: 3,
|
||||||
child: _field('药品名称', _nameCtrl, hint: '如: 阿司匹林'),
|
child: _field('药品名称', _nameCtrl, hint: '如:阿司匹林'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 2,
|
flex: 2,
|
||||||
child: _field('剂量', _dosageCtrl, hint: '如: 100mg'),
|
child: _field('剂量', _dosageCtrl, hint: '如:100mg'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// 频率选择
|
_label('每日服药次数'),
|
||||||
_label('每日服药次数'), const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final n = await showAppCountPicker(
|
final n = await showAppCountPicker(
|
||||||
@@ -176,23 +174,13 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
|||||||
);
|
);
|
||||||
if (n != null) _updateTimes(n);
|
if (n != null) _updateTimes(n);
|
||||||
},
|
},
|
||||||
child: Container(
|
child: _PickerBox(
|
||||||
width: double.infinity,
|
child: Text('$_timesPerDay 次/天', style: AppTextStyles.formValue),
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.surface,
|
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
|
||||||
border: Border.all(color: AppColors.border),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'$_timesPerDay 次/天',
|
|
||||||
style: const TextStyle(fontSize: 18),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// 时间选择
|
_label('服药时间'),
|
||||||
_label('服药时间'), const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
@@ -213,23 +201,18 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
|||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: AppRadius.mdBorder,
|
||||||
border: Border.all(color: AppColors.border, width: 1),
|
border: Border.all(color: _visual.borderColor, width: 1),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'${_times[i].hour.toString().padLeft(2, '0')}:${_times[i].minute.toString().padLeft(2, '0')}',
|
'${_times[i].hour.toString().padLeft(2, '0')}:${_times[i].minute.toString().padLeft(2, '0')}',
|
||||||
style: const TextStyle(
|
style: AppTextStyles.formValue,
|
||||||
fontSize: 18,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// 开始+结束 一行
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -242,7 +225,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _dateFieldOpt(
|
child: _dateFieldOpt(
|
||||||
'结束日期(可选)',
|
'结束日期(可选)',
|
||||||
_end,
|
_end,
|
||||||
(d) => setState(() => _end = d),
|
(d) => setState(() => _end = d),
|
||||||
),
|
),
|
||||||
@@ -250,7 +233,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_field('备注', _notesCtrl, hint: '如: 饭后服用、睡前'),
|
_field('备注', _notesCtrl, hint: '如:饭后服用、睡前'),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -258,20 +241,12 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
|||||||
onPressed: _loading ? null : _save,
|
onPressed: _loading ? null : _save,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
foregroundColor: AppColors.primary,
|
foregroundColor: _visual.color,
|
||||||
side: const BorderSide(color: AppColors.primary, width: 1.5),
|
side: BorderSide(color: _visual.color, width: 1.5),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder),
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(_loading ? '保存中...' : '保存', style: AppTextStyles.button),
|
||||||
_loading ? '保存中...' : '保存',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 19,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
@@ -280,10 +255,8 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _label(String text) => Text(
|
Widget _label(String text) => Text(text, style: AppTextStyles.formLabel);
|
||||||
text,
|
|
||||||
style: TextStyle(fontSize: 17, color: AppColors.textSecondary),
|
|
||||||
);
|
|
||||||
Widget _field(String label, TextEditingController ctrl, {String? hint}) =>
|
Widget _field(String label, TextEditingController ctrl, {String? hint}) =>
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -296,27 +269,19 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
|||||||
hintText: hint,
|
hintText: hint,
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: AppTheme.surface,
|
fillColor: AppTheme.surface,
|
||||||
border: OutlineInputBorder(
|
border: _inputBorder(AppColors.border),
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
enabledBorder: _inputBorder(AppColors.border),
|
||||||
borderSide: const BorderSide(color: AppColors.border),
|
focusedBorder: _inputBorder(_visual.color),
|
||||||
),
|
|
||||||
enabledBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
|
||||||
borderSide: const BorderSide(color: AppColors.border),
|
|
||||||
),
|
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
|
||||||
borderSide: const BorderSide(color: AppColors.primary),
|
|
||||||
),
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
horizontal: 12,
|
horizontal: 12,
|
||||||
vertical: 12,
|
vertical: 12,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
style: const TextStyle(fontSize: 19),
|
style: AppTextStyles.formValue,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _dateField(String label, DateTime val, ValueChanged<DateTime> cb) =>
|
Widget _dateField(String label, DateTime val, ValueChanged<DateTime> cb) =>
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -333,22 +298,13 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
|||||||
);
|
);
|
||||||
if (d != null) cb(d);
|
if (d != null) cb(d);
|
||||||
},
|
},
|
||||||
child: Container(
|
child: _PickerBox(
|
||||||
width: double.infinity,
|
child: Text(_displayDate(val), style: AppTextStyles.formValue),
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.surface,
|
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
|
||||||
border: Border.all(color: AppColors.border),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
_displayDate(val),
|
|
||||||
style: const TextStyle(fontSize: 18),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _dateFieldOpt(
|
Widget _dateFieldOpt(
|
||||||
String label,
|
String label,
|
||||||
DateTime? val,
|
DateTime? val,
|
||||||
@@ -368,24 +324,17 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
|||||||
);
|
);
|
||||||
if (d != null) cb(d);
|
if (d != null) cb(d);
|
||||||
},
|
},
|
||||||
child: Container(
|
child: _PickerBox(
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.surface,
|
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
|
||||||
border: Border.all(color: AppColors.border),
|
|
||||||
),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Expanded(
|
||||||
|
child: Text(
|
||||||
val != null ? _displayDate(val) : '不设置',
|
val != null ? _displayDate(val) : '不设置',
|
||||||
style: TextStyle(
|
style: AppTextStyles.formValue.copyWith(
|
||||||
fontSize: 18,
|
|
||||||
color: val != null ? null : AppTheme.textHint,
|
color: val != null ? null : AppTheme.textHint,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (val != null) const Spacer(),
|
),
|
||||||
if (val != null)
|
if (val != null)
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => cb(null),
|
onTap: () => cb(null),
|
||||||
@@ -401,6 +350,31 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
OutlineInputBorder _inputBorder(Color color) => OutlineInputBorder(
|
||||||
|
borderRadius: AppRadius.mdBorder,
|
||||||
|
borderSide: BorderSide(color: color),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PickerBox extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const _PickerBox({required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.surface,
|
||||||
|
borderRadius: AppRadius.mdBorder,
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _displayDate(DateTime date) {
|
String _displayDate(DateTime date) {
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
|
import '../../core/app_design_tokens.dart';
|
||||||
|
import '../../core/app_module_visuals.dart';
|
||||||
import '../../core/app_theme.dart';
|
import '../../core/app_theme.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/data_providers.dart';
|
import '../../providers/data_providers.dart';
|
||||||
import '../../widgets/app_empty_state.dart';
|
import '../../widgets/app_empty_state.dart';
|
||||||
import '../../widgets/app_future_view.dart';
|
import '../../widgets/app_future_view.dart';
|
||||||
|
import '../../widgets/app_status_badge.dart';
|
||||||
import '../../widgets/common_widgets.dart';
|
import '../../widgets/common_widgets.dart';
|
||||||
import '../../widgets/enterprise_widgets.dart';
|
import '../../widgets/enterprise_widgets.dart';
|
||||||
|
|
||||||
@@ -16,8 +19,7 @@ class MedicationListPage extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
||||||
static const _medBlue = Color(0xFF60A5FA);
|
static const _medVisual = AppModuleVisuals.medication;
|
||||||
static const _medCyan = Color(0xFF8B5CF6);
|
|
||||||
Future<List<Map<String, dynamic>>>? _future;
|
Future<List<Map<String, dynamic>>>? _future;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -50,7 +52,7 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
|||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
onPressed: () => pushRoute(ref, 'medicationEdit'),
|
onPressed: () => pushRoute(ref, 'medicationEdit'),
|
||||||
backgroundColor: _medBlue,
|
backgroundColor: _medVisual.color,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
child: const Icon(Icons.add, size: 28, color: Colors.white),
|
child: const Icon(Icons.add, size: 28, color: Colors.white),
|
||||||
),
|
),
|
||||||
@@ -60,8 +62,8 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
|||||||
errorTitle: '用药信息加载失败',
|
errorTitle: '用药信息加载失败',
|
||||||
onData: (ctx, list) {
|
onData: (ctx, list) {
|
||||||
if (list.isEmpty) {
|
if (list.isEmpty) {
|
||||||
return const AppEmptyState(
|
return AppEmptyState(
|
||||||
icon: Icons.medication_outlined,
|
icon: _medVisual.icon,
|
||||||
title: '暂无用药',
|
title: '暂无用药',
|
||||||
subtitle: '点击右下角➕添加药品',
|
subtitle: '点击右下角➕添加药品',
|
||||||
);
|
);
|
||||||
@@ -77,9 +79,9 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
|||||||
EnterpriseHeader(
|
EnterpriseHeader(
|
||||||
title: '今日用药概览',
|
title: '今日用药概览',
|
||||||
subtitle: '集中管理服药计划、剂量和每日打卡状态',
|
subtitle: '集中管理服药计划、剂量和每日打卡状态',
|
||||||
icon: Icons.medication_outlined,
|
icon: _medVisual.icon,
|
||||||
color: _medBlue,
|
color: _medVisual.color,
|
||||||
accent: _medCyan,
|
accent: AppColors.primary,
|
||||||
showIcon: false,
|
showIcon: false,
|
||||||
stats: [
|
stats: [
|
||||||
EnterpriseStat(
|
EnterpriseStat(
|
||||||
@@ -129,17 +131,13 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
|||||||
height: 44,
|
height: 44,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: isActive
|
gradient: isActive
|
||||||
? const LinearGradient(
|
? _medVisual.gradient
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [_medBlue, _medCyan],
|
|
||||||
)
|
|
||||||
: AppColors.surfaceGradient,
|
: AppColors.surfaceGradient,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: AppRadius.mdBorder,
|
||||||
border: Border.all(color: AppColors.borderLight),
|
border: Border.all(color: AppColors.borderLight),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.medication_outlined,
|
_medVisual.icon,
|
||||||
size: 25,
|
size: 25,
|
||||||
color: isActive ? Colors.white : AppTheme.textSub,
|
color: isActive ? Colors.white : AppTheme.textSub,
|
||||||
),
|
),
|
||||||
@@ -153,9 +151,7 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
m['name']?.toString() ?? '',
|
m['name']?.toString() ?? '',
|
||||||
style: TextStyle(
|
style: AppTextStyles.listTitle.copyWith(
|
||||||
fontSize: 19,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: isActive
|
color: isActive
|
||||||
? AppTheme.text
|
? AppTheme.text
|
||||||
: AppTheme.textSub,
|
: AppTheme.textSub,
|
||||||
@@ -163,24 +159,9 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
|||||||
),
|
),
|
||||||
if (!isActive) ...[
|
if (!isActive) ...[
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Container(
|
AppStatusBadge(
|
||||||
padding: const EdgeInsets.symmetric(
|
label: '已停',
|
||||||
horizontal: 6,
|
color: _medVisual.color,
|
||||||
vertical: 1,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: _medBlue.withValues(alpha: 0.08),
|
|
||||||
borderRadius: BorderRadius.circular(
|
|
||||||
999,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'已停',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: _medBlue,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -188,8 +169,7 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
|||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
'${m['dosage'] ?? ''} $times',
|
'${m['dosage'] ?? ''} $times',
|
||||||
style: TextStyle(
|
style: AppTextStyles.listSubtitle.copyWith(
|
||||||
fontSize: 16,
|
|
||||||
color: AppTheme.textSub,
|
color: AppTheme.textSub,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
|
import '../../core/app_module_visuals.dart';
|
||||||
import '../../core/app_theme.dart';
|
import '../../core/app_theme.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/data_providers.dart';
|
import '../../providers/data_providers.dart';
|
||||||
@@ -619,15 +620,19 @@ class _NotificationVisual {
|
|||||||
|
|
||||||
const _NotificationVisual(this.icon, this.color, this.lightColor, this.label);
|
const _NotificationVisual(this.icon, this.color, this.lightColor, this.label);
|
||||||
|
|
||||||
|
factory _NotificationVisual.fromModule(AppModuleVisual visual) {
|
||||||
|
return _NotificationVisual(
|
||||||
|
visual.icon,
|
||||||
|
visual.color,
|
||||||
|
visual.lightColor,
|
||||||
|
visual.label,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
factory _NotificationVisual.of(InAppNotification item) {
|
factory _NotificationVisual.of(InAppNotification item) {
|
||||||
switch (item.actionType) {
|
switch (item.actionType) {
|
||||||
case 'exercise':
|
case 'exercise':
|
||||||
return const _NotificationVisual(
|
return _NotificationVisual.fromModule(AppModuleVisuals.exercise);
|
||||||
LucideIcons.activity,
|
|
||||||
Color(0xFF16A34A),
|
|
||||||
Color(0xFFEAF8EF),
|
|
||||||
'运动',
|
|
||||||
);
|
|
||||||
case 'health':
|
case 'health':
|
||||||
return item.severity == 'critical'
|
return item.severity == 'critical'
|
||||||
? const _NotificationVisual(
|
? const _NotificationVisual(
|
||||||
@@ -636,12 +641,7 @@ class _NotificationVisual {
|
|||||||
Color(0xFFFEE2E2),
|
Color(0xFFFEE2E2),
|
||||||
'紧急',
|
'紧急',
|
||||||
)
|
)
|
||||||
: const _NotificationVisual(
|
: _NotificationVisual.fromModule(AppModuleVisuals.health);
|
||||||
LucideIcons.heartPulse,
|
|
||||||
Color(0xFFF59E0B),
|
|
||||||
Color(0xFFFEF3C7),
|
|
||||||
'健康',
|
|
||||||
);
|
|
||||||
case 'report':
|
case 'report':
|
||||||
return item.severity == 'error'
|
return item.severity == 'error'
|
||||||
? const _NotificationVisual(
|
? const _NotificationVisual(
|
||||||
@@ -650,19 +650,9 @@ class _NotificationVisual {
|
|||||||
Color(0xFFFEE2E2),
|
Color(0xFFFEE2E2),
|
||||||
'报告',
|
'报告',
|
||||||
)
|
)
|
||||||
: const _NotificationVisual(
|
: _NotificationVisual.fromModule(AppModuleVisuals.report);
|
||||||
LucideIcons.fileCheck2,
|
|
||||||
Color(0xFF8B5CF6),
|
|
||||||
Color(0xFFEDE9FE),
|
|
||||||
'报告',
|
|
||||||
);
|
|
||||||
default:
|
default:
|
||||||
return const _NotificationVisual(
|
return _NotificationVisual.fromModule(AppModuleVisuals.medication);
|
||||||
LucideIcons.pill,
|
|
||||||
Color(0xFF3B82F6),
|
|
||||||
Color(0xFFDBEAFE),
|
|
||||||
'用药',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
|
import '../../core/app_design_tokens.dart';
|
||||||
|
import '../../core/app_module_visuals.dart';
|
||||||
import '../../core/app_theme.dart';
|
import '../../core/app_theme.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
@@ -36,10 +38,10 @@ class ProfilePage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_ActionTile(
|
_ActionTile(
|
||||||
icon: Icons.folder_shared_outlined,
|
icon: AppModuleVisuals.health.icon,
|
||||||
title: '健康档案',
|
title: '健康档案',
|
||||||
subtitle: '维护个人资料、病史、手术和过敏信息',
|
subtitle: '维护个人资料、病史、手术和过敏信息',
|
||||||
color: const Color(0xFF8B5CF6),
|
visual: AppModuleVisuals.health,
|
||||||
onTap: () => pushRoute(ref, 'healthArchive'),
|
onTap: () => pushRoute(ref, 'healthArchive'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
@@ -54,9 +56,7 @@ class ProfilePage extends ConsumerWidget {
|
|||||||
final ok = await showDialog<bool>(
|
final ok = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder),
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rLg),
|
|
||||||
),
|
|
||||||
title: const Text('退出登录'),
|
title: const Text('退出登录'),
|
||||||
content: const Text('确定退出当前账号?'),
|
content: const Text('确定退出当前账号?'),
|
||||||
actions: [
|
actions: [
|
||||||
@@ -91,12 +91,12 @@ class _AccountCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Container(
|
Widget build(BuildContext context) => Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: AppSpacing.panel,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: AppRadius.lgBorder,
|
||||||
border: Border.all(color: AppColors.border, width: 1.1),
|
border: Border.all(color: AppColors.border, width: 1.1),
|
||||||
boxShadow: [AppTheme.shadowLight],
|
boxShadow: AppShadows.soft,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -110,21 +110,14 @@ class _AccountCard extends StatelessWidget {
|
|||||||
name,
|
name,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: AppTextStyles.summaryTitle.copyWith(fontSize: 20),
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 5),
|
const SizedBox(height: 5),
|
||||||
Text(
|
Text(
|
||||||
phone,
|
phone,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: AppTextStyles.listSubtitle,
|
||||||
fontSize: 16,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -144,18 +137,13 @@ class _Avatar extends StatelessWidget {
|
|||||||
width: 58,
|
width: 58,
|
||||||
height: 58,
|
height: 58,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.iconBg,
|
gradient: AppModuleVisuals.health.gradient,
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: AppRadius.lgBorder,
|
||||||
border: Border.all(color: AppColors.borderLight),
|
|
||||||
),
|
),
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: avatarUrl != null && avatarUrl!.isNotEmpty
|
child: avatarUrl != null && avatarUrl!.isNotEmpty
|
||||||
? Image.network(avatarUrl!, fit: BoxFit.cover)
|
? Image.network(avatarUrl!, fit: BoxFit.cover)
|
||||||
: const Icon(
|
: const Icon(Icons.person_rounded, color: Colors.white, size: 34),
|
||||||
Icons.person_rounded,
|
|
||||||
color: AppColors.blueMeasure,
|
|
||||||
size: 34,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,30 +151,30 @@ class _ActionTile extends StatelessWidget {
|
|||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String title;
|
final String title;
|
||||||
final String subtitle;
|
final String subtitle;
|
||||||
final Color color;
|
final AppModuleVisual visual;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
const _ActionTile({
|
const _ActionTile({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.subtitle,
|
required this.subtitle,
|
||||||
required this.color,
|
required this.visual,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Material(
|
Widget build(BuildContext context) => Material(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: AppRadius.lgBorder,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: AppRadius.lgBorder,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(15),
|
padding: const EdgeInsets.all(15),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: AppRadius.lgBorder,
|
||||||
border: Border.all(color: AppColors.border, width: 1.1),
|
border: Border.all(color: AppColors.border, width: 1.1),
|
||||||
boxShadow: [AppTheme.shadowLight],
|
boxShadow: AppShadows.soft,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -194,42 +182,28 @@ class _ActionTile extends StatelessWidget {
|
|||||||
width: 46,
|
width: 46,
|
||||||
height: 46,
|
height: 46,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color.withValues(alpha: 0.10),
|
gradient: visual.gradient,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: AppRadius.mdBorder,
|
||||||
),
|
),
|
||||||
child: Icon(icon, color: color, size: 24),
|
child: Icon(icon, color: Colors.white, size: 24),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 13),
|
const SizedBox(width: 13),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(title, style: AppTextStyles.listTitle),
|
||||||
title,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 17,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
subtitle,
|
subtitle,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: AppTextStyles.listSubtitle,
|
||||||
fontSize: 14,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Icon(
|
const Icon(Icons.chevron_right_rounded, color: AppColors.textHint),
|
||||||
Icons.chevron_right_rounded,
|
|
||||||
size: 22,
|
|
||||||
color: AppColors.textHint,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -243,16 +217,13 @@ class _LogoutButton extends StatelessWidget {
|
|||||||
const _LogoutButton({required this.onPressed});
|
const _LogoutButton({required this.onPressed});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => OutlinedButton.icon(
|
Widget build(BuildContext context) => TextButton(
|
||||||
onPressed: onPressed,
|
onPressed: onPressed,
|
||||||
icon: const Icon(Icons.logout_rounded, size: 19),
|
style: TextButton.styleFrom(
|
||||||
label: const Text('退出登录'),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: AppColors.error,
|
foregroundColor: AppColors.error,
|
||||||
side: BorderSide(color: AppColors.error.withValues(alpha: 0.34)),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
minimumSize: const Size.fromHeight(52),
|
textStyle: AppTextStyles.button,
|
||||||
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
|
||||||
),
|
),
|
||||||
|
child: const Text('退出登录'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../core/app_colors.dart';
|
import '../core/app_colors.dart';
|
||||||
|
import '../core/app_module_visuals.dart';
|
||||||
import '../core/app_theme.dart';
|
import '../core/app_theme.dart';
|
||||||
import '../core/navigation_provider.dart';
|
import '../core/navigation_provider.dart';
|
||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
@@ -9,6 +10,7 @@ import '../providers/data_providers.dart';
|
|||||||
import '../widgets/common_widgets.dart';
|
import '../widgets/common_widgets.dart';
|
||||||
import '../widgets/app_error_state.dart';
|
import '../widgets/app_error_state.dart';
|
||||||
import '../widgets/app_future_view.dart';
|
import '../widgets/app_future_view.dart';
|
||||||
|
import '../widgets/app_toast.dart';
|
||||||
|
|
||||||
/// 饮食记录列表(趋势+日历+胶囊详情+侧滑编辑删除)
|
/// 饮食记录列表(趋势+日历+胶囊详情+侧滑编辑删除)
|
||||||
class DietRecordListPage extends ConsumerStatefulWidget {
|
class DietRecordListPage extends ConsumerStatefulWidget {
|
||||||
@@ -687,8 +689,9 @@ class ExercisePlanPage extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
|
class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
|
||||||
static const _exerciseBlue = Color(0xFF60A5FA);
|
static const _exerciseVisual = AppModuleVisuals.exercise;
|
||||||
static const _exerciseViolet = Color(0xFF8B5CF6);
|
static const _exerciseBlue = AppColors.exercise;
|
||||||
|
static const _exerciseViolet = Color(0xFF059669);
|
||||||
|
|
||||||
Future<List<Map<String, dynamic>>>? _future;
|
Future<List<Map<String, dynamic>>>? _future;
|
||||||
final Set<String> _busyItems = {};
|
final Set<String> _busyItems = {};
|
||||||
@@ -718,11 +721,10 @@ class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[ExercisePlan] 打卡失败: $e');
|
debugPrint('[ExercisePlan] 打卡失败: $e');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppToast.show(
|
||||||
const SnackBar(
|
context,
|
||||||
content: Text('只能打卡今天的运动任务'),
|
'只能打卡今天的运动任务',
|
||||||
backgroundColor: AppTheme.error,
|
type: AppToastType.error,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -755,7 +757,7 @@ class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
pushRoute(ref, 'exerciseCreate');
|
pushRoute(ref, 'exerciseCreate');
|
||||||
},
|
},
|
||||||
backgroundColor: const Color(0xFF60A5FA),
|
backgroundColor: AppColors.exercise,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
child: const Icon(Icons.add, color: Colors.white),
|
child: const Icon(Icons.add, color: Colors.white),
|
||||||
@@ -881,8 +883,8 @@ class _ExercisePlanOverviewCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: Icon(
|
||||||
Icons.directions_run,
|
_ExercisePlanPageState._exerciseVisual.icon,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
size: 24,
|
size: 24,
|
||||||
),
|
),
|
||||||
@@ -1302,11 +1304,10 @@ class _ExercisePlanDetailPageState
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[ExercisePlanDetail] 打卡失败: $e');
|
debugPrint('[ExercisePlanDetail] 打卡失败: $e');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppToast.show(
|
||||||
const SnackBar(
|
context,
|
||||||
content: Text('只能打卡今天的运动任务'),
|
'只能打卡今天的运动任务',
|
||||||
backgroundColor: AppTheme.error,
|
type: AppToastType.error,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1802,9 +1803,7 @@ class _ExercisePlanCreatePageState
|
|||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
final name = _nameCtrl.text.trim();
|
final name = _nameCtrl.text.trim();
|
||||||
if (name.isEmpty) {
|
if (name.isEmpty) {
|
||||||
ScaffoldMessenger.of(
|
AppToast.show(context, '请输入运动名称', type: AppToastType.warning);
|
||||||
context,
|
|
||||||
).showSnackBar(const SnackBar(content: Text('请输入运动名称')));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final dur = int.tryParse(_durationCtrl.text) ?? 30;
|
final dur = int.tryParse(_durationCtrl.text) ?? 30;
|
||||||
@@ -2325,12 +2324,7 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
|
|||||||
await ref.read(authProvider.notifier).refreshProfile();
|
await ref.read(authProvider.notifier).refreshProfile();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppToast.show(context, '保存失败,请重试', type: AppToastType.error);
|
||||||
const SnackBar(
|
|
||||||
content: Text('保存失败,请重试'),
|
|
||||||
backgroundColor: AppColors.error,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _saving = false);
|
if (mounted) setState(() => _saving = false);
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import '../../core/app_colors.dart';
|
|||||||
import '../../core/app_theme.dart';
|
import '../../core/app_theme.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
|
import '../../widgets/ai_content.dart';
|
||||||
import 'report_pages.dart';
|
import 'report_pages.dart';
|
||||||
|
|
||||||
/// AI 解读页 — 从服务器获取真实报告数据
|
/// AI 解读页:从服务器获取真实报告数据
|
||||||
class AiAnalysisPage extends ConsumerStatefulWidget {
|
class AiAnalysisPage extends ConsumerStatefulWidget {
|
||||||
final String id;
|
final String id;
|
||||||
const AiAnalysisPage({super.key, required this.id});
|
const AiAnalysisPage({super.key, required this.id});
|
||||||
@@ -99,7 +100,7 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
|
|||||||
_analysisStateCard(
|
_analysisStateCard(
|
||||||
isFailed
|
isFailed
|
||||||
? (analysis.summary.isNotEmpty ? analysis.summary : 'AI 分析失败,请重新上传或稍后重试')
|
? (analysis.summary.isNotEmpty ? analysis.summary : 'AI 分析失败,请重新上传或稍后重试')
|
||||||
: 'AI 正在分析报告,请稍后刷新查看。',
|
: 'AI 正在分析报告,请稍后刷新查看',
|
||||||
isFailed: isFailed,
|
isFailed: isFailed,
|
||||||
onRetry: isFailed
|
onRetry: isFailed
|
||||||
? () => ref.read(reportProvider.notifier).reanalyzeReport(widget.id)
|
? () => ref.read(reportProvider.notifier).reanalyzeReport(widget.id)
|
||||||
@@ -107,7 +108,7 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
],
|
],
|
||||||
// ── 1. 指标分析 ──
|
// 指标分析
|
||||||
if (!isAnalyzing && !isFailed && analysis.indicators.isNotEmpty) ...[
|
if (!isAnalyzing && !isFailed && analysis.indicators.isNotEmpty) ...[
|
||||||
_sectionTitle('指标分析'),
|
_sectionTitle('指标分析'),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -121,7 +122,7 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
],
|
],
|
||||||
// ── 2. 综合解读 ──
|
// 综合解读
|
||||||
if (!isAnalyzing && !isFailed) ...[
|
if (!isAnalyzing && !isFailed) ...[
|
||||||
_sectionTitle('综合解读'),
|
_sectionTitle('综合解读'),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -131,35 +132,19 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
AiMarkdownView(
|
||||||
analysis.summary.isNotEmpty
|
data: analysis.summary.isNotEmpty
|
||||||
? analysis.summary
|
? analysis.summary
|
||||||
: 'AI 正在分析中,请稍后刷新查看',
|
: 'AI 正在分析中,请稍后刷新查看',
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
height: 1.7,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.cardInner,
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
child: const Text(
|
|
||||||
'以上为AI预解读,不能替代医生诊断和治疗建议',
|
|
||||||
style: TextStyle(fontSize: 13, color: AppColors.textHint),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
const AiGeneratedNote(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
],
|
],
|
||||||
if (!isAnalyzing && !isFailed) ...[
|
if (!isAnalyzing && !isFailed) ...[
|
||||||
// ── 3. 医生审核意见 ──
|
// 医生审核意见
|
||||||
_sectionTitle('医生审核意见'),
|
_sectionTitle('医生审核意见'),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (isReviewed)
|
if (isReviewed)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
|
import '../../core/app_module_visuals.dart';
|
||||||
import '../../core/app_theme.dart';
|
import '../../core/app_theme.dart';
|
||||||
import '../../core/api_client.dart' show baseUrl;
|
import '../../core/api_client.dart' show baseUrl;
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
@@ -17,6 +18,13 @@ final reportProvider = NotifierProvider<ReportNotifier, ReportState>(
|
|||||||
ReportNotifier.new,
|
ReportNotifier.new,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Duration? reportAnalysisPollDelay(int attempt) {
|
||||||
|
if (attempt > 15) return null;
|
||||||
|
if (attempt <= 2) return const Duration(seconds: 4);
|
||||||
|
if (attempt <= 5) return const Duration(seconds: 8);
|
||||||
|
return const Duration(seconds: 12);
|
||||||
|
}
|
||||||
|
|
||||||
class ReportState {
|
class ReportState {
|
||||||
final List<ReportItem> reports;
|
final List<ReportItem> reports;
|
||||||
final String? uploadingImage;
|
final String? uploadingImage;
|
||||||
@@ -132,6 +140,7 @@ class Indicator {
|
|||||||
|
|
||||||
class ReportNotifier extends Notifier<ReportState> {
|
class ReportNotifier extends Notifier<ReportState> {
|
||||||
Timer? _pollTimer;
|
Timer? _pollTimer;
|
||||||
|
int _pollAttempt = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ReportState build() {
|
ReportState build() {
|
||||||
@@ -185,12 +194,16 @@ class ReportNotifier extends Notifier<ReportState> {
|
|||||||
if (!hasAnalyzing) {
|
if (!hasAnalyzing) {
|
||||||
_pollTimer?.cancel();
|
_pollTimer?.cancel();
|
||||||
_pollTimer = null;
|
_pollTimer = null;
|
||||||
|
_pollAttempt = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_pollTimer ??= Timer.periodic(
|
if (_pollTimer != null) return;
|
||||||
const Duration(seconds: 4),
|
final delay = reportAnalysisPollDelay(++_pollAttempt);
|
||||||
(_) => loadReports(),
|
if (delay == null) return;
|
||||||
);
|
_pollTimer = Timer(delay, () {
|
||||||
|
_pollTimer = null;
|
||||||
|
loadReports();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void fetchReportDetail(String reportId) async {
|
void fetchReportDetail(String reportId) async {
|
||||||
@@ -354,8 +367,9 @@ class ReportNotifier extends Notifier<ReportState> {
|
|||||||
class ReportListPage extends ConsumerWidget {
|
class ReportListPage extends ConsumerWidget {
|
||||||
const ReportListPage({super.key});
|
const ReportListPage({super.key});
|
||||||
|
|
||||||
static const _reportBlue = Color(0xFF8B5CF6);
|
static const _reportVisual = AppModuleVisuals.report;
|
||||||
static const _reportCyan = Color(0xFF38BDF8);
|
static const _reportBlue = AppColors.report;
|
||||||
|
static const _reportCyan = Color(0xFF60A5FA);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -407,7 +421,7 @@ class ReportListPage extends ConsumerWidget {
|
|||||||
EnterpriseHeader(
|
EnterpriseHeader(
|
||||||
title: '报告处理概览',
|
title: '报告处理概览',
|
||||||
subtitle: '上传检查报告后自动进行 AI 结构化解读',
|
subtitle: '上传检查报告后自动进行 AI 结构化解读',
|
||||||
icon: Icons.description_outlined,
|
icon: _reportVisual.icon,
|
||||||
color: _reportBlue,
|
color: _reportBlue,
|
||||||
accent: _reportCyan,
|
accent: _reportCyan,
|
||||||
showIcon: false,
|
showIcon: false,
|
||||||
@@ -564,11 +578,7 @@ class ReportListPage extends ConsumerWidget {
|
|||||||
color: _reportBlue.withValues(alpha: 0.08),
|
color: _reportBlue.withValues(alpha: 0.08),
|
||||||
borderRadius: BorderRadius.circular(50),
|
borderRadius: BorderRadius.circular(50),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: Icon(_reportVisual.icon, size: 44, color: _reportBlue),
|
||||||
Icons.description_outlined,
|
|
||||||
size: 44,
|
|
||||||
color: _reportBlue,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
const Text(
|
const Text(
|
||||||
@@ -625,11 +635,7 @@ class ReportListPage extends ConsumerWidget {
|
|||||||
color: _reportBlue.withValues(alpha: 0.10),
|
color: _reportBlue.withValues(alpha: 0.10),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: Icon(_reportVisual.icon, size: 26, color: _reportBlue),
|
||||||
Icons.description_outlined,
|
|
||||||
size: 26,
|
|
||||||
color: _reportBlue,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ class UserInfo {
|
|||||||
final String? name;
|
final String? name;
|
||||||
final String? avatarUrl;
|
final String? avatarUrl;
|
||||||
|
|
||||||
UserInfo({required this.id, required this.phone, this.role = 'User', this.name, this.avatarUrl});
|
UserInfo({
|
||||||
|
required this.id,
|
||||||
|
required this.phone,
|
||||||
|
this.role = 'User',
|
||||||
|
this.name,
|
||||||
|
this.avatarUrl,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class AuthState {
|
class AuthState {
|
||||||
@@ -23,17 +29,34 @@ class AuthState {
|
|||||||
const AuthState({this.user, this.isLoggedIn = false, this.isLoading = true});
|
const AuthState({this.user, this.isLoggedIn = false, this.isLoading = true});
|
||||||
}
|
}
|
||||||
|
|
||||||
final authProvider = NotifierProvider<AuthNotifier, AuthState>(AuthNotifier.new);
|
final authProvider = NotifierProvider<AuthNotifier, AuthState>(
|
||||||
|
AuthNotifier.new,
|
||||||
|
);
|
||||||
|
|
||||||
final localDbProvider = Provider<LocalDatabase>((ref) => LocalDatabase.instance);
|
final localDbProvider = Provider<LocalDatabase>(
|
||||||
|
(ref) => LocalDatabase.instance,
|
||||||
|
);
|
||||||
|
|
||||||
|
final authExpiredNotifierProvider = Provider<AuthExpiredNotifier>((ref) {
|
||||||
|
return AuthExpiredNotifier();
|
||||||
|
});
|
||||||
|
|
||||||
final apiClientProvider = Provider<ApiClient>((ref) {
|
final apiClientProvider = Provider<ApiClient>((ref) {
|
||||||
return ApiClient(db: ref.watch(localDbProvider));
|
return ApiClient(
|
||||||
|
db: ref.watch(localDbProvider),
|
||||||
|
authExpiredNotifier: ref.watch(authExpiredNotifierProvider),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
class AuthNotifier extends Notifier<AuthState> {
|
class AuthNotifier extends Notifier<AuthState> {
|
||||||
@override
|
@override
|
||||||
AuthState build() {
|
AuthState build() {
|
||||||
|
final removeListener = ref.read(authExpiredNotifierProvider).addListener(
|
||||||
|
() {
|
||||||
|
state = const AuthState(isLoggedIn: false, isLoading: false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ref.onDispose(removeListener);
|
||||||
_checkAuth();
|
_checkAuth();
|
||||||
// 初始为"加载中",让启动闸门显示 Splash 盖住登录页/首页初始态
|
// 初始为"加载中",让启动闸门显示 Splash 盖住登录页/首页初始态
|
||||||
return const AuthState(isLoggedIn: false, isLoading: true);
|
return const AuthState(isLoggedIn: false, isLoading: true);
|
||||||
@@ -50,14 +73,17 @@ class AuthNotifier extends Notifier<AuthState> {
|
|||||||
|
|
||||||
state = const AuthState(isLoading: true);
|
state = const AuthState(isLoading: true);
|
||||||
try {
|
try {
|
||||||
final response = await Dio(BaseOptions(baseUrl: baseUrl))
|
final response = await Dio(
|
||||||
.post('/api/auth/refresh', data: {'refreshToken': refresh});
|
BaseOptions(baseUrl: baseUrl),
|
||||||
|
).post('/api/auth/refresh', data: {'refreshToken': refresh});
|
||||||
final data = response.data['data'];
|
final data = response.data['data'];
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
await db.write('access_token', data['accessToken']);
|
await db.write('access_token', data['accessToken']);
|
||||||
await db.write('refresh_token', data['refreshToken']);
|
await db.write('refresh_token', data['refreshToken']);
|
||||||
final u = data['user'] as Map<String, dynamic>?;
|
final u = data['user'] as Map<String, dynamic>?;
|
||||||
state = AuthState(isLoggedIn: true, isLoading: false,
|
state = AuthState(
|
||||||
|
isLoggedIn: true,
|
||||||
|
isLoading: false,
|
||||||
user: UserInfo(id: '', phone: '', role: u?['role'] ?? 'User'),
|
user: UserInfo(id: '', phone: '', role: u?['role'] ?? 'User'),
|
||||||
);
|
);
|
||||||
_loadProfile();
|
_loadProfile();
|
||||||
@@ -77,11 +103,15 @@ class AuthNotifier extends Notifier<AuthState> {
|
|||||||
final response = await api.get('/api/user/profile');
|
final response = await api.get('/api/user/profile');
|
||||||
final user = response.data['data'];
|
final user = response.data['data'];
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
state = AuthState(isLoggedIn: true, isLoading: false,
|
state = AuthState(
|
||||||
|
isLoggedIn: true,
|
||||||
|
isLoading: false,
|
||||||
user: UserInfo(
|
user: UserInfo(
|
||||||
id: user['id'] ?? '', phone: user['phone'] ?? '',
|
id: user['id'] ?? '',
|
||||||
|
phone: user['phone'] ?? '',
|
||||||
role: user['role'] ?? state.user?.role ?? 'User',
|
role: user['role'] ?? state.user?.role ?? 'User',
|
||||||
name: user['name'], avatarUrl: user['avatarUrl'],
|
name: user['name'],
|
||||||
|
avatarUrl: user['avatarUrl'],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -96,27 +126,50 @@ class AuthNotifier extends Notifier<AuthState> {
|
|||||||
Future<({String? error, String? devCode})> sendSms(String phone) async {
|
Future<({String? error, String? devCode})> sendSms(String phone) async {
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
final response = await api.post('/api/auth/send-sms', data: {'phone': phone});
|
final response = await api.post(
|
||||||
return (error: null, devCode: response.data['data']?['devCode'] as String?);
|
'/api/auth/send-sms',
|
||||||
|
data: {'phone': phone},
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
error: null,
|
||||||
|
devCode: response.data['data']?['devCode'] as String?,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return (error: '发送失败: $e', devCode: null);
|
return (error: '发送失败: $e', devCode: null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 注册(新用户,需选身份)
|
/// 注册(新用户,需选身份)
|
||||||
Future<String?> register(String phone, String code, String name, String doctorId) async {
|
Future<String?> register(
|
||||||
|
String phone,
|
||||||
|
String code,
|
||||||
|
String name,
|
||||||
|
String doctorId,
|
||||||
|
) async {
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
final response = await api.post('/api/auth/register', data: {
|
final response = await api.post(
|
||||||
'phone': phone, 'smsCode': code, 'name': name, 'doctorId': doctorId,
|
'/api/auth/register',
|
||||||
});
|
data: {
|
||||||
|
'phone': phone,
|
||||||
|
'smsCode': code,
|
||||||
|
'name': name,
|
||||||
|
'doctorId': doctorId,
|
||||||
|
},
|
||||||
|
);
|
||||||
final data = response.data['data'];
|
final data = response.data['data'];
|
||||||
if (data == null) return response.data['message'] ?? '注册失败';
|
if (data == null) return response.data['message'] ?? '注册失败';
|
||||||
|
|
||||||
await api.saveTokens(data['accessToken'], data['refreshToken']);
|
await api.saveTokens(data['accessToken'], data['refreshToken']);
|
||||||
final user = data['user'];
|
final user = data['user'];
|
||||||
state = AuthState(isLoggedIn: true, isLoading: false,
|
state = AuthState(
|
||||||
user: UserInfo(id: user['id'] ?? '', phone: user['phone'] ?? '', role: user['role'] ?? 'User'),
|
isLoggedIn: true,
|
||||||
|
isLoading: false,
|
||||||
|
user: UserInfo(
|
||||||
|
id: user['id'] ?? '',
|
||||||
|
phone: user['phone'] ?? '',
|
||||||
|
role: user['role'] ?? 'User',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -128,16 +181,23 @@ class AuthNotifier extends Notifier<AuthState> {
|
|||||||
Future<String?> login(String phone, String code) async {
|
Future<String?> login(String phone, String code) async {
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
final response = await api.post('/api/auth/login', data: {'phone': phone, 'smsCode': code});
|
final response = await api.post(
|
||||||
|
'/api/auth/login',
|
||||||
|
data: {'phone': phone, 'smsCode': code},
|
||||||
|
);
|
||||||
final data = response.data['data'];
|
final data = response.data['data'];
|
||||||
if (data == null) return response.data['message'] ?? '登录失败';
|
if (data == null) return response.data['message'] ?? '登录失败';
|
||||||
|
|
||||||
await api.saveTokens(data['accessToken'], data['refreshToken']);
|
await api.saveTokens(data['accessToken'], data['refreshToken']);
|
||||||
final user = data['user'];
|
final user = data['user'];
|
||||||
state = AuthState(isLoggedIn: true, isLoading: false,
|
state = AuthState(
|
||||||
|
isLoggedIn: true,
|
||||||
|
isLoading: false,
|
||||||
user: UserInfo(
|
user: UserInfo(
|
||||||
id: user['id'] ?? '', phone: user['phone'] ?? '',
|
id: user['id'] ?? '',
|
||||||
role: user['role'] ?? 'User', name: user['name'],
|
phone: user['phone'] ?? '',
|
||||||
|
role: user['role'] ?? 'User',
|
||||||
|
name: user['name'],
|
||||||
avatarUrl: user['avatarUrl'],
|
avatarUrl: user['avatarUrl'],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -153,7 +213,11 @@ class AuthNotifier extends Notifier<AuthState> {
|
|||||||
final db = ref.read(localDbProvider);
|
final db = ref.read(localDbProvider);
|
||||||
final refresh = await db.read('refresh_token');
|
final refresh = await db.read('refresh_token');
|
||||||
if (refresh != null) {
|
if (refresh != null) {
|
||||||
try { await api.post('/api/auth/logout', data: {'refreshToken': refresh}); } catch (e) { log('[Auth] logout: $e'); }
|
try {
|
||||||
|
await api.post('/api/auth/logout', data: {'refreshToken': refresh});
|
||||||
|
} catch (e) {
|
||||||
|
log('[Auth] logout: $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await api.clearTokens();
|
await api.clearTokens();
|
||||||
state = const AuthState(isLoggedIn: false, isLoading: false);
|
state = const AuthState(isLoggedIn: false, isLoading: false);
|
||||||
|
|||||||
87
health_app/lib/widgets/ai_content.dart
Normal file
87
health_app/lib/widgets/ai_content.dart
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||||
|
|
||||||
|
import '../core/app_colors.dart';
|
||||||
|
import '../core/app_design_tokens.dart';
|
||||||
|
|
||||||
|
class AiContent {
|
||||||
|
AiContent._();
|
||||||
|
|
||||||
|
static String clean(String text) {
|
||||||
|
var t = text;
|
||||||
|
t = t.replaceAll(RegExp(r'\$\d+'), '');
|
||||||
|
t = t.replaceAll(RegExp(r'<[^>]+>'), '');
|
||||||
|
t = t.replaceAllMapped(RegExp(r'^#{4,}\s', multiLine: true), (_) => '### ');
|
||||||
|
t = t.replaceAll(RegExp(r'\n{3,}'), '\n\n');
|
||||||
|
return t.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
static String plain(String text) {
|
||||||
|
final cleaned = clean(text);
|
||||||
|
return cleaned
|
||||||
|
.replaceAll(RegExp(r'\*\*(.*?)\*\*'), r'$1')
|
||||||
|
.replaceAll(RegExp(r'__(.*?)__'), r'$1')
|
||||||
|
.replaceAll(RegExp(r'`([^`]+)`'), r'$1')
|
||||||
|
.replaceAll(RegExp(r'^\s*[-*]\s+', multiLine: true), '• ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AiMarkdownView extends StatelessWidget {
|
||||||
|
final String data;
|
||||||
|
final bool selectable;
|
||||||
|
final void Function(String text, String? href, String title)? onTapLink;
|
||||||
|
|
||||||
|
const AiMarkdownView({
|
||||||
|
super.key,
|
||||||
|
required this.data,
|
||||||
|
this.selectable = true,
|
||||||
|
this.onTapLink,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MarkdownBody(
|
||||||
|
data: AiContent.clean(data),
|
||||||
|
selectable: selectable,
|
||||||
|
onTapLink: onTapLink,
|
||||||
|
styleSheet: MarkdownStyleSheet(
|
||||||
|
p: AppTextStyles.chatBody,
|
||||||
|
a: AppTextStyles.chatBody.copyWith(
|
||||||
|
color: AppColors.primary,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
decorationColor: AppColors.primary,
|
||||||
|
),
|
||||||
|
strong: AppTextStyles.chatBody.copyWith(fontWeight: FontWeight.w800),
|
||||||
|
h1: AppTextStyles.chatTitle.copyWith(fontSize: 20),
|
||||||
|
h2: AppTextStyles.chatTitle.copyWith(fontSize: 20),
|
||||||
|
h3: AppTextStyles.chatTitle.copyWith(fontSize: 19),
|
||||||
|
listBullet: AppTextStyles.chatBody.copyWith(
|
||||||
|
fontSize: 21,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
listBulletPadding: const EdgeInsets.only(right: 8),
|
||||||
|
blockquote: AppTextStyles.chatBody.copyWith(color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AiGeneratedNote extends StatelessWidget {
|
||||||
|
const AiGeneratedNote({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 10),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.auto_awesome_rounded, size: 15, color: AppColors.textHint),
|
||||||
|
SizedBox(width: 5),
|
||||||
|
Text('内容由 AI 生成', style: AppTextStyles.aiNote),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
242
health_app/lib/widgets/app_buttons.dart
Normal file
242
health_app/lib/widgets/app_buttons.dart
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../core/app_colors.dart';
|
||||||
|
import '../core/app_design_tokens.dart';
|
||||||
|
|
||||||
|
enum AppButtonVariant { primary, secondary, outline, ghost, danger, gradientOutline }
|
||||||
|
|
||||||
|
class AppButton extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
final IconData? icon;
|
||||||
|
final AppButtonVariant variant;
|
||||||
|
final Color? color;
|
||||||
|
final LinearGradient? gradient;
|
||||||
|
final bool loading;
|
||||||
|
final double height;
|
||||||
|
final bool expand;
|
||||||
|
|
||||||
|
const AppButton({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
this.onPressed,
|
||||||
|
this.icon,
|
||||||
|
this.variant = AppButtonVariant.primary,
|
||||||
|
this.color,
|
||||||
|
this.gradient,
|
||||||
|
this.loading = false,
|
||||||
|
this.height = 50,
|
||||||
|
this.expand = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final enabled = onPressed != null && !loading;
|
||||||
|
final baseColor = color ?? AppColors.primary;
|
||||||
|
final child = _ButtonContent(
|
||||||
|
label: label,
|
||||||
|
icon: icon,
|
||||||
|
loading: loading,
|
||||||
|
foreground: _foreground(baseColor),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (variant == AppButtonVariant.gradientOutline) {
|
||||||
|
return _GradientOutlineButton(
|
||||||
|
label: label,
|
||||||
|
icon: icon,
|
||||||
|
loading: loading,
|
||||||
|
enabled: enabled,
|
||||||
|
height: height,
|
||||||
|
expand: expand,
|
||||||
|
gradient: gradient ?? AppColors.actionOutlineGradient,
|
||||||
|
onPressed: onPressed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoration = _decoration(baseColor, enabled);
|
||||||
|
final width = expand ? double.infinity : null;
|
||||||
|
return SizedBox(
|
||||||
|
height: height,
|
||||||
|
width: width,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: enabled ? onPressed : null,
|
||||||
|
borderRadius: AppRadius.lgBorder,
|
||||||
|
child: Ink(
|
||||||
|
decoration: decoration,
|
||||||
|
child: Center(child: child),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _foreground(Color baseColor) {
|
||||||
|
return switch (variant) {
|
||||||
|
AppButtonVariant.primary => Colors.white,
|
||||||
|
AppButtonVariant.secondary => baseColor,
|
||||||
|
AppButtonVariant.outline => baseColor,
|
||||||
|
AppButtonVariant.ghost => baseColor,
|
||||||
|
AppButtonVariant.danger => variant == AppButtonVariant.danger && color == null
|
||||||
|
? AppColors.error
|
||||||
|
: Colors.white,
|
||||||
|
AppButtonVariant.gradientOutline => AppColors.textPrimary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
BoxDecoration _decoration(Color baseColor, bool enabled) {
|
||||||
|
final disabledColor = const Color(0xFFE5E7EB);
|
||||||
|
return switch (variant) {
|
||||||
|
AppButtonVariant.primary => BoxDecoration(
|
||||||
|
color: enabled ? baseColor : disabledColor,
|
||||||
|
borderRadius: AppRadius.lgBorder,
|
||||||
|
boxShadow: enabled ? AppShadows.soft : AppShadows.none,
|
||||||
|
),
|
||||||
|
AppButtonVariant.secondary => BoxDecoration(
|
||||||
|
color: enabled ? baseColor.withValues(alpha: 0.10) : disabledColor,
|
||||||
|
borderRadius: AppRadius.lgBorder,
|
||||||
|
),
|
||||||
|
AppButtonVariant.outline => BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: AppRadius.lgBorder,
|
||||||
|
border: Border.all(color: enabled ? baseColor.withValues(alpha: 0.38) : disabledColor),
|
||||||
|
),
|
||||||
|
AppButtonVariant.ghost => BoxDecoration(
|
||||||
|
color: enabled ? baseColor.withValues(alpha: 0.08) : disabledColor,
|
||||||
|
borderRadius: AppRadius.lgBorder,
|
||||||
|
),
|
||||||
|
AppButtonVariant.danger => BoxDecoration(
|
||||||
|
color: enabled ? (color ?? AppColors.error) : disabledColor,
|
||||||
|
borderRadius: AppRadius.lgBorder,
|
||||||
|
),
|
||||||
|
AppButtonVariant.gradientOutline => const BoxDecoration(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ButtonContent extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final IconData? icon;
|
||||||
|
final bool loading;
|
||||||
|
final Color foreground;
|
||||||
|
|
||||||
|
const _ButtonContent({
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
required this.loading,
|
||||||
|
required this.foreground,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (loading)
|
||||||
|
SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: foreground),
|
||||||
|
)
|
||||||
|
else if (icon != null)
|
||||||
|
Icon(icon, size: 19, color: foreground),
|
||||||
|
if (loading || icon != null) const SizedBox(width: 8),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: AppTextStyles.button.copyWith(color: foreground),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GradientOutlineButton extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final IconData? icon;
|
||||||
|
final bool loading;
|
||||||
|
final bool enabled;
|
||||||
|
final double height;
|
||||||
|
final bool expand;
|
||||||
|
final LinearGradient gradient;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
|
||||||
|
const _GradientOutlineButton({
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
required this.loading,
|
||||||
|
required this.enabled,
|
||||||
|
required this.height,
|
||||||
|
required this.expand,
|
||||||
|
required this.gradient,
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
height: height,
|
||||||
|
width: expand ? double.infinity : null,
|
||||||
|
padding: const EdgeInsets.all(1.5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: enabled ? gradient : null,
|
||||||
|
color: enabled ? null : AppColors.borderLight,
|
||||||
|
borderRadius: AppRadius.lgBorder,
|
||||||
|
boxShadow: enabled ? AppShadows.soft : AppShadows.none,
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.lg - 1.5),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: enabled ? onPressed : null,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.lg - 1.5),
|
||||||
|
child: Center(
|
||||||
|
child: _ButtonContent(
|
||||||
|
label: label,
|
||||||
|
icon: icon,
|
||||||
|
loading: loading,
|
||||||
|
foreground: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppIconTile extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final Color color;
|
||||||
|
final Color backgroundColor;
|
||||||
|
final double size;
|
||||||
|
final double iconSize;
|
||||||
|
final BorderRadius? borderRadius;
|
||||||
|
|
||||||
|
const AppIconTile({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.color,
|
||||||
|
required this.backgroundColor,
|
||||||
|
this.size = 44,
|
||||||
|
this.iconSize = 22,
|
||||||
|
this.borderRadius,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: borderRadius ?? AppRadius.mdBorder,
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.16)),
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: iconSize, color: color),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
50
health_app/lib/widgets/app_status_badge.dart
Normal file
50
health_app/lib/widgets/app_status_badge.dart
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../core/app_colors.dart';
|
||||||
|
import '../core/app_design_tokens.dart';
|
||||||
|
|
||||||
|
class AppStatusBadge extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final Color color;
|
||||||
|
final Color? backgroundColor;
|
||||||
|
final IconData? icon;
|
||||||
|
|
||||||
|
const AppStatusBadge({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
required this.color,
|
||||||
|
this.backgroundColor,
|
||||||
|
this.icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Container(
|
||||||
|
constraints: const BoxConstraints(minHeight: 24),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor ?? color.withValues(alpha: 0.10),
|
||||||
|
borderRadius: AppRadius.pillBorder,
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.18)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (icon != null) ...[
|
||||||
|
Icon(icon, size: 14, color: color),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
],
|
||||||
|
Text(label, style: AppTextStyles.tag.copyWith(color: color)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppStatusColors {
|
||||||
|
AppStatusColors._();
|
||||||
|
|
||||||
|
static Color done = AppColors.success;
|
||||||
|
static Color warning = AppColors.warning;
|
||||||
|
static Color danger = AppColors.error;
|
||||||
|
static Color info = AppColors.health;
|
||||||
|
}
|
||||||
|
|
||||||
86
health_app/lib/widgets/app_toast.dart
Normal file
86
health_app/lib/widgets/app_toast.dart
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
|
import '../core/app_colors.dart';
|
||||||
|
import '../core/app_design_tokens.dart';
|
||||||
|
|
||||||
|
enum AppToastType { success, error, warning, info }
|
||||||
|
|
||||||
|
class AppToast {
|
||||||
|
AppToast._();
|
||||||
|
|
||||||
|
static OverlayEntry? _entry;
|
||||||
|
|
||||||
|
static void show(
|
||||||
|
BuildContext context,
|
||||||
|
String message, {
|
||||||
|
AppToastType type = AppToastType.info,
|
||||||
|
Duration duration = const Duration(milliseconds: 2200),
|
||||||
|
}) {
|
||||||
|
_entry?.remove();
|
||||||
|
final overlay = Overlay.of(context);
|
||||||
|
final visual = _visual(type);
|
||||||
|
_entry = OverlayEntry(
|
||||||
|
builder: (ctx) => Positioned(
|
||||||
|
top: MediaQuery.paddingOf(ctx).top + 62,
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Center(
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Container(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
minHeight: 42,
|
||||||
|
maxWidth: MediaQuery.sizeOf(ctx).width * 0.82,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: AppRadius.lgBorder,
|
||||||
|
border: Border.all(color: visual.color.withValues(alpha: 0.20)),
|
||||||
|
boxShadow: AppShadows.floating,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(visual.icon, size: 18, color: visual.color),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
height: 1.25,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
overlay.insert(_entry!);
|
||||||
|
Future.delayed(duration, () {
|
||||||
|
_entry?.remove();
|
||||||
|
_entry = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static ({IconData icon, Color color}) _visual(AppToastType type) {
|
||||||
|
return switch (type) {
|
||||||
|
AppToastType.success => (icon: LucideIcons.circleCheck, color: AppColors.success),
|
||||||
|
AppToastType.error => (icon: LucideIcons.circleX, color: AppColors.error),
|
||||||
|
AppToastType.warning => (icon: LucideIcons.triangleAlert, color: AppColors.warning),
|
||||||
|
AppToastType.info => (icon: LucideIcons.info, color: AppColors.health),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../core/app_colors.dart';
|
import '../core/app_colors.dart';
|
||||||
|
import '../core/app_design_tokens.dart';
|
||||||
|
import '../core/app_module_visuals.dart';
|
||||||
import '../models/bp_reading.dart';
|
import '../models/bp_reading.dart';
|
||||||
|
|
||||||
Future<void> showBleSyncDialog(
|
Future<void> showBleSyncDialog(
|
||||||
@@ -17,31 +19,35 @@ Future<void> showBleSyncDialog(
|
|||||||
child: Container(
|
child: Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
constraints: const BoxConstraints(maxWidth: 380),
|
constraints: const BoxConstraints(maxWidth: 380),
|
||||||
padding: const EdgeInsets.fromLTRB(22, 28, 22, 22),
|
padding: const EdgeInsets.fromLTRB(22, 26, 22, 22),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: AppRadius.xlBorder,
|
||||||
boxShadow: [
|
boxShadow: AppShadows.floating,
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withValues(alpha: 0.14),
|
|
||||||
blurRadius: 34,
|
|
||||||
offset: const Offset(0, 18),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: AppModuleVisuals.device.gradient,
|
||||||
|
borderRadius: AppRadius.mdBorder,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
AppModuleVisuals.device.icon,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 25,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Text(
|
||||||
'数据已录入',
|
'数据已录入',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: AppTextStyles.summaryTitle.copyWith(fontSize: 22),
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 22),
|
||||||
const SizedBox(height: 24),
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -51,7 +57,7 @@ Future<void> showBleSyncDialog(
|
|||||||
unit: 'mmHg',
|
unit: 'mmHg',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _MetricCard(
|
child: _MetricCard(
|
||||||
label: '舒张压',
|
label: '舒张压',
|
||||||
@@ -62,7 +68,7 @@ Future<void> showBleSyncDialog(
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (reading.pulse != null) ...[
|
if (reading.pulse != null) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 14),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -76,7 +82,7 @@ Future<void> showBleSyncDialog(
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 26),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => Navigator.pop(ctx),
|
onTap: () => Navigator.pop(ctx),
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -84,17 +90,13 @@ Future<void> showBleSyncDialog(
|
|||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: AppColors.doctorGradient,
|
gradient: AppModuleVisuals.device.gradient,
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: AppRadius.lgBorder,
|
||||||
boxShadow: AppColors.buttonShadow,
|
boxShadow: AppColors.buttonShadow,
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: Text(
|
||||||
'知道了',
|
'知道了',
|
||||||
style: TextStyle(
|
style: AppTextStyles.button.copyWith(color: Colors.white),
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -121,8 +123,9 @@ class _MetricCard extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFF8FAFC),
|
color: AppModuleVisuals.device.lightColor,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: AppRadius.mdBorder,
|
||||||
|
border: Border.all(color: AppModuleVisuals.device.borderColor),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -136,23 +139,9 @@ class _MetricCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(unit, style: AppTextStyles.tag),
|
||||||
unit,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: AppColors.textHint,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(label, style: AppTextStyles.tag),
|
||||||
label,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../core/app_colors.dart';
|
import '../core/app_colors.dart';
|
||||||
|
import '../core/app_design_tokens.dart';
|
||||||
import '../core/app_theme.dart';
|
import '../core/app_theme.dart';
|
||||||
|
|
||||||
class EnterpriseStat {
|
class EnterpriseStat {
|
||||||
@@ -35,12 +36,12 @@ class EnterpriseHeader extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: AppSpacing.panel,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: AppRadius.xlBorder,
|
||||||
border: Border.all(color: AppColors.border, width: 1.1),
|
border: Border.all(color: AppColors.border, width: 1.1),
|
||||||
boxShadow: [AppTheme.shadowLight],
|
boxShadow: AppShadows.soft,
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -71,23 +72,14 @@ class EnterpriseHeader extends StatelessWidget {
|
|||||||
title,
|
title,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: AppTextStyles.summaryTitle,
|
||||||
color: AppColors.textPrimary,
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 3),
|
const SizedBox(height: 3),
|
||||||
Text(
|
Text(
|
||||||
subtitle,
|
subtitle,
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: AppTextStyles.summarySubtitle,
|
||||||
color: AppColors.textSecondary,
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
height: 1.25,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -143,7 +135,7 @@ class _HeaderStat extends StatelessWidget {
|
|||||||
accent.withValues(alpha: 0.06),
|
accent.withValues(alpha: 0.06),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: AppRadius.smBorder,
|
||||||
border: Border.all(color: AppColors.borderLight, width: 1.1),
|
border: Border.all(color: AppColors.borderLight, width: 1.1),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
import '../core/app_colors.dart';
|
import '../core/app_colors.dart';
|
||||||
|
import '../core/app_design_tokens.dart';
|
||||||
import '../core/navigation_provider.dart';
|
import '../core/navigation_provider.dart';
|
||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
import '../providers/chat_provider.dart';
|
import '../providers/chat_provider.dart';
|
||||||
import '../providers/conversation_history_provider.dart';
|
import '../providers/conversation_history_provider.dart';
|
||||||
|
import 'app_toast.dart';
|
||||||
import '../providers/data_providers.dart';
|
import '../providers/data_providers.dart';
|
||||||
import 'drawer_shell.dart';
|
import 'drawer_shell.dart';
|
||||||
|
|
||||||
@@ -23,7 +26,7 @@ class HealthDrawer extends ConsumerWidget {
|
|||||||
physics: const BouncingScrollPhysics(),
|
physics: const BouncingScrollPhysics(),
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 24),
|
padding: const EdgeInsets.fromLTRB(12, 16, 12, 24),
|
||||||
sliver: SliverList.list(
|
sliver: SliverList.list(
|
||||||
children: [
|
children: [
|
||||||
_AccountHeader(user: auth.user, ref: ref),
|
_AccountHeader(user: auth.user, ref: ref),
|
||||||
@@ -305,71 +308,66 @@ class _NavigationSection extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final items = [
|
final items = [
|
||||||
_NavItem(
|
_NavItem(
|
||||||
icon: Icons.folder_shared_rounded,
|
icon: LucideIcons.folderHeart,
|
||||||
title: '档案',
|
title: '档案',
|
||||||
route: 'healthArchive',
|
route: 'healthArchive',
|
||||||
colors: const [Color(0xFF93C5FD), Color(0xFF60A5FA)],
|
colors: const [Color(0xFF93C5FD), Color(0xFF60A5FA)],
|
||||||
),
|
),
|
||||||
_NavItem(
|
_NavItem(
|
||||||
icon: Icons.description_rounded,
|
icon: LucideIcons.fileText,
|
||||||
title: '报告',
|
title: '报告',
|
||||||
route: 'reports',
|
route: 'reports',
|
||||||
colors: const [Color(0xFFBFDBFE), Color(0xFF93C5FD)],
|
colors: const [Color(0xFFBFDBFE), Color(0xFF93C5FD)],
|
||||||
),
|
),
|
||||||
_NavItem(
|
_NavItem(
|
||||||
icon: Icons.medication_rounded,
|
icon: LucideIcons.pill,
|
||||||
title: '用药',
|
title: '用药',
|
||||||
route: 'medications',
|
route: 'medications',
|
||||||
colors: const [Color(0xFFDDD6FE), Color(0xFFC4B5FD)],
|
colors: const [Color(0xFFDDD6FE), Color(0xFFC4B5FD)],
|
||||||
),
|
),
|
||||||
_NavItem(
|
_NavItem(
|
||||||
icon: Icons.restaurant_rounded,
|
icon: LucideIcons.utensils,
|
||||||
title: '饮食',
|
title: '饮食',
|
||||||
route: 'dietRecords',
|
route: 'dietRecords',
|
||||||
colors: const [Color(0xFFFBCFE8), Color(0xFFF472B6)],
|
colors: const [Color(0xFFFBCFE8), Color(0xFFF472B6)],
|
||||||
),
|
),
|
||||||
_NavItem(
|
_NavItem(
|
||||||
icon: Icons.calendar_month_rounded,
|
icon: LucideIcons.calendarDays,
|
||||||
title: '日历',
|
title: '日历',
|
||||||
route: 'calendar',
|
route: 'calendar',
|
||||||
colors: const [Color(0xFFC4B5FD), Color(0xFF8B5CF6)],
|
colors: const [Color(0xFFC4B5FD), Color(0xFF8B5CF6)],
|
||||||
),
|
),
|
||||||
_NavItem(
|
_NavItem(
|
||||||
icon: Icons.event_available_rounded,
|
icon: LucideIcons.calendarCheck,
|
||||||
title: '随访',
|
title: '随访',
|
||||||
route: 'followups',
|
route: 'followups',
|
||||||
colors: const [Color(0xFFFBCFE8), Color(0xFFF9A8D4)],
|
colors: const [Color(0xFFFBCFE8), Color(0xFFF9A8D4)],
|
||||||
),
|
),
|
||||||
_NavItem(
|
_NavItem(
|
||||||
icon: Icons.directions_run_rounded,
|
icon: LucideIcons.footprints,
|
||||||
title: '运动',
|
title: '运动',
|
||||||
route: 'exercisePlan',
|
route: 'exercisePlan',
|
||||||
colors: const [Color(0xFFA5F3FC), Color(0xFF67E8F9)],
|
colors: const [Color(0xFFA5F3FC), Color(0xFF67E8F9)],
|
||||||
),
|
),
|
||||||
_NavItem(
|
_NavItem(
|
||||||
icon: Icons.bluetooth_connected_rounded,
|
icon: LucideIcons.bluetooth,
|
||||||
title: '蓝牙设备',
|
title: '设备',
|
||||||
route: 'devices',
|
route: 'devices',
|
||||||
colors: const [Color(0xFF60A5FA), Color(0xFFC4B5FD)],
|
colors: const [Color(0xFF60A5FA), Color(0xFFC4B5FD)],
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
return _Panel(
|
return _LightSection(
|
||||||
title: '功能入口',
|
title: '常用功能',
|
||||||
backgroundGradient: const LinearGradient(
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [Color(0xFAFFFFFF), Color(0xF5EFF6FF)],
|
|
||||||
),
|
|
||||||
child: GridView.builder(
|
child: GridView.builder(
|
||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 3,
|
crossAxisCount: 2,
|
||||||
mainAxisExtent: 98,
|
mainAxisExtent: 48,
|
||||||
mainAxisSpacing: 10,
|
mainAxisSpacing: 8,
|
||||||
crossAxisSpacing: 10,
|
crossAxisSpacing: 8,
|
||||||
),
|
),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = items[index];
|
final item = items[index];
|
||||||
@@ -398,14 +396,14 @@ class _NavTile extends StatelessWidget {
|
|||||||
};
|
};
|
||||||
|
|
||||||
List<Color> get _colors => switch (item.route) {
|
List<Color> get _colors => switch (item.route) {
|
||||||
'healthArchive' => const [Color(0xFF93C5FD), Color(0xFF60A5FA)],
|
'healthArchive' => const [AppColors.healthLight, AppColors.health],
|
||||||
'reports' => const [Color(0xFF60A5FA), Color(0xFF2563EB)],
|
'reports' => const [AppColors.reportLight, AppColors.report],
|
||||||
'medications' => const [Color(0xFFA78BFA), Color(0xFF7C3AED)],
|
'medications' => const [AppColors.medicationLight, AppColors.medication],
|
||||||
'dietRecords' => const [Color(0xFFFBCFE8), Color(0xFFF472B6)],
|
'dietRecords' => const [AppColors.dietLight, AppColors.diet],
|
||||||
'calendar' => const [Color(0xFFC4B5FD), Color(0xFF8B5CF6)],
|
'calendar' => const [AppColors.calendarLight, AppColors.calendar],
|
||||||
'followups' => const [Color(0xFFF472B6), Color(0xFFDB2777)],
|
'followups' => const [AppColors.followupLight, AppColors.followup],
|
||||||
'exercisePlan' => const [Color(0xFF7DD3FC), Color(0xFF60A5FA)],
|
'exercisePlan' => const [AppColors.exerciseLight, AppColors.exercise],
|
||||||
'devices' => const [Color(0xFF60A5FA), Color(0xFFC4B5FD)],
|
'devices' => const [AppColors.deviceLight, AppColors.device],
|
||||||
_ => item.colors,
|
_ => item.colors,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -413,41 +411,41 @@ class _NavTile extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: AppRadius.mdBorder,
|
||||||
child: Container(
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white.withValues(alpha: 0.54),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: AppRadius.mdBorder,
|
||||||
border: Border.all(color: AppColors.borderLight, width: 1.1),
|
|
||||||
boxShadow: AppColors.cardShadowLight,
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 44,
|
width: 30,
|
||||||
height: 44,
|
height: 30,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: _colors,
|
colors: _colors,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: AppRadius.smBorder,
|
||||||
),
|
),
|
||||||
child: Icon(item.icon, color: Colors.white, size: 24),
|
child: Icon(item.icon, color: Colors.white, size: 19),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 9),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Expanded(
|
||||||
|
child: Text(
|
||||||
_title,
|
_title,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w800,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -455,6 +453,39 @@ class _NavTile extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _LightSection extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const _LightSection({required this.title, required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
child,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class _Panel extends StatelessWidget {
|
class _Panel extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final Widget child;
|
final Widget child;
|
||||||
@@ -484,18 +515,12 @@ class _Panel extends StatelessWidget {
|
|||||||
const Color(0xFFF8F4FF).withValues(alpha: 0.84),
|
const Color(0xFFF8F4FF).withValues(alpha: 0.84),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: AppRadius.xlBorder,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: Colors.white.withValues(alpha: 0.82),
|
color: Colors.white.withValues(alpha: 0.82),
|
||||||
width: 1.2,
|
width: 1.2,
|
||||||
),
|
),
|
||||||
boxShadow: [
|
boxShadow: AppShadows.soft,
|
||||||
BoxShadow(
|
|
||||||
color: const Color(0xFF6D5DF6).withValues(alpha: 0.08),
|
|
||||||
blurRadius: 22,
|
|
||||||
offset: const Offset(0, 12),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -611,8 +636,6 @@ class _NavItem {
|
|||||||
required this.colors,
|
required this.colors,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 侧边栏底部:最近 5 条对话历史 + 查看全部 + 清空。
|
|
||||||
class _HistorySection extends ConsumerWidget {
|
class _HistorySection extends ConsumerWidget {
|
||||||
final WidgetRef ref;
|
final WidgetRef ref;
|
||||||
const _HistorySection({required this.ref});
|
const _HistorySection({required this.ref});
|
||||||
@@ -668,7 +691,7 @@ class _HistorySection extends ConsumerWidget {
|
|||||||
item: preview[i],
|
item: preview[i],
|
||||||
isLast: i == preview.length - 1,
|
isLast: i == preview.length - 1,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
Navigator.of(context).maybePop(); // 关侧边栏
|
Navigator.of(context).maybePop(); // 关闭侧边栏
|
||||||
await ref
|
await ref
|
||||||
.read(chatProvider.notifier)
|
.read(chatProvider.notifier)
|
||||||
.loadConversation(preview[i].id);
|
.loadConversation(preview[i].id);
|
||||||
@@ -779,11 +802,10 @@ class _HistoryActions extends StatelessWidget {
|
|||||||
await ref.read(conversationHistoryProvider.notifier).clearAll();
|
await ref.read(conversationHistoryProvider.notifier).clearAll();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppToast.show(
|
||||||
const SnackBar(
|
context,
|
||||||
content: Text('清空失败,请稍后重试'),
|
'清空失败,请稍后重试',
|
||||||
backgroundColor: AppColors.error,
|
type: AppToastType.error,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
health_app/test/api_client_test.dart
Normal file
19
health_app/test/api_client_test.dart
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:health_app/core/api_client.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('AuthExpiredNotifier notifies all current listeners once', () {
|
||||||
|
final notifier = AuthExpiredNotifier();
|
||||||
|
var first = 0;
|
||||||
|
var second = 0;
|
||||||
|
|
||||||
|
final removeFirst = notifier.addListener(() => first++);
|
||||||
|
notifier.addListener(() => second++);
|
||||||
|
notifier.notify();
|
||||||
|
removeFirst();
|
||||||
|
notifier.notify();
|
||||||
|
|
||||||
|
expect(first, 1);
|
||||||
|
expect(second, 2);
|
||||||
|
});
|
||||||
|
}
|
||||||
27
health_app/test/app_router_test.dart
Normal file
27
health_app/test/app_router_test.dart
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:health_app/core/app_router.dart';
|
||||||
|
import 'package:health_app/core/navigation_provider.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets(
|
||||||
|
'detail routes show an error instead of throwing when id is missing',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
child: Consumer(
|
||||||
|
builder: (context, ref, _) {
|
||||||
|
return MaterialApp(
|
||||||
|
home: buildPage(const RouteInfo('aiAnalysis'), ref),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.text('页面参数错误'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
11
health_app/test/report_polling_test.dart
Normal file
11
health_app/test/report_polling_test.dart
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:health_app/pages/report/report_pages.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('report polling backs off and stops after the retry budget', () {
|
||||||
|
expect(reportAnalysisPollDelay(1), const Duration(seconds: 4));
|
||||||
|
expect(reportAnalysisPollDelay(3), const Duration(seconds: 8));
|
||||||
|
expect(reportAnalysisPollDelay(6), const Duration(seconds: 12));
|
||||||
|
expect(reportAnalysisPollDelay(16), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user