Compare commits
22 Commits
415c7ca082
...
sccsbc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a9d56b07c | ||
|
|
e52e21d295 | ||
| ec5af19d30 | |||
|
|
bd4350c17f | ||
|
|
fe4c81c54f | ||
|
|
d82e006cf4 | ||
|
|
1c020b8ae5 | ||
|
|
7a93237069 | ||
|
|
4507083f3f | ||
|
|
a748c316f2 | ||
|
|
fa2cc994fa | ||
|
|
df37076c46 | ||
|
|
86c7957144 | ||
|
|
18df2f3285 | ||
|
|
53ce19e8c3 | ||
|
|
7ff429e071 | ||
|
|
73d99d56f6 | ||
|
|
80540e2191 | ||
|
|
6ce010e46a | ||
|
|
431b72d49a | ||
|
|
a479e5e95d | ||
|
|
a7fb6e5ff3 |
4
.gitignore
vendored
@@ -4,6 +4,10 @@
|
|||||||
*.key
|
*.key
|
||||||
*.pfx
|
*.pfx
|
||||||
|
|
||||||
|
# Android release keystore
|
||||||
|
health_app/android/**/*.jks
|
||||||
|
health_app/android/key.properties
|
||||||
|
|
||||||
# .NET build outputs
|
# .NET build outputs
|
||||||
backend/**/bin/
|
backend/**/bin/
|
||||||
backend/**/obj/
|
backend/**/obj/
|
||||||
|
|||||||
@@ -28,12 +28,15 @@ public sealed record OpenConversationResult(
|
|||||||
public interface IAiConversationService
|
public interface IAiConversationService
|
||||||
{
|
{
|
||||||
Task<OpenConversationResult> OpenAsync(Guid userId, Guid? conversationId, AgentType agentType, string firstMessage, CancellationToken ct);
|
Task<OpenConversationResult> OpenAsync(Guid userId, Guid? conversationId, AgentType agentType, string firstMessage, CancellationToken ct);
|
||||||
Task AddUserMessageAsync(Guid conversationId, string content, CancellationToken ct);
|
Task AddUserMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct);
|
||||||
Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct);
|
Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct);
|
||||||
|
Task AddAssistantMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct);
|
||||||
Task<IReadOnlyList<AiConversationMessageDto>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
|
Task<IReadOnlyList<AiConversationMessageDto>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
|
||||||
Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct);
|
Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct);
|
||||||
Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
||||||
|
Task UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct);
|
||||||
Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
||||||
|
Task<int> DeleteAllAsync(Guid userId, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IAiConversationRepository
|
public interface IAiConversationRepository
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Health.Application.AI;
|
|||||||
|
|
||||||
public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService
|
public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService
|
||||||
{
|
{
|
||||||
|
private const int HistoryConversationLimit = 7;
|
||||||
private readonly IAiConversationRepository _conversations = conversations;
|
private readonly IAiConversationRepository _conversations = conversations;
|
||||||
|
|
||||||
public async Task<OpenConversationResult> OpenAsync(
|
public async Task<OpenConversationResult> OpenAsync(
|
||||||
@@ -33,14 +34,18 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
|||||||
};
|
};
|
||||||
await _conversations.AddConversationAsync(conversation, ct);
|
await _conversations.AddConversationAsync(conversation, ct);
|
||||||
await _conversations.SaveChangesAsync(ct);
|
await _conversations.SaveChangesAsync(ct);
|
||||||
|
await PruneHistoryAsync(userId, ct);
|
||||||
return new OpenConversationResult(true, true, conversation.Id);
|
return new OpenConversationResult(true, true, conversation.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task AddUserMessageAsync(Guid conversationId, string content, CancellationToken ct) =>
|
public async Task AddUserMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct) =>
|
||||||
await AddMessageAsync(conversationId, MessageRole.User, content, updateSummary: false, ct);
|
await AddMessageAsync(conversationId, MessageRole.User, content, updateSummary: false, metadataJson, ct);
|
||||||
|
|
||||||
public async Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct) =>
|
public async Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct) =>
|
||||||
await AddMessageAsync(conversationId, MessageRole.Assistant, content, updateSummary: true, ct);
|
await AddMessageAsync(conversationId, MessageRole.Assistant, content, updateSummary: true, metadataJson: null, ct);
|
||||||
|
|
||||||
|
public async Task AddAssistantMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct) =>
|
||||||
|
await AddMessageAsync(conversationId, MessageRole.Assistant, content, updateSummary: true, metadataJson, ct);
|
||||||
|
|
||||||
public async Task<IReadOnlyList<AiConversationMessageDto>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct)
|
public async Task<IReadOnlyList<AiConversationMessageDto>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -51,7 +56,7 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
|||||||
public async Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct)
|
public async Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var conversations = await _conversations.ListAsync(userId, ct);
|
var conversations = await _conversations.ListAsync(userId, ct);
|
||||||
return conversations.Select(ToDto).ToList();
|
return conversations.Take(HistoryConversationLimit).Select(ToDto).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct)
|
public async Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct)
|
||||||
@@ -63,6 +68,17 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
|||||||
return messages.Select(ToMessageDto).ToList();
|
return messages.Select(ToMessageDto).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var conversation = await _conversations.GetAsync(conversationId, ct)
|
||||||
|
?? throw new InvalidOperationException("会话不存在");
|
||||||
|
var normalized = summary.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(normalized)) return;
|
||||||
|
|
||||||
|
conversation.Summary = normalized.Length > 24 ? normalized[..24] : normalized;
|
||||||
|
await _conversations.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct)
|
public async Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
|
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
|
||||||
@@ -72,7 +88,19 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
|||||||
await _conversations.SaveChangesAsync(ct);
|
await _conversations.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AddMessageAsync(Guid conversationId, MessageRole role, string content, bool updateSummary, CancellationToken ct)
|
public async Task<int> DeleteAllAsync(Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var list = await _conversations.ListAsync(userId, ct);
|
||||||
|
foreach (var conv in list)
|
||||||
|
{
|
||||||
|
_conversations.Delete(conv);
|
||||||
|
}
|
||||||
|
if (list.Count > 0)
|
||||||
|
await _conversations.SaveChangesAsync(ct);
|
||||||
|
return list.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddMessageAsync(Guid conversationId, MessageRole role, string content, bool updateSummary, string? metadataJson, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var conversation = await _conversations.GetAsync(conversationId, ct)
|
var conversation = await _conversations.GetAsync(conversationId, ct)
|
||||||
?? throw new InvalidOperationException("会话不存在");
|
?? throw new InvalidOperationException("会话不存在");
|
||||||
@@ -82,6 +110,7 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
|||||||
ConversationId = conversationId,
|
ConversationId = conversationId,
|
||||||
Role = role,
|
Role = role,
|
||||||
Content = content,
|
Content = content,
|
||||||
|
MetadataJson = metadataJson,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
}, ct);
|
}, ct);
|
||||||
|
|
||||||
@@ -90,6 +119,18 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
|||||||
if (updateSummary)
|
if (updateSummary)
|
||||||
conversation.Summary = content.Length > 100 ? content[..100] : content;
|
conversation.Summary = content.Length > 100 ? content[..100] : content;
|
||||||
await _conversations.SaveChangesAsync(ct);
|
await _conversations.SaveChangesAsync(ct);
|
||||||
|
await PruneHistoryAsync(conversation.UserId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PruneHistoryAsync(Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var list = await _conversations.ListAsync(userId, ct);
|
||||||
|
var expired = list.Skip(HistoryConversationLimit).ToList();
|
||||||
|
if (expired.Count == 0) return;
|
||||||
|
|
||||||
|
foreach (var conversation in expired)
|
||||||
|
_conversations.Delete(conversation);
|
||||||
|
await _conversations.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AiConversationDto ToDto(Conversation conversation) => new(
|
private static AiConversationDto ToDto(Conversation conversation) => new(
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace Health.Application.AI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用户消息附带的图片/PDF 解析结果。
|
||||||
|
/// 既用作 LLM 上下文拼装,也持久化到 ConversationMessage.MetadataJson。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AttachmentContext(
|
||||||
|
string Kind, // "image" 或 "pdf"
|
||||||
|
string? Category, // 仅图片:food / report / wound / drug / chart / other
|
||||||
|
string? FileName, // PDF 文件原名
|
||||||
|
string Summary, // 简短摘要,用于持久化和历史回顾
|
||||||
|
string LlmContent); // 完整文本,拼到当前轮 LLM user content 前
|
||||||
|
|
||||||
|
public interface IAttachmentContextBuilder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 根据 imageUrl 或 pdfUrl 构建附件上下文。两个都为空返回 null。
|
||||||
|
/// </summary>
|
||||||
|
Task<AttachmentContext?> BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@ namespace Health.Application.Auth;
|
|||||||
|
|
||||||
public sealed record AuthResult(int Code, object? Data, string? Message = null);
|
public sealed record AuthResult(int Code, object? Data, string? Message = null);
|
||||||
public sealed record RegisterCommand(string Phone, string SmsCode, string Name, Guid DoctorId);
|
public sealed record RegisterCommand(string Phone, string SmsCode, string Name, Guid DoctorId);
|
||||||
|
public sealed record AppleLoginCommand(string IdentityToken, string? AuthorizationCode, string? Name);
|
||||||
|
|
||||||
public interface IAuthService
|
public interface IAuthService
|
||||||
{
|
{
|
||||||
Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct);
|
Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct);
|
||||||
Task<AuthResult> RegisterAsync(RegisterCommand command, CancellationToken ct);
|
Task<AuthResult> RegisterAsync(RegisterCommand command, CancellationToken ct);
|
||||||
Task<AuthResult> LoginAsync(string phone, string smsCode, CancellationToken ct);
|
Task<AuthResult> LoginAsync(string phone, string smsCode, CancellationToken ct);
|
||||||
|
Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct);
|
||||||
Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct);
|
Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct);
|
||||||
Task LogoutAsync(string refreshToken, CancellationToken ct);
|
Task LogoutAsync(string refreshToken, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,6 @@ using Health.Domain.Entities;
|
|||||||
|
|
||||||
namespace Health.Application.Exercises;
|
namespace Health.Application.Exercises;
|
||||||
|
|
||||||
public sealed record ExercisePlanItemInput(
|
|
||||||
DateOnly ScheduledDate,
|
|
||||||
string? ExerciseType,
|
|
||||||
int DurationMinutes,
|
|
||||||
bool IsRestDay);
|
|
||||||
|
|
||||||
public sealed record ExercisePlanCreateRequest(
|
public sealed record ExercisePlanCreateRequest(
|
||||||
DateOnly StartDate,
|
DateOnly StartDate,
|
||||||
DateOnly EndDate,
|
DateOnly EndDate,
|
||||||
@@ -47,7 +41,6 @@ public interface IExerciseService
|
|||||||
{
|
{
|
||||||
Task<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct);
|
Task<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct);
|
||||||
Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct);
|
Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct);
|
||||||
Task<Guid> CreateFromItemsAsync(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime, IReadOnlyList<ExercisePlanItemInput> items, CancellationToken ct);
|
|
||||||
Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct);
|
Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct);
|
||||||
Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct);
|
Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct);
|
||||||
Task<bool> DeleteAsync(Guid userId, Guid planId, CancellationToken ct);
|
Task<bool> DeleteAsync(Guid userId, Guid planId, CancellationToken ct);
|
||||||
|
|||||||
@@ -41,26 +41,6 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
|
|||||||
return plan.Id;
|
return plan.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Guid> CreateFromItemsAsync(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime, IReadOnlyList<ExercisePlanItemInput> items, CancellationToken ct)
|
|
||||||
{
|
|
||||||
foreach (var item in items)
|
|
||||||
ValidateDuration(item.DurationMinutes);
|
|
||||||
|
|
||||||
var normalizedEnd = endDate < startDate ? startDate : endDate;
|
|
||||||
var plan = NewPlan(userId, startDate, normalizedEnd, reminderTime);
|
|
||||||
foreach (var item in items.Where(x => x.ScheduledDate >= startDate && x.ScheduledDate <= normalizedEnd).GroupBy(x => x.ScheduledDate).Select(x => x.First()))
|
|
||||||
{
|
|
||||||
plan.Items.Add(new ExercisePlanItem
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(), ScheduledDate = item.ScheduledDate,
|
|
||||||
ExerciseType = string.IsNullOrWhiteSpace(item.ExerciseType) ? "散步" : item.ExerciseType.Trim(),
|
|
||||||
DurationMinutes = item.DurationMinutes > 0 ? item.DurationMinutes : 30,
|
|
||||||
IsRestDay = item.IsRestDay,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await SaveNewAsync(plan, ct);
|
|
||||||
return plan.Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 时长上限校验:下限交由各方法兜底为 30,这里只拦截不合理的超大值
|
// 时长上限校验:下限交由各方法兜底为 30,这里只拦截不合理的超大值
|
||||||
private static void ValidateDuration(int durationMinutes)
|
private static void ValidateDuration(int durationMinutes)
|
||||||
@@ -97,6 +77,10 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
|
|||||||
{
|
{
|
||||||
var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct);
|
var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct);
|
||||||
if (item == null) return null;
|
if (item == null) return null;
|
||||||
|
if (item.ScheduledDate != BeijingToday())
|
||||||
|
throw new ValidationException("只能打卡今天的运动任务");
|
||||||
|
if (item.IsRestDay)
|
||||||
|
throw new ValidationException("休息日无需打卡");
|
||||||
item.IsCompleted = !item.IsCompleted;
|
item.IsCompleted = !item.IsCompleted;
|
||||||
item.CompletedAt = item.IsCompleted ? DateTime.UtcNow : null;
|
item.CompletedAt = item.IsCompleted ? DateTime.UtcNow : null;
|
||||||
item.Plan.UpdatedAt = DateTime.UtcNow;
|
item.Plan.UpdatedAt = DateTime.UtcNow;
|
||||||
@@ -108,6 +92,10 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
|
|||||||
{
|
{
|
||||||
var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct);
|
var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct);
|
||||||
if (item == null) return false;
|
if (item == null) return false;
|
||||||
|
if (item.ScheduledDate != BeijingToday())
|
||||||
|
throw new ValidationException("只能打卡今天的运动任务");
|
||||||
|
if (item.IsRestDay)
|
||||||
|
throw new ValidationException("休息日无需打卡");
|
||||||
item.IsCompleted = true;
|
item.IsCompleted = true;
|
||||||
item.CompletedAt = DateTime.UtcNow;
|
item.CompletedAt = DateTime.UtcNow;
|
||||||
item.Plan.UpdatedAt = DateTime.UtcNow;
|
item.Plan.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ public sealed record HealthArchiveUpdateRequest(
|
|||||||
public interface IHealthArchiveService
|
public interface IHealthArchiveService
|
||||||
{
|
{
|
||||||
Task<HealthArchiveDto?> GetAsync(Guid userId, CancellationToken ct);
|
Task<HealthArchiveDto?> GetAsync(Guid userId, CancellationToken ct);
|
||||||
Task<HealthArchiveDto> GetOrCreateAsync(Guid userId, CancellationToken ct);
|
|
||||||
Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct);
|
Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct);
|
||||||
Task<object> ExecuteAiActionAsync(Guid userId, string action, HealthArchiveUpdateRequest request, CancellationToken ct);
|
Task<object> ExecuteAiActionAsync(Guid userId, string action, HealthArchiveUpdateRequest request, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,6 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
|||||||
return archive == null ? null : ToDto(archive);
|
return archive == null ? null : ToDto(archive);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<HealthArchiveDto> GetOrCreateAsync(Guid userId, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var archive = await GetOrCreateEntityAsync(userId, ct);
|
|
||||||
await _archives.SaveChangesAsync(ct);
|
|
||||||
return ToDto(archive);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct)
|
public async Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var archive = await GetOrCreateEntityAsync(userId, ct);
|
var archive = await GetOrCreateEntityAsync(userId, ct);
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ public sealed class ReportService(
|
|||||||
IReportFileStorage fileStorage,
|
IReportFileStorage fileStorage,
|
||||||
IReportAnalysisQueue queue) : IReportService
|
IReportAnalysisQueue queue) : IReportService
|
||||||
{
|
{
|
||||||
private const long MaxReportImageBytes = 10 * 1024 * 1024;
|
private const long MaxReportFileBytes = 20 * 1024 * 1024;
|
||||||
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
".jpg", ".jpeg", ".png", ".webp"
|
".jpg", ".jpeg", ".png", ".webp", ".pdf"
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly IReportRepository _reports = reports;
|
private readonly IReportRepository _reports = reports;
|
||||||
@@ -35,12 +35,12 @@ public sealed class ReportService(
|
|||||||
if (file.Length <= 0)
|
if (file.Length <= 0)
|
||||||
return Fail(400, "未上传文件");
|
return Fail(400, "未上传文件");
|
||||||
|
|
||||||
if (file.Length > MaxReportImageBytes)
|
if (file.Length > MaxReportFileBytes)
|
||||||
return Fail(400, "报告图片不能超过 10MB,请压缩后重新上传");
|
return Fail(400, "报告文件不能超过 20MB,请压缩后重新上传");
|
||||||
|
|
||||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||||
if (!AllowedExtensions.Contains(ext))
|
if (!AllowedExtensions.Contains(ext))
|
||||||
return Fail(400, "目前仅支持上传 JPG、PNG、WEBP 格式的报告图片");
|
return Fail(400, "目前支持上传 JPG、PNG、WEBP 图片或 PDF 报告");
|
||||||
|
|
||||||
var storedFile = await _fileStorage.SaveAsync(file, ext, ct);
|
var storedFile = await _fileStorage.SaveAsync(file, ext, ct);
|
||||||
var report = new Report
|
var report = new Report
|
||||||
@@ -48,7 +48,7 @@ public sealed class ReportService(
|
|||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
FileUrl = storedFile.FileUrl,
|
FileUrl = storedFile.FileUrl,
|
||||||
FileType = ReportFileType.Image,
|
FileType = ext == ".pdf" ? ReportFileType.Pdf : ReportFileType.Image,
|
||||||
Category = ReportCategory.Other,
|
Category = ReportCategory.Other,
|
||||||
Status = ReportStatus.Analyzing,
|
Status = ReportStatus.Analyzing,
|
||||||
AiSummary = null,
|
AiSummary = null,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace Health.Application.Users;
|
|||||||
|
|
||||||
public sealed record UserProfileDto(
|
public sealed record UserProfileDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string Phone,
|
string? Phone, // Apple 用户无手机号
|
||||||
string Role,
|
string Role,
|
||||||
string? Name,
|
string? Name,
|
||||||
string? Gender,
|
string? Gender,
|
||||||
|
|||||||
@@ -89,6 +89,13 @@ public sealed class NotificationPreference
|
|||||||
public bool FollowUpReminder { get; set; } = true;
|
public bool FollowUpReminder { get; set; } = true;
|
||||||
public bool DoctorReply { get; set; } = true;
|
public bool DoctorReply { get; set; } = true;
|
||||||
public bool AbnormalAlert { get; set; } = true;
|
public bool AbnormalAlert { get; set; } = true;
|
||||||
|
// 健康录入提醒——总开关 + 5 子项
|
||||||
|
public bool HealthRecordReminder { get; set; } = true;
|
||||||
|
public bool HealthRecordReminderBloodPressure { get; set; } = true;
|
||||||
|
public bool HealthRecordReminderHeartRate { get; set; } = true;
|
||||||
|
public bool HealthRecordReminderGlucose { get; set; } = true;
|
||||||
|
public bool HealthRecordReminderSpO2 { get; set; } = true;
|
||||||
|
public bool HealthRecordReminderWeight { get; set; } = true;
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
public User User { get; set; } = null!;
|
public User User { get; set; } = null!;
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ namespace Health.Domain.Entities;
|
|||||||
public sealed class User
|
public sealed class User
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public string Phone { get; set; } = string.Empty;
|
public string? Phone { get; set; } // Apple 用户无手机号
|
||||||
|
public string? AppleUserId { get; set; } // Apple Sign-In 唯一标识
|
||||||
public string Role { get; set; } = "User"; // "User" | "Doctor" | "Admin"
|
public string Role { get; set; } = "User"; // "User" | "Doctor" | "Admin"
|
||||||
public Guid? DoctorId { get; set; } // 患者选择的医生
|
public Guid? DoctorId { get; set; } // 患者选择的医生
|
||||||
public string? Name { get; set; }
|
public string? Name { get; set; }
|
||||||
|
|||||||
@@ -33,21 +33,17 @@ public static class MedicationAgentHandler
|
|||||||
};
|
};
|
||||||
|
|
||||||
public static List<ToolDefinition> Tools => [ManageMedicationTool, CommonAgentHandler.CheckArchiveTool];
|
public static List<ToolDefinition> Tools => [ManageMedicationTool, CommonAgentHandler.CheckArchiveTool];
|
||||||
|
|
||||||
public static async Task<object> Execute(
|
public static async Task<object> Execute(
|
||||||
string toolName,
|
string toolName,
|
||||||
JsonElement args,
|
JsonElement args,
|
||||||
AppDbContext db,
|
|
||||||
Guid userId,
|
Guid userId,
|
||||||
IMedicationService? medications = null,
|
IMedicationService medications,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
return toolName switch
|
return toolName switch
|
||||||
{
|
{
|
||||||
"manage_medication" => medications != null
|
"manage_medication" => await ExecuteManageMedication(medications, userId, args, ct),
|
||||||
? await ExecuteManageMedication(medications, userId, args, ct)
|
_ => new { success = false, message = $"鏈煡宸ュ叿: {toolName}" }
|
||||||
: await ExecuteManageMedication(db, userId, args),
|
|
||||||
_ => new { success = false, message = $"未知工具: {toolName}" }
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,82 +107,6 @@ public static class MedicationAgentHandler
|
|||||||
return success ? new { success = true } : new { success = false, message = "药品不存在或今天已经打卡" };
|
return success ? new { success = true } : new { success = false, message = "药品不存在或今天已经打卡" };
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<object> ExecuteManageMedication(AppDbContext db, Guid userId, JsonElement args)
|
|
||||||
{
|
|
||||||
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
|
|
||||||
return action switch
|
|
||||||
{
|
|
||||||
"create" => await CreateMedication(db, userId, args),
|
|
||||||
"query" => await QueryMedications(db, userId),
|
|
||||||
"confirm" => await ConfirmMedication(db, userId, args),
|
|
||||||
_ => new { success = false, message = $"未知操作: {action}" }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<object> CreateMedication(AppDbContext db, Guid userId, JsonElement args)
|
|
||||||
{
|
|
||||||
var name = args.TryGetProperty("name", out var n) ? n.GetString()! : "";
|
|
||||||
var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null;
|
|
||||||
var frequency = ReadFrequency(args);
|
|
||||||
var times = ReadTimes(args);
|
|
||||||
var durationDays = args.TryGetProperty("duration_days", out var dd) ? dd.GetInt32() : 0;
|
|
||||||
var startDate = ReadStartDate(args);
|
|
||||||
|
|
||||||
var med = new Medication
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
UserId = userId,
|
|
||||||
Name = name,
|
|
||||||
Dosage = dosage,
|
|
||||||
Frequency = frequency,
|
|
||||||
TimeOfDay = times.Count > 0 ? times : [new TimeOnly(8, 0)],
|
|
||||||
Source = MedicationSource.AiEntry,
|
|
||||||
IsActive = true,
|
|
||||||
StartDate = startDate,
|
|
||||||
EndDate = durationDays > 0 ? startDate.AddDays(durationDays) : null,
|
|
||||||
};
|
|
||||||
db.Medications.Add(med);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
|
|
||||||
var timeLabels = times.Count > 0 ? string.Join(", ", times.Select(t => t.ToString("HH:mm"))) : "08:00";
|
|
||||||
return new
|
|
||||||
{
|
|
||||||
success = true,
|
|
||||||
medication_id = med.Id,
|
|
||||||
name = med.Name,
|
|
||||||
dosage = med.Dosage,
|
|
||||||
frequency = med.Frequency.ToString(),
|
|
||||||
time = timeLabels,
|
|
||||||
start_date = med.StartDate?.ToString("yyyy-MM-dd"),
|
|
||||||
duration_days = durationDays,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<object> QueryMedications(AppDbContext db, Guid userId)
|
|
||||||
{
|
|
||||||
var meds = await db.Medications.Where(m => m.UserId == userId && m.IsActive)
|
|
||||||
.Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay }).ToListAsync();
|
|
||||||
return new { count = meds.Count, medications = meds };
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<object> ConfirmMedication(AppDbContext db, Guid userId, JsonElement args)
|
|
||||||
{
|
|
||||||
var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty;
|
|
||||||
var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == medId && m.UserId == userId);
|
|
||||||
if (med == null) return new { success = false, message = "药品不存在" };
|
|
||||||
|
|
||||||
db.MedicationLogs.Add(new MedicationLog
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
MedicationId = medId,
|
|
||||||
UserId = userId,
|
|
||||||
Status = MedicationLogStatus.Taken,
|
|
||||||
ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
|
|
||||||
ConfirmedAt = DateTime.UtcNow,
|
|
||||||
});
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
return new { success = true };
|
|
||||||
}
|
|
||||||
|
|
||||||
private static MedicationFrequency ReadFrequency(JsonElement args)
|
private static MedicationFrequency ReadFrequency(JsonElement args)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ public sealed class AiToolExecutionService(
|
|||||||
"record_health_data" => HealthDataAgentHandler.Execute(toolName, root, userId, _healthRecords),
|
"record_health_data" => HealthDataAgentHandler.Execute(toolName, root, userId, _healthRecords),
|
||||||
"query_health_records" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
|
"query_health_records" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
|
||||||
"check_archive" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
|
"check_archive" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
|
||||||
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, _db, userId, _medications, ct),
|
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, userId, _medications, ct),
|
||||||
"analyze_report" => ReportAgentHandler.AnalyzeReport(_db, userId, root, _visionClient),
|
"analyze_report" => ReportAgentHandler.AnalyzeReport(_db, userId, root, _visionClient),
|
||||||
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, _db, userId, _exercises, ct),
|
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, _db, userId, _exercises, ct),
|
||||||
"manage_archive" => CommonAgentHandler.ExecuteManageArchive(_healthArchives, userId, root, ct),
|
"manage_archive" => CommonAgentHandler.ExecuteManageArchive(_healthArchives, userId, root, ct),
|
||||||
|
|||||||
179
backend/src/Health.Infrastructure/AI/AttachmentContextBuilder.cs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Health.Application.AI;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using UglyToad.PdfPig;
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.AI;
|
||||||
|
|
||||||
|
public sealed class AttachmentContextBuilder(
|
||||||
|
VisionClient vision,
|
||||||
|
ILogger<AttachmentContextBuilder> logger) : IAttachmentContextBuilder
|
||||||
|
{
|
||||||
|
private const int MaxPdfChars = 6000;
|
||||||
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly VisionClient _vision = vision;
|
||||||
|
private readonly ILogger<AttachmentContextBuilder> _logger = logger;
|
||||||
|
|
||||||
|
public async Task<AttachmentContext?> BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(imageUrl))
|
||||||
|
return await BuildImageAsync(imageUrl!, ct);
|
||||||
|
if (!string.IsNullOrWhiteSpace(pdfUrl))
|
||||||
|
return await BuildPdfAsync(pdfUrl!, ct);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 图片:调 VLM 输出结构化 JSON ──
|
||||||
|
private async Task<AttachmentContext?> BuildImageAsync(string imageUrl, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var filePath = ResolveLocalPath(imageUrl);
|
||||||
|
if (filePath == null || !File.Exists(filePath))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Image file not found for {Url}", imageUrl);
|
||||||
|
return new AttachmentContext("image", null, null, "图片暂时无法读取", "[图片附件读取失败,请描述图片内容]");
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytes = await File.ReadAllBytesAsync(filePath, ct);
|
||||||
|
var mime = Path.GetExtension(filePath).ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
".png" => "image/png",
|
||||||
|
".webp" => "image/webp",
|
||||||
|
".heic" => "image/heic",
|
||||||
|
_ => "image/jpeg",
|
||||||
|
};
|
||||||
|
var dataUrl = $"data:{mime};base64,{Convert.ToBase64String(bytes)}";
|
||||||
|
|
||||||
|
var prompt = """
|
||||||
|
识别图片内容,输出 JSON:
|
||||||
|
{"category":"food|report|wound|drug|chart|other","summary":"1-2句话描述图片","details":["关键信息1","关键信息2"]}
|
||||||
|
|
||||||
|
分类标准:
|
||||||
|
- food:任何食物、饮品、餐食
|
||||||
|
- report:医学检查报告、化验单、影像报告(X光/CT/MRI/B超截图等)
|
||||||
|
- wound:伤口、术后切口、皮肤异常
|
||||||
|
- drug:药品包装、药品说明书
|
||||||
|
- chart:心电图、监护仪截图等
|
||||||
|
- other:其他
|
||||||
|
|
||||||
|
details 给出最有价值的细节(食物种类、报告关键值、伤口部位等)。
|
||||||
|
只输出 JSON 本身,不要任何前后缀。
|
||||||
|
""";
|
||||||
|
|
||||||
|
string? raw = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var resp = await _vision.VisionAsync(prompt, [dataUrl], userText: null, maxTokens: 1024, ct: ct);
|
||||||
|
raw = resp.Choices?.FirstOrDefault()?.Message?.Content;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "VLM call failed for image {Url}", imageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
return new AttachmentContext("image", null, null, "图片识别失败", "[图片附件识别失败,请简要描述图片内容]");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cleaned = StripCodeFence(raw.Trim());
|
||||||
|
using var doc = JsonDocument.Parse(cleaned);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
var category = GetString(root, "category") ?? "other";
|
||||||
|
var summary = GetString(root, "summary") ?? "图片内容";
|
||||||
|
var details = root.TryGetProperty("details", out var d) && d.ValueKind == JsonValueKind.Array
|
||||||
|
? string.Join("、", d.EnumerateArray().Select(x => x.GetString()).Where(s => !string.IsNullOrWhiteSpace(s)))
|
||||||
|
: "";
|
||||||
|
|
||||||
|
var compact = string.IsNullOrEmpty(details)
|
||||||
|
? $"图片识别:{summary}"
|
||||||
|
: $"图片识别:{summary}({details})";
|
||||||
|
var llmContent = $"[图片识别(类别 {category}):{summary}{(string.IsNullOrEmpty(details) ? "" : $",包含 {details}")}]";
|
||||||
|
|
||||||
|
return new AttachmentContext("image", category, null, compact, llmContent);
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to parse VLM JSON: {Raw}", raw);
|
||||||
|
// 兜底:原文也能用
|
||||||
|
return new AttachmentContext("image", null, null, $"图片识别:{raw[..Math.Min(raw.Length, 80)]}", $"[图片识别:{raw}]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PDF:PdfPig 抽取文本 ──
|
||||||
|
private Task<AttachmentContext?> BuildPdfAsync(string pdfUrl, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var filePath = ResolveLocalPath(pdfUrl);
|
||||||
|
var fileName = Path.GetFileName(pdfUrl);
|
||||||
|
if (filePath == null || !File.Exists(filePath))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("PDF file not found for {Url}", pdfUrl);
|
||||||
|
return Task.FromResult<AttachmentContext?>(new AttachmentContext(
|
||||||
|
"pdf", null, fileName, "PDF 文件读取失败",
|
||||||
|
"[PDF 附件读取失败,请描述文档内容]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var pdf = PdfDocument.Open(filePath);
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
foreach (var page in pdf.GetPages())
|
||||||
|
{
|
||||||
|
sb.AppendLine(page.Text);
|
||||||
|
if (sb.Length > MaxPdfChars) break;
|
||||||
|
}
|
||||||
|
var text = sb.ToString().Trim();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
return Task.FromResult<AttachmentContext?>(new AttachmentContext(
|
||||||
|
"pdf", null, fileName, "PDF 内容为空或扫描件",
|
||||||
|
"[PDF 看起来是扫描件或图片版,无法提取文字。请提示用户改用拍照上传,或简要描述报告内容]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var truncated = text.Length > MaxPdfChars ? text[..MaxPdfChars] + "...(已截断)" : text;
|
||||||
|
var summary = $"PDF:{fileName}";
|
||||||
|
var llmContent = $"[用户上传 PDF 「{fileName}」,文档内容如下]\n{truncated}";
|
||||||
|
|
||||||
|
return Task.FromResult<AttachmentContext?>(new AttachmentContext("pdf", null, fileName, summary, llmContent));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to parse PDF {Url}", pdfUrl);
|
||||||
|
return Task.FromResult<AttachmentContext?>(new AttachmentContext(
|
||||||
|
"pdf", null, fileName, "PDF 解析异常",
|
||||||
|
"[PDF 解析失败,请提示用户文件可能损坏或加密]"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveLocalPath(string url)
|
||||||
|
{
|
||||||
|
// url 形如 "/uploads/{guid}.{ext}"。处理 base URL 前缀也兼容。
|
||||||
|
var idx = url.IndexOf("/uploads/", StringComparison.Ordinal);
|
||||||
|
if (idx < 0) return null;
|
||||||
|
var relative = url[(idx + "/uploads/".Length)..];
|
||||||
|
// 去掉可能的 query string
|
||||||
|
var q = relative.IndexOf('?');
|
||||||
|
if (q >= 0) relative = relative[..q];
|
||||||
|
return Path.Combine(Directory.GetCurrentDirectory(), "uploads", relative);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StripCodeFence(string raw)
|
||||||
|
{
|
||||||
|
var t = raw.Trim();
|
||||||
|
if (t.StartsWith("```"))
|
||||||
|
{
|
||||||
|
var firstNewline = t.IndexOf('\n');
|
||||||
|
if (firstNewline > 0) t = t[(firstNewline + 1)..];
|
||||||
|
if (t.EndsWith("```")) t = t[..^3];
|
||||||
|
}
|
||||||
|
return t.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetString(JsonElement el, string name) =>
|
||||||
|
el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||||
|
}
|
||||||
@@ -119,6 +119,7 @@ public sealed class VisionClient(HttpClient http, IConfiguration config)
|
|||||||
{
|
{
|
||||||
Model = _model, Messages = messages, MaxTokens = maxTokens, Stream = false,
|
Model = _model, Messages = messages, MaxTokens = maxTokens, Stream = false,
|
||||||
Temperature = 0.1f, VlHighResolutionImages = true,
|
Temperature = 0.1f, VlHighResolutionImages = true,
|
||||||
|
EnableThinking = false,
|
||||||
};
|
};
|
||||||
|
|
||||||
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ public sealed class ChatCompletionRequest
|
|||||||
|
|
||||||
[System.Text.Json.Serialization.JsonPropertyName("vl_high_resolution_images")]
|
[System.Text.Json.Serialization.JsonPropertyName("vl_high_resolution_images")]
|
||||||
public bool VlHighResolutionImages { get; set; }
|
public bool VlHighResolutionImages { get; set; }
|
||||||
|
|
||||||
|
[System.Text.Json.Serialization.JsonPropertyName("enable_thinking")]
|
||||||
|
public bool? EnableThinking { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ChatMessage
|
public sealed class ChatMessage
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.AI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// FastGPT 知识库检索客户端(仅做 RAG 检索,不参与生成)。
|
||||||
|
/// 我们后端的 DeepSeek 依旧负责对话生成与工具调用;
|
||||||
|
/// 这里只把检索到的医学知识片段拼回 system prompt,提升回答准确度。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FastGptKnowledgeClient(HttpClient http, IConfiguration config, ILogger<FastGptKnowledgeClient> logger)
|
||||||
|
{
|
||||||
|
private readonly HttpClient _http = http;
|
||||||
|
private readonly string _datasetId = config["FASTGPT_DATASET_ID"] ?? "";
|
||||||
|
private readonly float _similarity = float.TryParse(config["FASTGPT_SIMILARITY"], out var s) ? s : 0.4f;
|
||||||
|
private readonly int _limit = int.TryParse(config["FASTGPT_LIMIT"], out var l) ? l : 3000;
|
||||||
|
private readonly ILogger<FastGptKnowledgeClient> _logger = logger;
|
||||||
|
private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true };
|
||||||
|
|
||||||
|
public bool IsEnabled => !string.IsNullOrWhiteSpace(_datasetId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 按用户问题检索知识库,返回最相关的文档片段。
|
||||||
|
/// 失败或未配置时返回空字符串,调用方应当容错——RAG 只是增强,不是必需。
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string> SearchAsync(string question, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!IsEnabled || string.IsNullOrWhiteSpace(question)) return "";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
datasetId = _datasetId,
|
||||||
|
text = question,
|
||||||
|
limit = _limit,
|
||||||
|
similarity = _similarity,
|
||||||
|
searchMode = "embedding",
|
||||||
|
};
|
||||||
|
var json = JsonSerializer.Serialize(payload);
|
||||||
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
using var response = await _http.PostAsync("core/dataset/searchTest", content, ct);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("FastGPT 检索失败: {Status}", response.StatusCode);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
if (!doc.RootElement.TryGetProperty("data", out var data)) return "";
|
||||||
|
if (!data.TryGetProperty("list", out var list) || list.ValueKind != JsonValueKind.Array) return "";
|
||||||
|
|
||||||
|
var snippets = new List<string>();
|
||||||
|
foreach (var item in list.EnumerateArray())
|
||||||
|
{
|
||||||
|
var q = item.TryGetProperty("q", out var qEl) ? qEl.GetString() ?? "" : "";
|
||||||
|
var a = item.TryGetProperty("a", out var aEl) ? aEl.GetString() ?? "" : "";
|
||||||
|
if (string.IsNullOrWhiteSpace(q) && string.IsNullOrWhiteSpace(a)) continue;
|
||||||
|
snippets.Add(string.IsNullOrWhiteSpace(a) ? q : $"{q}:{a}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return snippets.Count == 0 ? "" : string.Join("\n", snippets);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "FastGPT 检索异常,已忽略");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,9 +23,21 @@ public sealed class PromptManager
|
|||||||
_ => DefaultPrompt
|
_ => DefaultPrompt
|
||||||
};
|
};
|
||||||
|
|
||||||
return $"{prompt}\n\n{MedicalBoundaryRules}";
|
return $"{prompt}\n\n{MedicalBoundaryRules}\n\n{SmartLinkRules}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const string SmartLinkRules = """
|
||||||
|
智能跳转链接(按需嵌入):
|
||||||
|
- 当用户明确询问食物的 热量/卡路里 时,在回答的自然位置嵌入 Markdown 链接 [热量分析](app://diet),引导用户使用专门的饮食拍照分析功能。
|
||||||
|
- 当用户上传医学检查报告、化验单、影像报告、出院小结等需要专业解读时,在回答末尾嵌入 [报告分析](app://report),引导上传到报告分析功能获得详细解读。
|
||||||
|
- 当用户询问血压、血糖、血氧等需要长期追踪的设备测量场景时,可嵌入 [蓝牙设备](app://device)。
|
||||||
|
|
||||||
|
硬性约束:
|
||||||
|
- 每条回复最多嵌入一个跳转链接,且必须在合适语境下自然提及,不要堆砌。
|
||||||
|
- 仅"问热量/卡路里"才用 app://diet;"营养、能不能吃、是否健康"类问题正常回答,禁止插入饮食跳转链接。
|
||||||
|
- 链接的 Markdown 文本只能用上面列出的 4 个固定文案,不要自创其他文案。
|
||||||
|
""";
|
||||||
|
|
||||||
private const string MedicalBoundaryRules = """
|
private const string MedicalBoundaryRules = """
|
||||||
医疗边界(必须遵守):
|
医疗边界(必须遵守):
|
||||||
- 你的定位是患者端 AI 健康解释与预问诊助手,不是医生,不能替代医生诊断、处方或治疗决策。
|
- 你的定位是患者端 AI 健康解释与预问诊助手,不是医生,不能替代医生诊断、处方或治疗决策。
|
||||||
@@ -34,6 +46,16 @@ public sealed class PromptManager
|
|||||||
- 对胸痛、胸闷憋气、呼吸困难、意识异常、晕厥、说话不清、一侧肢体无力、血氧明显偏低、血压或血糖严重异常等情况,优先建议及时就医或急诊评估。
|
- 对胸痛、胸闷憋气、呼吸困难、意识异常、晕厥、说话不清、一侧肢体无力、血氧明显偏低、血压或血糖严重异常等情况,优先建议及时就医或急诊评估。
|
||||||
- 回答用语使用“可能、建议、需要结合医生判断”等表达,避免绝对化结论。
|
- 回答用语使用“可能、建议、需要结合医生判断”等表达,避免绝对化结论。
|
||||||
- 医疗相关分析末尾用一句自然的话提醒:以上为 AI 预分析,不能替代医生诊断和治疗建议。
|
- 医疗相关分析末尾用一句自然的话提醒:以上为 AI 预分析,不能替代医生诊断和治疗建议。
|
||||||
|
|
||||||
|
回复格式规则(严格遵守):
|
||||||
|
- 禁止使用 **粗体**、*斜体*、~~删除线~~ 等 Markdown 内联标记
|
||||||
|
- 禁止使用 HTML 标签
|
||||||
|
- 禁止使用 Markdown 表格(|--|--|)
|
||||||
|
- 标题只使用 ### 三级标题,禁止使用 #、##、####
|
||||||
|
- 列表使用 - 开头,每项一行
|
||||||
|
- 数值和单位之间加空格:120 mmHg、6.2 mmol/L
|
||||||
|
- 不要输出任何 $ 开头的占位符
|
||||||
|
- 回复结尾不再重复免责声明,MedicalBoundaryRules 已统一处理
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private const string DefaultPrompt = """
|
private const string DefaultPrompt = """
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ using Health.Infrastructure.Services;
|
|||||||
|
|
||||||
namespace Health.Infrastructure.Auth;
|
namespace Health.Infrastructure.Auth;
|
||||||
|
|
||||||
public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms) : IAuthService
|
public sealed class AuthService(
|
||||||
|
AppDbContext db,
|
||||||
|
JwtProvider jwt,
|
||||||
|
SmsService sms,
|
||||||
|
AppleTokenValidator appleValidator // Apple Sign-In JWT 验证
|
||||||
|
) : IAuthService
|
||||||
{
|
{
|
||||||
private const string AdminPhone = "12345678910";
|
private const string AdminPhone = "12345678910";
|
||||||
private const string AdminSmsCode = "000000";
|
private const string AdminSmsCode = "000000";
|
||||||
@@ -11,6 +16,7 @@ public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms
|
|||||||
private readonly AppDbContext _db = db;
|
private readonly AppDbContext _db = db;
|
||||||
private readonly JwtProvider _jwt = jwt;
|
private readonly JwtProvider _jwt = jwt;
|
||||||
private readonly SmsService _sms = sms;
|
private readonly SmsService _sms = sms;
|
||||||
|
private readonly AppleTokenValidator _appleValidator = appleValidator;
|
||||||
|
|
||||||
public async Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct)
|
public async Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -99,13 +105,64 @@ public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms
|
|||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// 1. 验证 Apple identityToken
|
||||||
|
string appleUserId;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
appleUserId = await _appleValidator.ValidateAsync(command.IdentityToken, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Error(40005, $"Apple 登录验证失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 查找已有用户
|
||||||
|
var existingUser = await _db.Users.FirstOrDefaultAsync(x => x.AppleUserId == appleUserId, ct);
|
||||||
|
if (existingUser != null)
|
||||||
|
{
|
||||||
|
existingUser.UpdatedAt = DateTime.UtcNow;
|
||||||
|
if (!string.IsNullOrWhiteSpace(command.Name)) existingUser.Name ??= command.Name;
|
||||||
|
var tokens = AddTokens(existingUser.Id, existingUser.Phone, existingUser.Role);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return new AuthResult(0, new
|
||||||
|
{
|
||||||
|
tokens.accessToken, tokens.refreshToken,
|
||||||
|
user = new { existingUser.Id, existingUser.Phone, existingUser.Role, existingUser.Name, existingUser.Gender, existingUser.AvatarUrl, isNew = false }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 新用户 → 创建账户(无需手机号、无需选医生)
|
||||||
|
var newUser = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Phone = null,
|
||||||
|
AppleUserId = appleUserId,
|
||||||
|
Role = "User",
|
||||||
|
Name = command.Name,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
_db.Users.Add(newUser);
|
||||||
|
_db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = newUser.Id });
|
||||||
|
_db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = newUser.Id });
|
||||||
|
var newTokens = AddTokens(newUser.Id, null, newUser.Role);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return new AuthResult(0, new
|
||||||
|
{
|
||||||
|
newTokens.accessToken, newTokens.refreshToken,
|
||||||
|
user = new { newUser.Id, Phone = (string?)null, newUser.Role, newUser.Name, isNew = true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private Task<VerificationCode?> TakeCodeAsync(string phone, string code, CancellationToken ct) =>
|
private Task<VerificationCode?> TakeCodeAsync(string phone, string code, CancellationToken ct) =>
|
||||||
_db.VerificationCodes.Where(x => x.Phone == phone && x.Code == code && x.ExpiresAt > DateTime.UtcNow && !x.IsUsed)
|
_db.VerificationCodes.Where(x => x.Phone == phone && x.Code == code && x.ExpiresAt > DateTime.UtcNow && !x.IsUsed)
|
||||||
.OrderByDescending(x => x.CreatedAt).FirstOrDefaultAsync(ct);
|
.OrderByDescending(x => x.CreatedAt).FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
private (string accessToken, string refreshToken) AddTokens(Guid userId, string phone, string role)
|
private (string accessToken, string refreshToken) AddTokens(Guid userId, string? phone, string role)
|
||||||
{
|
{
|
||||||
var access = _jwt.GenerateAccessToken(userId, phone, role);
|
var access = _jwt.GenerateAccessToken(userId, phone ?? "", role);
|
||||||
var refresh = _jwt.GenerateRefreshToken();
|
var refresh = _jwt.GenerateRefreshToken();
|
||||||
_db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), UserId = userId, Token = refresh, ExpiresAt = DateTime.UtcNow.AddDays(30) });
|
_db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), UserId = userId, Token = refresh, ExpiresAt = DateTime.UtcNow.AddDays(30) });
|
||||||
return (access, refresh);
|
return (access, refresh);
|
||||||
|
|||||||
1461
backend/src/Health.Infrastructure/Data/Migrations/20260624063234_AddHealthRecordReminder.Designer.cs
generated
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddHealthRecordReminder : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "HealthRecordReminder",
|
||||||
|
table: "NotificationPreferences",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "HealthRecordReminderBloodPressure",
|
||||||
|
table: "NotificationPreferences",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "HealthRecordReminderGlucose",
|
||||||
|
table: "NotificationPreferences",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "HealthRecordReminderHeartRate",
|
||||||
|
table: "NotificationPreferences",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "HealthRecordReminderSpO2",
|
||||||
|
table: "NotificationPreferences",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "HealthRecordReminderWeight",
|
||||||
|
table: "NotificationPreferences",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "HealthRecordReminder",
|
||||||
|
table: "NotificationPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "HealthRecordReminderBloodPressure",
|
||||||
|
table: "NotificationPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "HealthRecordReminderGlucose",
|
||||||
|
table: "NotificationPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "HealthRecordReminderHeartRate",
|
||||||
|
table: "NotificationPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "HealthRecordReminderSpO2",
|
||||||
|
table: "NotificationPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "HealthRecordReminderWeight",
|
||||||
|
table: "NotificationPreferences");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1466
backend/src/Health.Infrastructure/Data/Migrations/20260710052811_AddAppleSignIn.Designer.cs
generated
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAppleSignIn : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Phone",
|
||||||
|
table: "Users",
|
||||||
|
type: "text",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "AppleUserId",
|
||||||
|
table: "Users",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Users_AppleUserId",
|
||||||
|
table: "Users",
|
||||||
|
column: "AppleUserId",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Users_AppleUserId",
|
||||||
|
table: "Users");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AppleUserId",
|
||||||
|
table: "Users");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Phone",
|
||||||
|
table: "Users",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "",
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldNullable: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -679,6 +679,24 @@ namespace Health.Infrastructure.Data.Migrations
|
|||||||
b.Property<bool>("FollowUpReminder")
|
b.Property<bool>("FollowUpReminder")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("HealthRecordReminder")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("HealthRecordReminderBloodPressure")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("HealthRecordReminderGlucose")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("HealthRecordReminderHeartRate")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("HealthRecordReminderSpO2")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("HealthRecordReminderWeight")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<bool>("MedicationReminder")
|
b.Property<bool>("MedicationReminder")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
@@ -785,6 +803,9 @@ namespace Health.Infrastructure.Data.Migrations
|
|||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AppleUserId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("AvatarUrl")
|
b.Property<string>("AvatarUrl")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
@@ -804,7 +825,6 @@ namespace Health.Infrastructure.Data.Migrations
|
|||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("Phone")
|
b.Property<string>("Phone")
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("Role")
|
b.Property<string>("Role")
|
||||||
@@ -819,6 +839,9 @@ namespace Health.Infrastructure.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AppleUserId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
b.HasIndex("DoctorId");
|
b.HasIndex("DoctorId");
|
||||||
|
|
||||||
b.HasIndex("Phone")
|
b.HasIndex("Phone")
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
|||||||
builder.Entity<User>(e =>
|
builder.Entity<User>(e =>
|
||||||
{
|
{
|
||||||
e.HasIndex(u => u.Phone).IsUnique();
|
e.HasIndex(u => u.Phone).IsUnique();
|
||||||
|
e.HasIndex(u => u.AppleUserId).IsUnique(); // Apple Sign-In 唯一标识
|
||||||
e.Property(u => u.Role).HasMaxLength(32).HasDefaultValue("User");
|
e.Property(u => u.Role).HasMaxLength(32).HasDefaultValue("User");
|
||||||
e.HasOne(u => u.Doctor).WithMany().HasForeignKey(u => u.DoctorId).IsRequired(false).OnDelete(DeleteBehavior.SetNull);
|
e.HasOne(u => u.Doctor).WithMany().HasForeignKey(u => u.DoctorId).IsRequired(false).OnDelete(DeleteBehavior.SetNull);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,9 +31,38 @@ public sealed class DietImageAnalyzer(VisionClient vision) : IDietImageAnalyzer
|
|||||||
}
|
}
|
||||||
|
|
||||||
var prompt = """
|
var prompt = """
|
||||||
识别图片中的食物和饮品,返回JSON数组:
|
你是营养师助手。请识别图片中所有食物和饮品,估算每项的具体克数或毫升数,输出 JSON 数组。
|
||||||
[{"name":"名称","portion":"份量","calories":热量}]
|
|
||||||
只返回JSON,不要其他内容。
|
## 无食物判定(必读)
|
||||||
|
如果图片中完全没有可食用的食物或饮品(例如是风景、人物、宠物、文档、屏幕截图、纯背景、空盘子等),**直接输出空数组 []**,不要硬编造食物。
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
[{"name":"食物名","portion":"具体克数","calories":数字}]
|
||||||
|
|
||||||
|
## 份量估算依据(容器与参照物对照表)
|
||||||
|
- 标准饭碗(口径11cm):满碗米饭≈200g、满碗面条≈250g、满碗粥≈250g、半碗≈100g
|
||||||
|
- 标准汤碗(口径14cm):满碗汤≈400ml、半碗≈200ml
|
||||||
|
- 标准盘子(口径23cm):满盘菜≈300g、半盘≈150g
|
||||||
|
- 普通玻璃杯:满杯水/饮料≈250ml、纸杯≈200ml
|
||||||
|
- 鸡蛋1个≈50g、馒头1个≈100g、包子1个≈80g、饺子1个≈15g
|
||||||
|
- 香蕉1根≈100g、苹果1个≈200g、橘子1个≈120g
|
||||||
|
- 肉块巴掌大小≈80-120g、鸡腿1个≈100g、鱼1条(中等)≈300g
|
||||||
|
|
||||||
|
## 输出示例(务必模仿此风格)
|
||||||
|
正确:{"name":"米饭","portion":"约200g","calories":230}
|
||||||
|
正确:{"name":"红烧肉","portion":"约150g","calories":420}
|
||||||
|
正确:{"name":"豆浆","portion":"约250ml","calories":80}
|
||||||
|
错误:{"name":"米饭","portion":"一碗","calories":230} ← portion 缺克数
|
||||||
|
错误:{"name":"汤","portion":"一份","calories":80} ← portion 缺毫升数
|
||||||
|
错误:{"name":"水果","portion":"少许","calories":50} ← portion 是模糊词
|
||||||
|
|
||||||
|
## 硬性要求
|
||||||
|
1. 图片若无食物,输出 [] 即可。不要硬凑食物。
|
||||||
|
2. portion 字段必须包含阿拉伯数字 + 单位(g 或 ml)。不允许任何只有量词没有克数的回答。
|
||||||
|
3. 即使图片不够清楚,也要根据可见容器/参照物给出估算,可加"约"或区间("约80-120g")。
|
||||||
|
4. calories 是数字(千卡),不带单位、不带引号。
|
||||||
|
|
||||||
|
只输出 JSON 数组本身,不要任何前缀、后缀、代码块标记或说明文字。
|
||||||
""";
|
""";
|
||||||
var response = await _vision.VisionAsync(prompt, imageUrls, userText: null, maxTokens: 8192, ct: ct);
|
var response = await _vision.VisionAsync(prompt, imageUrls, userText: null, maxTokens: 8192, ct: ct);
|
||||||
var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "[]";
|
var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "[]";
|
||||||
|
|||||||
@@ -7,11 +7,13 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.18.0" />
|
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.18.0" />
|
||||||
<PackageReference Include="Minio" Version="7.0.0" />
|
<PackageReference Include="Minio" Version="7.0.0" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||||
|
<PackageReference Include="PdfPig" Version="0.1.16-alpha-20260629-34229" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Health.Application.Reports;
|
|||||||
using Health.Application.Notifications;
|
using Health.Application.Notifications;
|
||||||
using Health.Infrastructure.AI;
|
using Health.Infrastructure.AI;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using UglyToad.PdfPig;
|
||||||
|
|
||||||
namespace Health.Infrastructure.Reports;
|
namespace Health.Infrastructure.Reports;
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ public sealed class ReportAnalysisService(
|
|||||||
private readonly DeepSeekClient _llm = llm;
|
private readonly DeepSeekClient _llm = llm;
|
||||||
private readonly IUserNotificationProducer _notifications = notifications;
|
private readonly IUserNotificationProducer _notifications = notifications;
|
||||||
private readonly ILogger<ReportAnalysisService> _logger = logger;
|
private readonly ILogger<ReportAnalysisService> _logger = logger;
|
||||||
|
private const int MaxPdfChars = 8000;
|
||||||
|
|
||||||
public async Task AnalyzeAsync(ReportAnalysisJob job, CancellationToken ct)
|
public async Task AnalyzeAsync(ReportAnalysisJob job, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -72,6 +74,9 @@ public sealed class ReportAnalysisService(
|
|||||||
|
|
||||||
private async Task<JsonElement> ExtractIndicatorsAsync(string filePath, CancellationToken ct)
|
private async Task<JsonElement> ExtractIndicatorsAsync(string filePath, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
if (Path.GetExtension(filePath).Equals(".pdf", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return await ExtractIndicatorsFromPdfAsync(filePath, ct);
|
||||||
|
|
||||||
var imageUrl = await GetLocalImageUrl(filePath, ct);
|
var imageUrl = await GetLocalImageUrl(filePath, ct);
|
||||||
if (imageUrl == null)
|
if (imageUrl == null)
|
||||||
throw new InvalidOperationException("报告图片文件不存在或无法读取");
|
throw new InvalidOperationException("报告图片文件不存在或无法读取");
|
||||||
@@ -112,6 +117,66 @@ public sealed class ReportAnalysisService(
|
|||||||
return json ?? throw new InvalidOperationException("报告识别结果格式异常");
|
return json ?? throw new InvalidOperationException("报告识别结果格式异常");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<JsonElement> ExtractIndicatorsFromPdfAsync(string filePath, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var text = ExtractPdfText(filePath);
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
return JsonDocument.Parse("""
|
||||||
|
{"isReport": false, "reason": "这份 PDF 可能是扫描件或图片版,当前无法直接提取文字。请上传清晰图片,或后续启用 PDF 页转图片后再用 VLM 识别。"}
|
||||||
|
""").RootElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
var prompt = $$"""
|
||||||
|
你是一名医学检验报告分析专家。下面是用户上传 PDF 中提取出的文字。
|
||||||
|
|
||||||
|
PDF 文本:
|
||||||
|
{{text}}
|
||||||
|
|
||||||
|
第一步:判断这到底是不是一份医学检验报告。
|
||||||
|
如果不是,直接返回:
|
||||||
|
{"isReport": false, "reason": "这不是医学报告,原因:..."}
|
||||||
|
|
||||||
|
如果是医学报告,提取所有检测指标,返回严格 JSON 格式(不要 markdown 包裹):
|
||||||
|
{
|
||||||
|
"isReport": true,
|
||||||
|
"reportType": "BloodTest/Biochemistry/Ecg/Ultrasound/Discharge/Other",
|
||||||
|
"indicators": [
|
||||||
|
{"name": "指标名称", "value": "数值", "unit": "单位", "referenceRange": "参考范围", "status": "normal/high/low"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
注意:
|
||||||
|
- 只提取报告里真实出现的指标
|
||||||
|
- status 判断:数值在参考范围内=normal,超出上限=high,低于下限=low
|
||||||
|
- 不确定的参考范围不要编造,可留空
|
||||||
|
""";
|
||||||
|
|
||||||
|
var messages = new List<ChatMessage>
|
||||||
|
{
|
||||||
|
new() { Role = "system", Content = "你是医学报告结构化抽取助手,只输出严格 JSON。" },
|
||||||
|
new() { Role = "user", Content = prompt }
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await _llm.ChatAsync(messages, maxTokens: 1800, temperature: 0.1f, ct: ct);
|
||||||
|
var content = response.Choices?.FirstOrDefault()?.Message?.Content ?? "";
|
||||||
|
var json = TryParseJson(content);
|
||||||
|
return json ?? throw new InvalidOperationException("PDF 报告解析结果格式异常");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractPdfText(string filePath)
|
||||||
|
{
|
||||||
|
using var pdf = PdfDocument.Open(filePath);
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
foreach (var page in pdf.GetPages())
|
||||||
|
{
|
||||||
|
sb.AppendLine(page.Text);
|
||||||
|
if (sb.Length > MaxPdfChars) break;
|
||||||
|
}
|
||||||
|
var text = sb.ToString().Trim();
|
||||||
|
return text.Length > MaxPdfChars ? text[..MaxPdfChars] : text;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<string> GenerateSummaryAsync(string indicatorsJson, CancellationToken ct)
|
private async Task<string> GenerateSummaryAsync(string indicatorsJson, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var prompt = $"""
|
var prompt = $"""
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Apple IdentityToken 验证:下载 Apple 公钥、验签、检查声明
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AppleTokenValidator
|
||||||
|
{
|
||||||
|
private const string AppleKeysUrl = "https://appleid.apple.com/auth/keys";
|
||||||
|
private const string AppleIssuer = "https://appleid.apple.com";
|
||||||
|
private static readonly HttpClient _http = new();
|
||||||
|
private static List<JsonWebKey>? _cachedKeys;
|
||||||
|
private static DateTime _keysExpiry = DateTime.MinValue;
|
||||||
|
private static readonly SemaphoreSlim _lock = new(1, 1);
|
||||||
|
|
||||||
|
private readonly string _clientId;
|
||||||
|
|
||||||
|
public AppleTokenValidator(IConfiguration config)
|
||||||
|
{
|
||||||
|
_clientId = config["APPLE_CLIENT_ID"] ?? "com.datalumina.YYA";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证 Apple identityToken,返回 sub(用户唯一标识)
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string> ValidateAsync(string identityToken, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var handler = new JwtSecurityTokenHandler();
|
||||||
|
// 先解码获取 kid
|
||||||
|
var rawToken = handler.ReadJwtToken(identityToken);
|
||||||
|
var kid = rawToken.Header.Kid;
|
||||||
|
|
||||||
|
var keys = await GetKeysAsync(ct);
|
||||||
|
var key = keys.FirstOrDefault(k => k.KeyId == kid)
|
||||||
|
?? throw new SecurityTokenException($"No matching key for kid: {kid}");
|
||||||
|
|
||||||
|
var validationParams = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidIssuer = AppleIssuer,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidAudiences = [_clientId, "com.datalumina.YYA.signin"],
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
IssuerSigningKey = key,
|
||||||
|
ClockSkew = TimeSpan.FromMinutes(1),
|
||||||
|
};
|
||||||
|
|
||||||
|
handler.ValidateToken(identityToken, validationParams, out _);
|
||||||
|
// 从原始 JWT 取 sub(避免 .NET 的 ClaimType 映射把 "sub" 转成 NameIdentifier)
|
||||||
|
var sub = rawToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;
|
||||||
|
if (string.IsNullOrWhiteSpace(sub))
|
||||||
|
throw new SecurityTokenException("Missing 'sub' claim in Apple identityToken");
|
||||||
|
|
||||||
|
return sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<List<JsonWebKey>> GetKeysAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
|
||||||
|
|
||||||
|
await _lock.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
|
||||||
|
|
||||||
|
var response = await _http.GetStringAsync(AppleKeysUrl, ct);
|
||||||
|
var jwks = new JsonWebKeySet(response);
|
||||||
|
_cachedKeys = jwks.Keys.ToList();
|
||||||
|
_keysExpiry = DateTime.UtcNow.AddHours(6); // 缓存 6 小时
|
||||||
|
return _cachedKeys;
|
||||||
|
}
|
||||||
|
finally { _lock.Release(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using Health.Application.Notifications;
|
||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Health.WebApi.BackgroundServices;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 健康录入提醒服务——每天 9:00 / 12:00 扫描所有用户,
|
||||||
|
/// 对未录入指标且开关开启的用户推送提醒。
|
||||||
|
/// 中国时区 UTC+8。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HealthRecordReminderService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
ILogger<HealthRecordReminderService> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
|
||||||
|
private readonly ILogger<HealthRecordReminderService> _logger = logger;
|
||||||
|
|
||||||
|
// 北京时间 9:00 和 12:00 触发
|
||||||
|
private static readonly int[] ReminderHoursCst = [9, 12];
|
||||||
|
|
||||||
|
private DateTime _lastFired = DateTime.MinValue;
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("健康录入提醒服务已启动");
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var nowCst = DateTime.UtcNow.AddHours(8);
|
||||||
|
// 当前小时是否在触发列表,且今天该小时还没触发过
|
||||||
|
if (ReminderHoursCst.Contains(nowCst.Hour)
|
||||||
|
&& (_lastFired.Date != nowCst.Date || _lastFired.Hour != nowCst.Hour))
|
||||||
|
{
|
||||||
|
await ScanAndPushAsync(nowCst, stoppingToken);
|
||||||
|
_lastFired = nowCst;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
|
||||||
|
catch (Exception ex) { _logger.LogError(ex, "健康录入提醒扫描异常"); }
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ScanAndPushAsync(DateTime nowCst, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var producer = scope.ServiceProvider.GetRequiredService<IUserNotificationProducer>();
|
||||||
|
|
||||||
|
var todayStart = nowCst.Date.AddHours(-8); // 北京 0:00 转 UTC
|
||||||
|
var todayEnd = todayStart.AddDays(1);
|
||||||
|
|
||||||
|
// 取所有启用了健康录入提醒的用户偏好
|
||||||
|
var prefs = await db.NotificationPreferences
|
||||||
|
.Where(p => p.HealthRecordReminder)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
foreach (var pref in prefs)
|
||||||
|
{
|
||||||
|
// 该用户当天已录入的指标
|
||||||
|
var recordedTypes = await db.HealthRecords
|
||||||
|
.Where(r => r.UserId == pref.UserId && r.RecordedAt >= todayStart && r.RecordedAt < todayEnd)
|
||||||
|
.Select(r => r.MetricType)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// 哪些指标启用开关但当天没录入
|
||||||
|
var missing = new List<(HealthMetricType type, bool enabled, string label)>
|
||||||
|
{
|
||||||
|
(HealthMetricType.BloodPressure, pref.HealthRecordReminderBloodPressure, "血压"),
|
||||||
|
(HealthMetricType.HeartRate, pref.HealthRecordReminderHeartRate, "心率"),
|
||||||
|
(HealthMetricType.Glucose, pref.HealthRecordReminderGlucose, "血糖"),
|
||||||
|
(HealthMetricType.SpO2, pref.HealthRecordReminderSpO2, "血氧"),
|
||||||
|
(HealthMetricType.Weight, pref.HealthRecordReminderWeight, "体重"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var missingLabels = missing
|
||||||
|
.Where(m => m.enabled && !recordedTypes.Contains(m.type))
|
||||||
|
.Select(m => m.label)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (missingLabels.Count == 0) continue;
|
||||||
|
|
||||||
|
// 每天每个时段每个用户只推一次——SourceTaskId 用 userId + 日期 + 小时
|
||||||
|
var sourceId = MakeSourceId(pref.UserId, nowCst);
|
||||||
|
var summary = string.Join("、", missingLabels);
|
||||||
|
|
||||||
|
await producer.EnqueueAsync(pref.UserId, sourceId, new NotificationMessage(
|
||||||
|
Type: "health_record_reminder",
|
||||||
|
Title: nowCst.Hour < 12 ? "早安,记得录入今日健康数据" : "下午好,今日健康数据还未录入",
|
||||||
|
Message: $"建议录入:{summary}",
|
||||||
|
Severity: "info",
|
||||||
|
ActionType: "health",
|
||||||
|
ActionTargetId: null), ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guid MakeSourceId(Guid userId, DateTime nowCst)
|
||||||
|
{
|
||||||
|
// 通过哈希构造稳定的 Guid,保证同一用户同一时段不重复
|
||||||
|
var key = $"hrr|{userId:N}|{nowCst:yyyyMMddHH}";
|
||||||
|
var bytes = System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(key));
|
||||||
|
return new Guid(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,14 +28,18 @@ public static class AiChatEndpoints
|
|||||||
app.MapGet("/api/ai/{agentType}/chat", async (
|
app.MapGet("/api/ai/{agentType}/chat", async (
|
||||||
string message,
|
string message,
|
||||||
string? conversationId,
|
string? conversationId,
|
||||||
|
string? imageUrl,
|
||||||
|
string? pdfUrl,
|
||||||
string token,
|
string token,
|
||||||
string agentType,
|
string agentType,
|
||||||
HttpContext http,
|
HttpContext http,
|
||||||
DeepSeekClient llmClient,
|
DeepSeekClient llmClient,
|
||||||
PromptManager promptManager,
|
PromptManager promptManager,
|
||||||
|
FastGptKnowledgeClient knowledgeClient,
|
||||||
IAiToolExecutionService toolExecution,
|
IAiToolExecutionService toolExecution,
|
||||||
IAiWriteConfirmationStore confirmations,
|
IAiWriteConfirmationStore confirmations,
|
||||||
IAiConversationService conversations,
|
IAiConversationService conversations,
|
||||||
|
IAttachmentContextBuilder attachments,
|
||||||
IPatientContextService patientContexts,
|
IPatientContextService patientContexts,
|
||||||
CancellationToken ct) =>
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
@@ -84,7 +88,24 @@ public static class AiChatEndpoints
|
|||||||
if (opened.Created)
|
if (opened.Created)
|
||||||
await SseWriteAsync(http, new { action = "conversation_id", data = activeConversationId.ToString() }, ct);
|
await SseWriteAsync(http, new { action = "conversation_id", data = activeConversationId.ToString() }, ct);
|
||||||
|
|
||||||
await conversations.AddUserMessageAsync(activeConversationId, message, ct);
|
// 附件解析(图片走 VLM、PDF 走 PdfPig),结果同时拼 LLM 上下文 + 持久化到 user message metadata
|
||||||
|
var attachment = await attachments.BuildAsync(imageUrl, pdfUrl, ct);
|
||||||
|
string? userMessageMetadataJson = null;
|
||||||
|
if (attachment != null)
|
||||||
|
{
|
||||||
|
userMessageMetadataJson = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
kind = attachment.Kind,
|
||||||
|
category = attachment.Category,
|
||||||
|
fileName = attachment.FileName,
|
||||||
|
summary = attachment.Summary,
|
||||||
|
imageUrl,
|
||||||
|
pdfUrl,
|
||||||
|
}, JsonOpts);
|
||||||
|
await SseWriteAsync(http, new { action = "attachment", data = new { attachment.Kind, attachment.Category, attachment.Summary } }, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
await conversations.AddUserMessageAsync(activeConversationId, message, userMessageMetadataJson, ct);
|
||||||
|
|
||||||
var urgentWarning = DetectUrgentRisk(message);
|
var urgentWarning = DetectUrgentRisk(message);
|
||||||
if (!string.IsNullOrEmpty(urgentWarning))
|
if (!string.IsNullOrEmpty(urgentWarning))
|
||||||
@@ -98,6 +119,7 @@ public static class AiChatEndpoints
|
|||||||
""";
|
""";
|
||||||
|
|
||||||
await conversations.AddAssistantMessageAsync(activeConversationId, urgentResponse, ct);
|
await conversations.AddAssistantMessageAsync(activeConversationId, urgentResponse, ct);
|
||||||
|
await TryUpdateConversationSummaryAsync(conversations, llmClient, userId.Value, activeConversationId, ct);
|
||||||
|
|
||||||
await SseWriteAsync(http, new { action = "notice", message = "检测到可能的危险信号,优先给出就医提醒" }, ct);
|
await SseWriteAsync(http, new { action = "notice", message = "检测到可能的危险信号,优先给出就医提醒" }, ct);
|
||||||
await SseWriteAsync(http, new { action = "answer", data = urgentResponse, type = "text" }, ct);
|
await SseWriteAsync(http, new { action = "answer", data = urgentResponse, type = "text" }, ct);
|
||||||
@@ -110,21 +132,66 @@ public static class AiChatEndpoints
|
|||||||
var systemPrompt = promptManager.GetSystemPrompt(parsedType);
|
var systemPrompt = promptManager.GetSystemPrompt(parsedType);
|
||||||
var patientContext = await patientContexts.BuildAsync(userId.Value, ct);
|
var patientContext = await patientContexts.BuildAsync(userId.Value, ct);
|
||||||
|
|
||||||
|
// ── RAG 知识库检索 ──
|
||||||
|
// 仅对需要医学知识的 Agent 启用检索;记数据/管用药/排运动这些纯工具调用类跳过。
|
||||||
|
// 失败/空结果不阻塞流程,仅作为 prompt 增强。
|
||||||
|
var knowledgeSnippet = "";
|
||||||
|
if (NeedsKnowledgeBase(parsedType) && knowledgeClient.IsEnabled)
|
||||||
|
{
|
||||||
|
knowledgeSnippet = await knowledgeClient.SearchAsync(message, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
var enhancedSystem = systemPrompt + "\n\n当前患者信息:\n" + patientContext;
|
||||||
|
if (!string.IsNullOrWhiteSpace(knowledgeSnippet))
|
||||||
|
{
|
||||||
|
enhancedSystem += "\n\n知识库参考资料(如与用户问题相关请优先采用):\n" + knowledgeSnippet;
|
||||||
|
}
|
||||||
|
|
||||||
var messages = new List<ChatMessage>
|
var messages = new List<ChatMessage>
|
||||||
{
|
{
|
||||||
new() { Role = "system", Content = systemPrompt + "\n\n当前患者信息:\n" + patientContext },
|
new() { Role = "system", Content = enhancedSystem },
|
||||||
};
|
};
|
||||||
|
|
||||||
// 加载历史对话(最近 10 条)
|
// 加载历史对话(最近 12 条)
|
||||||
var history = await conversations.GetRecentMessagesAsync(activeConversationId, 12, ct);
|
var history = await conversations.GetRecentMessagesAsync(activeConversationId, 12, ct);
|
||||||
|
|
||||||
foreach (var h in history)
|
foreach (var h in history)
|
||||||
{
|
{
|
||||||
messages.Add(new ChatMessage
|
var role = h.Role == MessageRole.User.ToString() ? "user" : "assistant";
|
||||||
|
var content = h.Content;
|
||||||
|
|
||||||
|
// 历史 user message 如果带过附件,把附件摘要拼回 content,让 AI 能回忆起来
|
||||||
|
if (role == "user" && !string.IsNullOrWhiteSpace(h.MetadataJson))
|
||||||
{
|
{
|
||||||
Role = h.Role == MessageRole.User.ToString() ? "user" : "assistant",
|
try
|
||||||
Content = h.Content,
|
{
|
||||||
});
|
using var meta = JsonDocument.Parse(h.MetadataJson);
|
||||||
|
if (meta.RootElement.TryGetProperty("summary", out var sumEl) &&
|
||||||
|
sumEl.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
var sum = sumEl.GetString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(sum) &&
|
||||||
|
// 跳过本轮:本轮 user message 会在下面单独拼 LlmContent,避免重复
|
||||||
|
h.Content != message)
|
||||||
|
{
|
||||||
|
content = $"[历史附件回忆:{sum}]\n{content}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException) { /* 忽略损坏的 metadata */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.Add(new ChatMessage { Role = role, Content = content });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本轮如果带了附件,把完整识别结果拼到最后一条 user message 前
|
||||||
|
if (attachment != null && messages.Count > 0 && messages[^1].Role == "user")
|
||||||
|
{
|
||||||
|
messages[^1] = new ChatMessage
|
||||||
|
{
|
||||||
|
Role = "user",
|
||||||
|
Content = $"{attachment.LlmContent}\n{messages[^1].Content}",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool Calling 循环
|
// Tool Calling 循环
|
||||||
@@ -197,7 +264,16 @@ public static class AiChatEndpoints
|
|||||||
|
|
||||||
// 保存 AI 回复
|
// 保存 AI 回复
|
||||||
if (!string.IsNullOrEmpty(fullResponse))
|
if (!string.IsNullOrEmpty(fullResponse))
|
||||||
await conversations.AddAssistantMessageAsync(activeConversationId, fullResponse, ct);
|
{
|
||||||
|
string? assistantMetadataJson = null;
|
||||||
|
if (metadata.Count > 0 || messageType != "text")
|
||||||
|
{
|
||||||
|
metadata["messageType"] = messageType;
|
||||||
|
assistantMetadataJson = JsonSerializer.Serialize(metadata, JsonOpts);
|
||||||
|
}
|
||||||
|
await conversations.AddAssistantMessageAsync(activeConversationId, fullResponse, assistantMetadataJson, ct);
|
||||||
|
await TryUpdateConversationSummaryAsync(conversations, llmClient, userId.Value, activeConversationId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
await SseWriteAsync(http, new { action = "status", data = completedNormally ? "done" : "error" }, ct);
|
await SseWriteAsync(http, new { action = "status", data = completedNormally ? "done" : "error" }, ct);
|
||||||
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
|
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
|
||||||
@@ -249,6 +325,16 @@ public static class AiChatEndpoints
|
|||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 一键清空当前用户的全部对话
|
||||||
|
app.MapDelete("/api/ai/conversations", async (HttpContext http, IAiConversationService conversations, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
|
||||||
|
|
||||||
|
var count = await conversations.DeleteAllAsync(userId.Value, ct);
|
||||||
|
return Results.Ok(new { code = 0, data = new { deleted = count }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
app.MapPost("/api/ai/analyze-food-image", async (
|
app.MapPost("/api/ai/analyze-food-image", async (
|
||||||
HttpRequest httpRequest,
|
HttpRequest httpRequest,
|
||||||
HttpContext http,
|
HttpContext http,
|
||||||
@@ -301,8 +387,132 @@ public static class AiChatEndpoints
|
|||||||
catch (Exception) { return null; }
|
catch (Exception) { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task TryUpdateConversationSummaryAsync(
|
||||||
|
IAiConversationService conversations,
|
||||||
|
DeepSeekClient llmClient,
|
||||||
|
Guid userId,
|
||||||
|
Guid conversationId,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var messages = await conversations.GetMessagesAsync(userId, conversationId, ct);
|
||||||
|
var transcript = BuildSummaryTranscript(messages);
|
||||||
|
if (string.IsNullOrWhiteSpace(transcript)) return;
|
||||||
|
|
||||||
|
var prompt = $"""
|
||||||
|
请把下面这次健康助手会话压缩成一个“对话记录标题”。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
- 只输出标题,不要解释
|
||||||
|
- 8到18个汉字
|
||||||
|
- 优先保留用户真正要解决的问题、对象、最终动作或结论
|
||||||
|
- 像列表标题,不要像完整句子
|
||||||
|
- 不要写“本次会话、用户咨询、健康建议、进行分析、相关问题”
|
||||||
|
- 如果是上传图片/PDF,标题要体现附件内容和目的
|
||||||
|
|
||||||
|
示例:
|
||||||
|
- 早餐热量识别
|
||||||
|
- 血压偏高记录
|
||||||
|
- 服药时间调整
|
||||||
|
- 报告指标解读
|
||||||
|
- 跑步计划打卡
|
||||||
|
|
||||||
|
会话内容:
|
||||||
|
{transcript}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var response = await llmClient.ChatAsync(
|
||||||
|
[
|
||||||
|
new ChatMessage { Role = "system", Content = "你是对话标题生成助手,只输出短标题。" },
|
||||||
|
new ChatMessage { Role = "user", Content = prompt },
|
||||||
|
],
|
||||||
|
maxTokens: 120,
|
||||||
|
temperature: 0.2f,
|
||||||
|
ct: ct);
|
||||||
|
|
||||||
|
var summary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(summary)) return;
|
||||||
|
|
||||||
|
summary = Regex.Replace(summary, @"\s+", "");
|
||||||
|
summary = summary.Trim(' ', '。', '.', '"', '"', '\'', '“', '”', ':', ':');
|
||||||
|
summary = TrimSummaryPrefix(summary);
|
||||||
|
await conversations.UpdateSummaryAsync(conversationId, summary, ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 摘要只是历史列表体验增强,失败不能影响主对话。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildSummaryTranscript(IReadOnlyList<AiConversationMessageDto> messages)
|
||||||
|
{
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
foreach (var message in messages.TakeLast(24))
|
||||||
|
{
|
||||||
|
var role = message.Role == MessageRole.User.ToString() ? "用户" : "AI";
|
||||||
|
var content = message.Content?.Trim() ?? "";
|
||||||
|
|
||||||
|
if (message.Role == MessageRole.User.ToString() &&
|
||||||
|
!string.IsNullOrWhiteSpace(message.MetadataJson))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var meta = JsonDocument.Parse(message.MetadataJson);
|
||||||
|
if (meta.RootElement.TryGetProperty("summary", out var sumEl) &&
|
||||||
|
sumEl.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
var attachmentSummary = sumEl.GetString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(attachmentSummary))
|
||||||
|
content = $"[附件:{attachmentSummary}] {content}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(content)) continue;
|
||||||
|
if (content.Length > 800) content = content[..800] + "...";
|
||||||
|
sb.Append(role).Append(":").AppendLine(content);
|
||||||
|
if (sb.Length > 6000) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TrimSummaryPrefix(string summary)
|
||||||
|
{
|
||||||
|
var prefixes = new[]
|
||||||
|
{
|
||||||
|
"本次会话",
|
||||||
|
"用户咨询",
|
||||||
|
"用户询问",
|
||||||
|
"健康建议",
|
||||||
|
"相关问题",
|
||||||
|
};
|
||||||
|
foreach (var prefix in prefixes)
|
||||||
|
{
|
||||||
|
if (summary.StartsWith(prefix, StringComparison.Ordinal))
|
||||||
|
return summary[prefix.Length..].Trim(' ', ':', ':', ',', ',');
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Agent / Tool 调度 ──
|
// ── Agent / Tool 调度 ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 哪些 Agent 需要走 FastGPT 知识库 RAG。
|
||||||
|
/// 纯工具调用类(Health/Medication/Exercise)跳过 RAG,避免拖慢响应。
|
||||||
|
/// </summary>
|
||||||
|
private static bool NeedsKnowledgeBase(AgentType agentType) => agentType switch
|
||||||
|
{
|
||||||
|
AgentType.Default => true,
|
||||||
|
AgentType.Consultation => true,
|
||||||
|
AgentType.Diet => true,
|
||||||
|
AgentType.Report => true,
|
||||||
|
AgentType.Unified => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
private static List<ToolDefinition> GetToolsForAgent(AgentType agentType) => agentType switch
|
private static List<ToolDefinition> GetToolsForAgent(AgentType agentType) => agentType switch
|
||||||
{
|
{
|
||||||
AgentType.Health => HealthDataAgentHandler.Tools,
|
AgentType.Health => HealthDataAgentHandler.Tools,
|
||||||
@@ -608,5 +818,3 @@ public static class AiChatEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record ChatRequest(string Message, string? ConversationId);
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ public static class AuthEndpoints
|
|||||||
ToResult(await auth.RegisterAsync(new RegisterCommand(request.Phone, request.SmsCode, request.Name, request.DoctorId), ct)));
|
ToResult(await auth.RegisterAsync(new RegisterCommand(request.Phone, request.SmsCode, request.Name, request.DoctorId), ct)));
|
||||||
app.MapPost("/api/auth/login", async (LoginRequest request, IAuthService auth, CancellationToken ct) =>
|
app.MapPost("/api/auth/login", async (LoginRequest request, IAuthService auth, CancellationToken ct) =>
|
||||||
ToResult(await auth.LoginAsync(request.Phone, request.SmsCode, ct)));
|
ToResult(await auth.LoginAsync(request.Phone, request.SmsCode, ct)));
|
||||||
|
app.MapPost("/api/auth/apple-login", async (AppleLoginRequest request, IAuthService auth, CancellationToken ct) =>
|
||||||
|
ToResult(await auth.AppleLoginAsync(new AppleLoginCommand(request.IdentityToken, request.AuthorizationCode, request.Name), ct)));
|
||||||
app.MapPost("/api/auth/refresh", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
app.MapPost("/api/auth/refresh", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
||||||
ToResult(await auth.RefreshAsync(request.RefreshToken, ct)));
|
ToResult(await auth.RefreshAsync(request.RefreshToken, ct)));
|
||||||
app.MapPost("/api/auth/logout", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
app.MapPost("/api/auth/logout", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
||||||
@@ -28,4 +30,5 @@ public static class AuthEndpoints
|
|||||||
public sealed record SendSmsRequest(string Phone);
|
public sealed record SendSmsRequest(string Phone);
|
||||||
public sealed record RegisterRequest(string Phone, string SmsCode, string Name, Guid DoctorId);
|
public sealed record RegisterRequest(string Phone, string SmsCode, string Name, Guid DoctorId);
|
||||||
public sealed record LoginRequest(string Phone, string SmsCode);
|
public sealed record LoginRequest(string Phone, string SmsCode);
|
||||||
|
public sealed record AppleLoginRequest(string IdentityToken, string? AuthorizationCode, string? Name);
|
||||||
public sealed record RefreshRequest(string RefreshToken);
|
public sealed record RefreshRequest(string RefreshToken);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
using Health.Application.Notifications;
|
using Health.Application.Notifications;
|
||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
namespace Health.WebApi.Endpoints;
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
@@ -56,8 +58,119 @@ public static class NotificationEndpoints
|
|||||||
? Results.Ok(new { code = 0, data = new { deleted = true }, message = (string?)null })
|
? Results.Ok(new { code = 0, data = new { deleted = true }, message = (string?)null })
|
||||||
: Results.NotFound(new { code = 404, data = (object?)null, message = "通知不存在" });
|
: Results.NotFound(new { code = 404, data = (object?)null, message = "通知不存在" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 通知偏好设置 ──
|
||||||
|
var prefsGroup = app.MapGroup("/api/notification-prefs").RequireAuthorization();
|
||||||
|
|
||||||
|
prefsGroup.MapGet("", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
if (userId == Guid.Empty) return Results.Unauthorized();
|
||||||
|
var pref = await db.NotificationPreferences.FirstOrDefaultAsync(p => p.UserId == userId, ct);
|
||||||
|
if (pref == null)
|
||||||
|
{
|
||||||
|
pref = new NotificationPreference { Id = Guid.NewGuid(), UserId = userId };
|
||||||
|
db.NotificationPreferences.Add(pref);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
return Results.Ok(new { code = 0, data = ToDto(pref), message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
prefsGroup.MapPut("", async (NotificationPrefsUpdateDto body, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
if (userId == Guid.Empty) return Results.Unauthorized();
|
||||||
|
var pref = await db.NotificationPreferences.FirstOrDefaultAsync(p => p.UserId == userId, ct);
|
||||||
|
if (pref == null)
|
||||||
|
{
|
||||||
|
pref = new NotificationPreference { Id = Guid.NewGuid(), UserId = userId };
|
||||||
|
db.NotificationPreferences.Add(pref);
|
||||||
|
}
|
||||||
|
if (body.MedicationReminder.HasValue) pref.MedicationReminder = body.MedicationReminder.Value;
|
||||||
|
if (body.FollowUpReminder.HasValue) pref.FollowUpReminder = body.FollowUpReminder.Value;
|
||||||
|
if (body.DoctorReply.HasValue) pref.DoctorReply = body.DoctorReply.Value;
|
||||||
|
if (body.AbnormalAlert.HasValue) pref.AbnormalAlert = body.AbnormalAlert.Value;
|
||||||
|
if (body.HealthRecordReminder.HasValue) pref.HealthRecordReminder = body.HealthRecordReminder.Value;
|
||||||
|
if (body.HealthRecordReminderBloodPressure.HasValue) pref.HealthRecordReminderBloodPressure = body.HealthRecordReminderBloodPressure.Value;
|
||||||
|
if (body.HealthRecordReminderHeartRate.HasValue) pref.HealthRecordReminderHeartRate = body.HealthRecordReminderHeartRate.Value;
|
||||||
|
if (body.HealthRecordReminderGlucose.HasValue) pref.HealthRecordReminderGlucose = body.HealthRecordReminderGlucose.Value;
|
||||||
|
if (body.HealthRecordReminderSpO2.HasValue) pref.HealthRecordReminderSpO2 = body.HealthRecordReminderSpO2.Value;
|
||||||
|
if (body.HealthRecordReminderWeight.HasValue) pref.HealthRecordReminderWeight = body.HealthRecordReminderWeight.Value;
|
||||||
|
pref.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
// 保存后立即检查该用户是否需要健康录入提醒,不用等到 9:00/12:00
|
||||||
|
if (body.HealthRecordReminder is true && pref.HealthRecordReminder)
|
||||||
|
{
|
||||||
|
var nowCst = DateTime.UtcNow.AddHours(8);
|
||||||
|
var todayStart = nowCst.Date.AddHours(-8);
|
||||||
|
var todayEnd = todayStart.AddDays(1);
|
||||||
|
|
||||||
|
var recordedTypes = await db.HealthRecords
|
||||||
|
.Where(r => r.UserId == userId && r.RecordedAt >= todayStart && r.RecordedAt < todayEnd)
|
||||||
|
.Select(r => r.MetricType)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var missing = new List<(HealthMetricType type, bool enabled, string label)>
|
||||||
|
{
|
||||||
|
(HealthMetricType.BloodPressure, pref.HealthRecordReminderBloodPressure, "血压"),
|
||||||
|
(HealthMetricType.HeartRate, pref.HealthRecordReminderHeartRate, "心率"),
|
||||||
|
(HealthMetricType.Glucose, pref.HealthRecordReminderGlucose, "血糖"),
|
||||||
|
(HealthMetricType.SpO2, pref.HealthRecordReminderSpO2, "血氧"),
|
||||||
|
(HealthMetricType.Weight, pref.HealthRecordReminderWeight, "体重"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var missingLabels = missing
|
||||||
|
.Where(m => m.enabled && !recordedTypes.Contains(m.type))
|
||||||
|
.Select(m => m.label)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (missingLabels.Count > 0)
|
||||||
|
{
|
||||||
|
var producer = http.RequestServices.GetRequiredService<IUserNotificationProducer>();
|
||||||
|
var sourceId = Guid.NewGuid();
|
||||||
|
await producer.EnqueueAsync(userId, sourceId, new NotificationMessage(
|
||||||
|
Type: "health_record_reminder",
|
||||||
|
Title: "记录今日健康指标",
|
||||||
|
Message: $"建议录入:{string.Join("、", missingLabels)}",
|
||||||
|
Severity: "info",
|
||||||
|
ActionType: "health",
|
||||||
|
ActionTargetId: null), ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(new { code = 0, data = ToDto(pref), message = (string?)null });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Guid GetUserId(HttpContext http) =>
|
private static Guid GetUserId(HttpContext http) =>
|
||||||
Guid.TryParse(http.User.FindFirst(ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
Guid.TryParse(http.User.FindFirst(ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||||
|
|
||||||
|
private static object ToDto(NotificationPreference pref) => new
|
||||||
|
{
|
||||||
|
medicationReminder = pref.MedicationReminder,
|
||||||
|
followUpReminder = pref.FollowUpReminder,
|
||||||
|
doctorReply = pref.DoctorReply,
|
||||||
|
abnormalAlert = pref.AbnormalAlert,
|
||||||
|
healthRecordReminder = pref.HealthRecordReminder,
|
||||||
|
healthRecordReminderBloodPressure = pref.HealthRecordReminderBloodPressure,
|
||||||
|
healthRecordReminderHeartRate = pref.HealthRecordReminderHeartRate,
|
||||||
|
healthRecordReminderGlucose = pref.HealthRecordReminderGlucose,
|
||||||
|
healthRecordReminderSpO2 = pref.HealthRecordReminderSpO2,
|
||||||
|
healthRecordReminderWeight = pref.HealthRecordReminderWeight,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record NotificationPrefsUpdateDto(
|
||||||
|
bool? MedicationReminder,
|
||||||
|
bool? FollowUpReminder,
|
||||||
|
bool? DoctorReply,
|
||||||
|
bool? AbnormalAlert,
|
||||||
|
bool? HealthRecordReminder,
|
||||||
|
bool? HealthRecordReminderBloodPressure,
|
||||||
|
bool? HealthRecordReminderHeartRate,
|
||||||
|
bool? HealthRecordReminderGlucose,
|
||||||
|
bool? HealthRecordReminderSpO2,
|
||||||
|
bool? HealthRecordReminderWeight
|
||||||
|
);
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ builder.Services.AddAuthorization();
|
|||||||
// ---- 业务服务 ----
|
// ---- 业务服务 ----
|
||||||
builder.Services.AddSingleton<JwtProvider>();
|
builder.Services.AddSingleton<JwtProvider>();
|
||||||
builder.Services.AddSingleton<SmsService>();
|
builder.Services.AddSingleton<SmsService>();
|
||||||
|
builder.Services.AddSingleton<AppleTokenValidator>(); // Apple Sign-In JWT 验证
|
||||||
builder.Services.AddSingleton<PromptManager>();
|
builder.Services.AddSingleton<PromptManager>();
|
||||||
builder.Services.AddScoped<IAiWriteConfirmationStore, EfAiWriteConfirmationStore>();
|
builder.Services.AddScoped<IAiWriteConfirmationStore, EfAiWriteConfirmationStore>();
|
||||||
builder.Services.AddScoped<IAiToolExecutionService, AiToolExecutionService>();
|
builder.Services.AddScoped<IAiToolExecutionService, AiToolExecutionService>();
|
||||||
@@ -123,6 +124,7 @@ builder.Services.AddScoped<IDietRepository, EfDietRepository>();
|
|||||||
builder.Services.AddScoped<IDietImageAnalysisCoordinator, DietImageAnalysisCoordinator>();
|
builder.Services.AddScoped<IDietImageAnalysisCoordinator, DietImageAnalysisCoordinator>();
|
||||||
builder.Services.AddScoped<IDietImageAnalyzer, DietImageAnalyzer>();
|
builder.Services.AddScoped<IDietImageAnalyzer, DietImageAnalyzer>();
|
||||||
builder.Services.AddScoped<IDietImageAnalysisQueue, DietImageAnalysisQueue>();
|
builder.Services.AddScoped<IDietImageAnalysisQueue, DietImageAnalysisQueue>();
|
||||||
|
builder.Services.AddScoped<IAttachmentContextBuilder, AttachmentContextBuilder>();
|
||||||
builder.Services.AddScoped<IExerciseService, ExerciseService>();
|
builder.Services.AddScoped<IExerciseService, ExerciseService>();
|
||||||
builder.Services.AddScoped<IExerciseRepository, EfExerciseRepository>();
|
builder.Services.AddScoped<IExerciseRepository, EfExerciseRepository>();
|
||||||
builder.Services.AddScoped<IExerciseReminderProducer, ExerciseReminderProducer>();
|
builder.Services.AddScoped<IExerciseReminderProducer, ExerciseReminderProducer>();
|
||||||
@@ -162,10 +164,17 @@ builder.Services.AddHttpClient<VisionClient>(client =>
|
|||||||
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["VLM_API_KEY"] ?? "");
|
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["VLM_API_KEY"] ?? "");
|
||||||
client.Timeout = TimeSpan.FromSeconds(120);
|
client.Timeout = TimeSpan.FromSeconds(120);
|
||||||
});
|
});
|
||||||
|
builder.Services.AddHttpClient<FastGptKnowledgeClient>(client =>
|
||||||
|
{
|
||||||
|
client.BaseAddress = new Uri((builder.Configuration["FASTGPT_BASE_URL"] ?? "https://cloud.fastgpt.cn/api").TrimEnd('/') + "/");
|
||||||
|
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["FASTGPT_API_KEY"] ?? "");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(15);
|
||||||
|
});
|
||||||
|
|
||||||
// ---- 后台服务 ----
|
// ---- 后台服务 ----
|
||||||
builder.Services.AddHostedService<MedicationReminderService>();
|
builder.Services.AddHostedService<MedicationReminderService>();
|
||||||
builder.Services.AddHostedService<ExerciseReminderService>();
|
builder.Services.AddHostedService<ExerciseReminderService>();
|
||||||
|
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>();
|
||||||
@@ -195,6 +204,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
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Warning",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
@@ -3,6 +3,7 @@ using Health.Application.Calendars;
|
|||||||
using Health.Application.HealthArchives;
|
using Health.Application.HealthArchives;
|
||||||
using Health.Application.HealthRecords;
|
using Health.Application.HealthRecords;
|
||||||
using Health.Application.Exercises;
|
using Health.Application.Exercises;
|
||||||
|
using Health.Domain;
|
||||||
using Health.Domain.Entities;
|
using Health.Domain.Entities;
|
||||||
using Health.Domain.Enums;
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
@@ -167,6 +168,26 @@ public sealed class ApplicationServiceTests
|
|||||||
Assert.Equal(2, current.Items.Count);
|
Assert.Equal(2, current.Items.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExercisePlan_CheckInRejectsNonTodayItem()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||||
|
var repository = new FakeExerciseRepository();
|
||||||
|
var plan = new ExercisePlan
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), UserId = Guid.NewGuid(), StartDate = today.AddDays(-1), EndDate = today, ReminderTime = new TimeOnly(19, 0),
|
||||||
|
};
|
||||||
|
var item = new ExercisePlanItem
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), Plan = plan, ScheduledDate = today.AddDays(-1), ExerciseType = "散步", DurationMinutes = 30,
|
||||||
|
};
|
||||||
|
plan.Items.Add(item);
|
||||||
|
repository.SetPlan(plan);
|
||||||
|
var service = new ExerciseService(repository);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ValidationException>(() => service.ToggleCheckInAsync(plan.UserId, item.Id, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class FakeHealthArchiveRepository(HealthArchive? archive) : IHealthArchiveRepository
|
private sealed class FakeHealthArchiveRepository(HealthArchive? archive) : IHealthArchiveRepository
|
||||||
{
|
{
|
||||||
public HealthArchive? Archive { get; private set; } = archive;
|
public HealthArchive? Archive { get; private set; } = archive;
|
||||||
|
|||||||
@@ -184,4 +184,5 @@ public sealed class PersistencePipelineTests
|
|||||||
new DbContextOptionsBuilder<AppDbContext>()
|
new DbContextOptionsBuilder<AppDbContext>()
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
.Options);
|
.Options);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,438 +0,0 @@
|
|||||||
# 健康管家 — 全面代码审查与Bug文档
|
|
||||||
|
|
||||||
> 审查日期:2026-06-10 | 范围:全栈(后端 .NET 10 + 前端 Flutter)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 测试结果汇总
|
|
||||||
|
|
||||||
| 测试集 | 通过 | 失败 | 说明 |
|
|
||||||
|--------|------|------|------|
|
|
||||||
| 后端单元测试 (entity_tests) | 10 | 0 | 实体/数据库操作全通过 |
|
|
||||||
| 后端单元测试 (auth_tests) | 4 | 0 | 认证流程全通过 |
|
|
||||||
| 后端单元测试 (ai_agent_tests - PromptManager) | 8 | 0 | AI提示词全通过 |
|
|
||||||
| 后端集成测试 (ai_agent_tests - AI对话) | 0 | 5 | 需要后端运行中 |
|
|
||||||
| Flutter 测试 (widget_test) | - | - | sqlite3 原生库下载失败,无法运行 |
|
|
||||||
|
|
||||||
**总计:22/27 通过(5个集成测试需要后端在线)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、后端 Bug
|
|
||||||
|
|
||||||
### B1. 缺少 consultation DELETE 端点
|
|
||||||
- **位置**:`consultation_endpoints.cs`
|
|
||||||
- **问题**:问诊创建后无删除/取消接口。患者无法取消发起的问诊
|
|
||||||
- **影响**:患者发起错误问诊后无法撤销
|
|
||||||
- **建议**:添加 `MapDelete("/consultations/{id:guid}", ...)`
|
|
||||||
|
|
||||||
### B2. ai_chat_endpoints 中 unified agent 缺少部分工具
|
|
||||||
- **位置**:`ai_chat_endpoints.cs:340-347`
|
|
||||||
- **问题**:unified agent 虽聚合了 7 种工具,但缺少 `manage_archive` 和 `request_doctor`
|
|
||||||
- **影响**:用户在 unified 模式无法通过对话修改档案或请求医生
|
|
||||||
|
|
||||||
### B3. CompressImage 未释放 GDI 资源
|
|
||||||
- **位置**:`ai_chat_endpoints.cs:484-503`
|
|
||||||
- **问题**:`Image.FromFile`、`Bitmap`、`Graphics` 均 `using` 包裹,但 `EncoderParameters` 未释放
|
|
||||||
- **影响**:每次食物识别可能泄漏少量非托管内存
|
|
||||||
- **修复**:`using var parameters = new EncoderParameters(1);`
|
|
||||||
|
|
||||||
### B4. 用药提醒服务可能定时查询过于频繁
|
|
||||||
- **位置**:`BackgroundServices/medication_reminder_service.cs`
|
|
||||||
- **问题**:需要检查轮询间隔,每分钟查一次可能对数据库压力大
|
|
||||||
|
|
||||||
### B5. 开发数据种子硬编码 API Key 检查
|
|
||||||
- **位置**:`dev_data_seeder.cs`
|
|
||||||
- **问题**:`DEVDATA_ENABLED=true` 创建测试用户,生产环境可能误开启
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、前端 Bug
|
|
||||||
|
|
||||||
### C1. ~~聊天列表从顶部跳到底部~~ ✅ 已修复
|
|
||||||
- **修复**:`chat_messages_view.dart` 改为 `reverse:true`
|
|
||||||
- **修复**:`home_page.dart` 滚动逻辑简化
|
|
||||||
|
|
||||||
### C2. ~~SwipeDeleteTile 内层手势冲突~~ ✅ 已修复
|
|
||||||
- **修复**:`common_widgets.dart` 滑动状态下用 `AbsorbPointer` 屏蔽内部按钮
|
|
||||||
|
|
||||||
### C3. ~~SwipeDeleteTile 红色溢出~~ ✅ 已修复
|
|
||||||
- **修复**:margin 参数从 Stack 内部移到外部 Padding
|
|
||||||
|
|
||||||
### C4. ~~运动打卡 dayOfWeek 索引偏差~~ ✅ 已修复
|
|
||||||
- **修复**:`remaining_pages.dart` 改为 `weekday % 7` 与 C# DayOfWeek 对齐
|
|
||||||
|
|
||||||
### C5. ~~用药管理 Dismissible 一步删除~~ ✅ 已修复
|
|
||||||
- **修复**:统一使用 `SwipeDeleteTile`
|
|
||||||
|
|
||||||
### C6. Flutter 测试断言错误
|
|
||||||
- **位置**:`health_app/test/widget_test.dart:9`
|
|
||||||
- **代码**:`expect(AppTheme.primary, AppTheme.primaryLight);`
|
|
||||||
- **问题**:`primary` (0xFF6366F1) ≠ `primaryLight` (0xFFEEF2FF),这个断言必然失败
|
|
||||||
- **修复**:改为 `expect(AppTheme.primary, const Color(0xFF6366F1));`
|
|
||||||
|
|
||||||
### C7. home_page.dart onTap 手误写了 `?.()`
|
|
||||||
- **位置**:`home_page.dart:115`
|
|
||||||
- **代码**:`onTap: () => pushRoute(ref, 'notificationPrefs')` 实际没有 `?.()` 问题(之前看错了),此处无误
|
|
||||||
|
|
||||||
### C8. 报告详情返回按钮:popRoute 在 setState 后
|
|
||||||
- **位置**:`report_pages.dart:449-454`
|
|
||||||
- **问题**:`clearAnalysis()` 调用 `state.copyWith(...)` 然后立即 `popRoute(ref)`,在 pop 过程中可能访问已 dispose 的 provider
|
|
||||||
- **风险**:中等
|
|
||||||
|
|
||||||
### C9. DietCapturePage TextField 内存泄漏
|
|
||||||
- **位置**:`diet_capture_page.dart:465,479,495`
|
|
||||||
- **问题**:每个食物项创建 `TextEditingController(text: food.name)` 但从不 dispose
|
|
||||||
- **修复**:用 `TextFormField` + `initialValue` 或缓存 controller
|
|
||||||
|
|
||||||
### C10. 报告上传错误吞没
|
|
||||||
- **位置**:`report_pages.dart:199`
|
|
||||||
- **代码**:`} catch (_) {}` — 上传失败无任何反馈
|
|
||||||
- **修复**:至少显示 snackbar 提示
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、前端缺失功能
|
|
||||||
|
|
||||||
### M1. 饮食记录无编辑/修改功能
|
|
||||||
- 当前只能查看(`DietRecordDetailPage`)和删除,无法修改已有记录
|
|
||||||
|
|
||||||
### M2. 运动计划详情页缺失
|
|
||||||
- `ExercisePlanPage` 的 `onTap: () {}` 为空,点卡片无反应
|
|
||||||
- 没有类似 `ExercisePlanDetailPage` 的页面
|
|
||||||
|
|
||||||
### M3. 问诊列表无前端入口
|
|
||||||
- 后端有 `/api/consultations` GET,但前端没有问诊历史列表页
|
|
||||||
|
|
||||||
### M4. 对话无删除/清空功能
|
|
||||||
- 后端有 `DELETE /api/ai/conversations/{id}`,但前端无对应UI
|
|
||||||
|
|
||||||
### M5. 无数据导出功能
|
|
||||||
- 用户无法导出健康数据(血压记录、饮食记录等)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、安全与代码质量
|
|
||||||
|
|
||||||
### S1. JWT Secret 开发默认值
|
|
||||||
- **位置**:`Program.cs:46`
|
|
||||||
- **问题**:`jwtSecret ??= "dev-secret-key-change-in-production-min-32-chars!!";`
|
|
||||||
- **风险**:若忘记设环境变量,生产环境用弱密钥
|
|
||||||
|
|
||||||
### S2. token 通过 query string 传输(SSE)
|
|
||||||
- **位置**:`ai_chat_endpoints.cs:25`, `sse_handler.dart:17`
|
|
||||||
- **问题**:`token` 放在 URL query string,会被服务器日志、代理缓存
|
|
||||||
- **风险**:中等(含过期时间的 token,但仍有泄露风险)
|
|
||||||
|
|
||||||
### S3. CORS 全开
|
|
||||||
- **位置**:`Program.cs:96-98`
|
|
||||||
- **代码**:`policy.SetIsOriginAllowed(_ => true)`
|
|
||||||
- **风险**:生产环境应限定具体 origin
|
|
||||||
|
|
||||||
### S4. 全局异常中间件可能泄露内部错误
|
|
||||||
- **位置**:`exception_middleware.cs`
|
|
||||||
- **需要检查**:是否在生产环境返回了调用栈
|
|
||||||
|
|
||||||
### S5. Flutter 硬编码后端 IP
|
|
||||||
- **位置**:`api_client.dart:6`
|
|
||||||
- **代码**:`const String baseUrl = 'http://10.4.164.158:5000';`
|
|
||||||
- **问题**:每次换网络都需改代码
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、未使用代码(可清理)
|
|
||||||
|
|
||||||
- `chat_messages_view.dart`: `_cardFilledBtn`, `_cardOutlineBtn`, `_agentColors`, `_taskRow` 未使用
|
|
||||||
- `chat_provider.dart`: `_parseAgent` 未使用
|
|
||||||
- `remaining_pages.dart`: `shadcn_ui` 导入未使用
|
|
||||||
- `report_pages.dart`: `shadcn_ui` 导入未使用,`reportId` 变量未使用
|
|
||||||
- `service_package_detail_page.dart`: `navigation_provider` 导入未使用
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、Agent 自动审查新增发现
|
|
||||||
|
|
||||||
### A1. app_router.dart 无防御 null 断言(🔴严重)
|
|
||||||
- **位置**:`app_router.dart:41-73`
|
|
||||||
- **代码示例**:`ReportDetailPage(id: params['id']!)` 等多处
|
|
||||||
- **问题**:`params['id']!` 若 params 中无 'id' 键,会抛出 null 断言异常导致崩溃
|
|
||||||
- **影响**:任何路由参数拼写错误或缺少都会 crash
|
|
||||||
- **修复**:使用 `params['id'] ?? ''` 并提供 fallback
|
|
||||||
|
|
||||||
### A2. device_scan_page.dart BLE 流订阅未取消(🔴严重)
|
|
||||||
- **位置**:`device_scan_page.dart`
|
|
||||||
- **问题**:BLE stream subscription 在 dispose 时未 cancel,导致蓝牙连接泄漏
|
|
||||||
- **影响**:多次进出扫描页会积累未释放的 BLE 连接
|
|
||||||
|
|
||||||
### A3. 静默吞错误(🟡中)
|
|
||||||
- **范围**:全前端 28 处 `catch (_) {}`
|
|
||||||
- **问题**:所有 API 错误、解析错误均无日志或用户提示
|
|
||||||
- **修复**:至少加上 `debugPrint('Error: $e')` 或显示 snackbar
|
|
||||||
|
|
||||||
### A4. 删除后未刷新 Provider(🟡中)
|
|
||||||
- **范围**:多个页面
|
|
||||||
- **问题**:删除操作后调用了 `_load()` 或 `_refresh()`(setState),但未调用 `ref.invalidate(someProvider)`,导致 Riverpod 缓存未更新
|
|
||||||
- **影响**:切换到其他页面再回来,旧数据可能仍显示
|
|
||||||
|
|
||||||
### A5. FutureProvider + setState 双重模式(🟢低)
|
|
||||||
- **范围**:`DietRecordListPage`, `ExercisePlanPage` 等
|
|
||||||
- **问题**:同时使用 Riverpod FutureProvider 和本地 setState,导致 provider 失效无效
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、全部问题汇总(含Agent发现)
|
|
||||||
|
|
||||||
| # | 优先级 | 位置 | 问题描述 |
|
|
||||||
|---|--------|------|----------|
|
|
||||||
| 1 | 🔴 | `app_router.dart` | null 断言无防御,参数缺失即崩溃 |
|
|
||||||
| 2 | 🔴 | `device_scan_page.dart` | BLE 流订阅未取消 |
|
|
||||||
| 3 | 🔴 | `B1` consultation | 缺少 DELETE 端点 |
|
|
||||||
| 4 | 🔴 | `S1` Program.cs | JWT 开发默认弱密钥 |
|
|
||||||
| 5 | 🔴 | `C9` diet_capture | TextEditingController 泄漏 |
|
|
||||||
| 6 | 🟡 | 全局 28处 | catch(_){} 静默吞错 |
|
|
||||||
| 7 | 🟡 | 多页面 | 删除后无 ref.invalidate |
|
|
||||||
| 8 | 🟡 | `C6` widget_test | 断言 primary == primaryLight 错误 |
|
|
||||||
| 9 | 🟡 | `C10` report | 上传失败无提示 |
|
|
||||||
| 10 | 🟡 | `M2` exercise | 运动详情页缺失,onTap 空 |
|
|
||||||
| 11 | 🟡 | `C8` report | pop 时序问题 |
|
|
||||||
| 12 | 🟡 | `A5` 多页面 | FutureProvider+setState 双重模式 |
|
|
||||||
| 13 | 🟢 | `B3` ai_chat | EncoderParameters 未释放 |
|
|
||||||
| 14 | 🟢 | `B4` bg_service | 用药提醒轮询频率需审视 |
|
|
||||||
| 15 | 🟢 | `B5` dev_data | 生产环境可能误开启测试数据 |
|
|
||||||
| 16 | 🟢 | `S2` SSE | token 走 query string |
|
|
||||||
| 17 | 🟢 | `S3` CORS | 全开 AllowCredentials |
|
|
||||||
| 18 | 🟢 | `S5` api_client | 硬编码 IP |
|
|
||||||
| 19 | 🟢 | 多处 | 未使用代码可清理 |
|
|
||||||
| 20 | 🟢 | `B2` unified | unified agent 缺少 manage_archive 工具 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 八、后端 Agent 审查新增发现(关键)
|
|
||||||
|
|
||||||
### D1. 运动计划 DayOfWeek 跨层不一致(🔴严重)
|
|
||||||
- **位置**:`prompt_manager.cs` vs `exercise_plan.cs` 实体注释
|
|
||||||
- **问题**:Prompt 告诉 AI `day_of_week: 0-6(周日=0)`,但实体注释 `// 0=周一, 6=周日`。AI 按周日=0 生成数据,后端按周一=0 解析,运动计划星期全偏一天
|
|
||||||
- **修复**:统一为 C# DayOfWeek 枚举(0=周日)
|
|
||||||
|
|
||||||
### D2. SMS 验证码使用伪随机数(🔴安全漏洞)
|
|
||||||
- **位置**:`sms_service.cs`
|
|
||||||
- **问题**:`Random.Shared.Next(100000, 1000000)` 使用 PRNG,攻击者可预测验证码
|
|
||||||
- **修复**:改用 `RandomNumberGenerator.GetInt32(100000, 999999)`
|
|
||||||
|
|
||||||
### D3. checkin 无所有权验证(🔴越权漏洞)
|
|
||||||
- **位置**:`medication_agent_handler.cs:99`、`exercise_agent_handler.cs:69`
|
|
||||||
- **问题**:`confirm_medication` 和 `exercise checkin` 直接通过 itemId 操作,不验证是否属于当前用户。任何认证用户可操作他人数据
|
|
||||||
- **修复**:添加 `&& item.Plan.UserId == userId` 检查
|
|
||||||
|
|
||||||
### D4. VisionAsync content 序列化错误(🔴严重)
|
|
||||||
- **位置**:`open_ai_compatible_client.cs:136`
|
|
||||||
- **问题**:将图片 contentParts 先序列化为 JSON 字符串再赋值给 Content,但 OpenAI 兼容 API 期望 Content 为数组格式
|
|
||||||
- **影响**:食物识别 VLM 调用可能失败
|
|
||||||
|
|
||||||
### D5. diet/consultation agent 工具声明但未实现(🔴严重)
|
|
||||||
- **位置**:`diet_agent_handler.cs`、`consultation_agent_handler.cs`
|
|
||||||
- **问题**:`EstimateFoodTool` 和 `RequestDoctorTool` 在 Tools 列表中声明,但 Execute 方法无对应 case,调用返回"未知工具"
|
|
||||||
- **影响**:饮食识别和请求医生功能不可用
|
|
||||||
|
|
||||||
### D6. CleanupService 删除未级联(🔴严重)
|
|
||||||
- **位置**:`cleanup_service.cs:28`
|
|
||||||
- **问题**:`db.Conversations.RemoveRange(oldConversations)` 未先删除关联的 ConversationMessage,可能因 FK 约束抛异常
|
|
||||||
- **修复**:先删除 Messages 再删 Conversations,或使用 ExecuteDeleteAsync
|
|
||||||
|
|
||||||
### D7. 用药提醒时区计算 bug(🔴严重)
|
|
||||||
- **位置**:`medication_reminder_service.cs:37`
|
|
||||||
- **问题**:`DateTime.SpecifyKind(beijingNow.Date, DateTimeKind.Utc)` — 北京时间 00:00 被标记为 UTC 00:00,导致打卡检测有 8 小时偏差
|
|
||||||
- **影响**:提醒时间错位、重复提醒或漏提醒
|
|
||||||
|
|
||||||
### D8. DbContext 未配置外键和级联删除(🟡中等)
|
|
||||||
- **位置**:`app_db_context.cs`
|
|
||||||
- **问题**:`OnModelCreating` 没有任何 `HasOne/WithMany/HasForeignKey/OnDelete` 配置,全凭约定
|
|
||||||
- **影响**:数据库无 FK 约束、级联行为不明确、RefreshToken 全表扫描
|
|
||||||
|
|
||||||
### D9. 缺少多个数据库索引(🟡中等)
|
|
||||||
- RefreshToken: 无索引,认证查询全表扫描
|
|
||||||
- FollowUp: 无 (UserId, ScheduledAt) 索引
|
|
||||||
- DeviceToken: 无 UserId 索引
|
|
||||||
- Report: 无 (UserId, CreatedAt) 索引
|
|
||||||
|
|
||||||
### D10. ExceptionMiddleware 统一返回500(🟡中等)
|
|
||||||
- **位置**:`exception_middleware.cs`
|
|
||||||
- **问题**:所有异常都返回 500,不区分 400/401/404
|
|
||||||
- **影响**:客户端无法根据状态码处理不同类型的错误
|
|
||||||
|
|
||||||
### D11. 用药提醒未实际推送(🟡中等)
|
|
||||||
- **位置**:`medication_reminder_service.cs`
|
|
||||||
- **问题**:TODO 注释表明推送尚未实现,只记录日志
|
|
||||||
|
|
||||||
### D12. Prompt 文本硬编码(🟢低)
|
|
||||||
- 不支持热更新,修改需重新编译
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 九、最终汇总(所有发现)
|
|
||||||
|
|
||||||
| # | 优先级 | 类别 | 位置 | 问题 |
|
|
||||||
|---|--------|------|------|------|
|
|
||||||
| 1 | 🔴 | 安全 | sms_service | 验证码用 PRNG 可预测 |
|
|
||||||
| 2 | 🔴 | 安全 | agent handlers | checkin 越权漏洞 |
|
|
||||||
| 3 | 🔴 | 逻辑 | prompt/entity | DayOfWeek 跨层不一致 |
|
|
||||||
| 4 | 🔴 | 功能 | diet/consult agent | 工具声明但未实现 |
|
|
||||||
| 5 | 🔴 | 功能 | open_ai_client | Vision content 序列化错误 |
|
|
||||||
| 6 | 🔴 | 稳定性 | cleanup_service | 删除未级联 FK 冲突 |
|
|
||||||
| 7 | 🔴 | 功能 | reminder_service | 时区计算 8h 偏差 |
|
|
||||||
| 8 | 🔴 | 崩溃 | app_router.dart | null 断言无防御 |
|
|
||||||
| 9 | 🔴 | 泄漏 | device_scan_page | BLE 流未取消 |
|
|
||||||
| 10 | 🔴 | 安全 | Program.cs | CORS 全开+AllowCredentials |
|
|
||||||
| 11 | 🔴 | 安全 | Program.cs | JWT 默认弱密钥 |
|
|
||||||
| 12 | 🔴 | 泄漏 | diet_capture | TextEditingController 未释放 |
|
|
||||||
| 13 | 🟡 | 体验 | 28处 | catch(_){} 静默吞错 |
|
|
||||||
| 14 | 🟡 | 逻辑 | 多页面 | 删除后未 invalidate provider |
|
|
||||||
| 15 | 🟡 | 配置 | app_db_context | 无 FK/级联/索引 |
|
|
||||||
| 16 | 🟡 | 体验 | exception_middleware | 统一返回 500 |
|
|
||||||
| 17 | 🟡 | 测试 | widget_test | 断言永远失败 |
|
|
||||||
| 18 | 🟡 | 功能 | 用药提醒 | 推送未实现 |
|
|
||||||
| 19 | 🟡 | 架构 | 多页面 | FutureProvider+setState 混用 |
|
|
||||||
| 20 | 🟡 | 缺失 | exercise | 计划详情页缺失 |
|
|
||||||
| 21 | 🟢 | 代码 | 多处 | 未使用代码/本地时间/冗余 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 十、后端端点 Agent 审查新增发现(关键)
|
|
||||||
|
|
||||||
### E1. 医生端点零授权(🔴阻断级)
|
|
||||||
- **位置**:`doctor_endpoints.cs:19`
|
|
||||||
- **问题**:`MapGroup("/api/doctor")` **没有 `.RequireAuthorization()`**,所有医生端点(患者详情、健康数据、问诊、报告、随访)对公网开放
|
|
||||||
- **影响**:任何人可查看所有患者隐私数据、修改报告、创建/删除随访
|
|
||||||
|
|
||||||
### E2. consultation POST 消息无所有权检查(🔴阻断级)
|
|
||||||
- **位置**:`consultation_endpoints.cs:48-75`
|
|
||||||
- **问题**:发消息只检查 consultation 存在,不验证是否属于当前用户。用户A可向用户B的问诊发消息
|
|
||||||
|
|
||||||
### E3. exercise checkin 无所有权检查(🔴阻断级)
|
|
||||||
- **位置**:`exercise_endpoints.cs:119`
|
|
||||||
- **问题**:`FindAsync([itemId])` 只按ID查,不验证 `item.Plan.UserId == userId`。用户可操作他人运动计划
|
|
||||||
|
|
||||||
### E4. SMS验证码在响应中暴露(🔴严重)
|
|
||||||
- **位置**:`auth_endpoints.cs:34`
|
|
||||||
- **问题**:`devCode = code` 将6位验证码直接返回在JSON中,无 `#if DEBUG` 守卫
|
|
||||||
|
|
||||||
### E5. Task.Run 火后不理模式(🔴严重)
|
|
||||||
- **位置**:`report_endpoints.cs:67`
|
|
||||||
- **问题**:`_ = Task.Run(async () => { ... }, CancellationToken.None)` 在请求结束后 scope 可能已释放,后台任务崩溃
|
|
||||||
|
|
||||||
### E6. System.Drawing 仅Windows(🔴严重)
|
|
||||||
- **位置**:`ai_chat_endpoints.cs:486-494`
|
|
||||||
- **问题**:`Image.FromFile`/`Bitmap`/`Graphics` 在Linux容器中崩溃
|
|
||||||
|
|
||||||
### E7. 健康数据 N+1 查询(🟡中等)
|
|
||||||
- **位置**:`health_endpoints.cs:78-89`
|
|
||||||
- **问题**:`/latest` 对5种指标类型分别发一次SQL查询
|
|
||||||
|
|
||||||
### E8. 日历用药事件显示错误(🟡中等)
|
|
||||||
- **位置**:`calendar_endpoints.cs:49`
|
|
||||||
- **问题**:用户有任意活跃用药就在每月每天标记"用药",不区分具体哪天该吃药
|
|
||||||
|
|
||||||
### E9. 手动JSON解析绕过模型绑定(🟡中等)
|
|
||||||
- **范围**:diet、medication、exercise、doctor endpoints
|
|
||||||
- **问题**:`JsonDocument.Parse` 手动解析导致 Swagger 无法文档化、无自动验证、拼写错误抛500
|
|
||||||
|
|
||||||
### E10. 缺失端点
|
|
||||||
- health:缺 DELETE
|
|
||||||
- diet:缺 PUT
|
|
||||||
- exercise:缺 PUT
|
|
||||||
- report:缺 DELETE
|
|
||||||
- file:缺 GET/DELETE/list
|
|
||||||
- followup:缺 detail/confirm
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 十一、完整问题排名
|
|
||||||
|
|
||||||
| # | 等级 | 文件 | 问题 |
|
|
||||||
|---|------|------|------|
|
|
||||||
| 1 | 🔴🔴 | doctor_endpoints | **零授权** — 所有患者数据公开 |
|
|
||||||
| 2 | 🔴🔴 | consultation | 发消息无所有权检查 |
|
|
||||||
| 3 | 🔴🔴 | exercise | checkin无所有权检查 |
|
|
||||||
| 4 | 🔴 | auth | SMS验证码响应暴露 |
|
|
||||||
| 5 | 🔴 | report | Task.Run火后不理 |
|
|
||||||
| 6 | 🔴 | ai_chat | System.Drawing仅Windows |
|
|
||||||
| 7 | 🔴 | sms_service | PRNG可预测验证码 |
|
|
||||||
| 8 | 🔴 | agent handlers | checkin越权 |
|
|
||||||
| 9 | 🔴 | prompt_manager | DayOfWeek不一致 |
|
|
||||||
| 10 | 🔴 | diet/consult agent | 工具声明未实现 |
|
|
||||||
| 11 | 🔴 | open_ai_client | Vision序列化错误 |
|
|
||||||
| 12 | 🔴 | cleanup_service | 删除未级联 |
|
|
||||||
| 13 | 🔴 | reminder_service | 时区8h偏差 |
|
|
||||||
| 14 | 🔴 | app_router | null断言崩溃 |
|
|
||||||
| 15 | 🔴 | device_scan | BLE泄漏 |
|
|
||||||
| 16 | 🔴 | Program.cs | CORS全开 |
|
|
||||||
| 17 | 🔴 | Program.cs | JWT弱密钥 |
|
|
||||||
| 18 | 🔴 | diet_capture | Controller泄漏 |
|
|
||||||
| 19 | 🟡 | 28处 | catch(_){}吞错 |
|
|
||||||
| 20 | 🟡 | 多页面 | 删除未invalidate |
|
|
||||||
| 21 | 🟡 | health | N+1查询 |
|
|
||||||
| 22 | 🟡 | calendar | 用药事件逻辑错误 |
|
|
||||||
| 23 | 🟡 | app_db_context | 无FK/索引配置 |
|
|
||||||
| 24 | 🟡 | exception_mw | 统一500 |
|
|
||||||
| 25 | 🟡 | 提醒服务 | 推送未实现 |
|
|
||||||
| 26 | 🟡 | 多端点 | 缺PUT/DELETE |
|
|
||||||
| 27 | 🟡 | 多端点 | 手动JSON无验证 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 优先级排序
|
|
||||||
|
|
||||||
| 优先级 | Bug | 影响范围 |
|
|
||||||
|--------|-----|----------|
|
|
||||||
| 🔴 高 | C9: TextEditingController 内存泄漏 | 饮食识别页面 |
|
|
||||||
| 🔴 高 | B1: 缺少 consultation DELETE | 问诊功能 |
|
|
||||||
| 🔴 高 | S1: JWT 弱密钥默认值 | 安全 |
|
|
||||||
| 🟡 中 | C6: Flutter 测试断言错误 | CI/CD |
|
|
||||||
| 🟡 中 | C10: 报告上传错误吞没 | 用户体验 |
|
|
||||||
| 🟡 中 | M2: 运动计划详情页缺失 | 用户体验 |
|
|
||||||
| 🟡 中 | C8: report pop 时序问题 | 潜在崩溃 |
|
|
||||||
| 🟢 低 | B3: EncoderParameters 未释放 | 极少量泄漏 |
|
|
||||||
| 🟢 低 | S5: 硬编码 IP | 开发体验 |
|
|
||||||
| 🟢 低 | 未使用代码 | 代码质量 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*共发现 15 个问题,5 个缺失功能。其中 5 个 Bug 已在本轮修复。*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 第二轮换角度审查(新增 10 个问题)
|
|
||||||
|
|
||||||
### F1. uploadFile 响应解析崩溃 🔴
|
|
||||||
`api_client.dart:60-69` — 后端返回 `data: [{id, name, size}]`(List),前端做 `data['data']?['url']` 对List用字符串索引,运行时异常。聊天图片上传全部无声失败。
|
|
||||||
|
|
||||||
### F2. consultationChatProvider SignalR 永停不了 🔴
|
|
||||||
`consultation_provider.dart` — `stop()` 定义了但无 Widget dispose 调用。离开问诊页后 SignalR 连接和 5秒轮询永续运行。
|
|
||||||
|
|
||||||
### F3. chatProvider 流订阅无 dispose 🔴
|
|
||||||
`chat_provider.dart` — `_subscription`/`_streamTimer` 无 dispose,provider 销毁即泄漏。
|
|
||||||
|
|
||||||
### F4. ChatMessage 原地修改破坏不可变性 🔴
|
|
||||||
`msg.confirmed = true` 直接修改 state 中的对象 → 违反 Riverpod 不可变契约。
|
|
||||||
|
|
||||||
### F5. 所有 FutureProvider 无 autoDispose 🟡
|
|
||||||
6 个 FutureProvider 缓存永不过期,页面级数据不释放。
|
|
||||||
|
|
||||||
### F6. authProvider Token 刷新窗口期 id/phone 为空 🟡
|
|
||||||
`auth_provider.dart:59` — `UserInfo(id: '', phone: '')` 然后等 `_loadProfile()` 异步补全。
|
|
||||||
|
|
||||||
### F7. 医生 Web 零认证 🔴🔴
|
|
||||||
`doctor_web/src/services/api-client.ts` — 不发送 Authorization 头。叠加后端 doctor_endpoints 零授权(E1),任何人可访问所有患者数据。
|
|
||||||
|
|
||||||
### F8. 医生 Web SignalR handler 泄漏 🔴
|
|
||||||
`ChatPage.tsx:28-65` — 组件卸载时若在 Reconnecting 状态,跳过 `off('ReceiveMessage')`,重复挂载累积 handler。
|
|
||||||
|
|
||||||
### F9. 6个后端端点前端从未调用 🟢
|
|
||||||
`GET/DELETE /api/ai/conversations`、`GET /api/consultations`、`PUT /api/health-records/{id}`、全部 `/api/doctor/*`
|
|
||||||
|
|
||||||
### F10. 37 对 API 契约中 1 对崩溃 + 多端手动JSON脆弱 🟡
|
|
||||||
`uploadFile` 是唯一崩溃的。其余 36 对匹配但 `TryGetProperty` 区分大小写,依赖前端恰好用 camelCase。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*两轮共发现 37 个问题,其中 5 个阻断级、19 个严重、10 个中等、3 个低。*
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
# 后端架构演进方案
|
|
||||||
|
|
||||||
日期:2026-06-18
|
|
||||||
|
|
||||||
## 1. 背景
|
|
||||||
|
|
||||||
当前项目已经具备 `Health.Domain`、`Health.Application`、`Health.Infrastructure`、`Health.WebApi` 的分层目录,但实际业务逻辑主要仍写在 `Health.WebApi/Endpoints` 中。多数接口直接注入 `AppDbContext`,在 Endpoint 内完成查询、权限判断、状态流转和 `SaveChanges`。
|
|
||||||
|
|
||||||
这种方式适合早期快速验证,但随着患者端健康管理、AI 分析、报告、饮食、用药、运动、医生端等业务增长,会带来几个问题:
|
|
||||||
|
|
||||||
- 业务规则分散在 Endpoint、AI Agent Handler、后台服务中。
|
|
||||||
- 同一个业务动作可能被普通接口和 AI 工具重复实现。
|
|
||||||
- 权限和资源归属校验容易遗漏。
|
|
||||||
- 异步任务目前存在 `Task.Run` 形式,不便于限流、重试和统一管理。
|
|
||||||
- Application 层没有真正承接业务用例,后续测试和维护成本会升高。
|
|
||||||
|
|
||||||
技术目标是按 DDD 思路逐步演进:让 Endpoint 成为接口适配层,业务流程进入 Application Service,外部能力和数据访问由 Infrastructure 支撑,耗时任务通过生产者-消费者管道处理。
|
|
||||||
|
|
||||||
## 2. 当前业务边界
|
|
||||||
|
|
||||||
### 2.1 当前核心患者端闭环
|
|
||||||
|
|
||||||
以下业务都需要继续做扎实:
|
|
||||||
|
|
||||||
1. AI 健康管家聊天
|
|
||||||
2. 健康指标记录与趋势:血压、心率、血糖、血氧、体重
|
|
||||||
3. 报告上传与 AI 预解读
|
|
||||||
4. 饮食拍照分析与保存
|
|
||||||
5. 用药管理与打卡
|
|
||||||
6. 运动计划
|
|
||||||
|
|
||||||
### 2.2 医生端当前策略
|
|
||||||
|
|
||||||
医生端可以做代码整理和权限边界收拢,但不作为当前核心业务闭环:
|
|
||||||
|
|
||||||
- 医患实时聊天:暂时搁置,后续接入互联网医院后再完善。
|
|
||||||
- 医生审核报告:暂时不是当前真实流程,患者端报告以 AI 预解读为主;界面可显示“医生审核中/待审核”一类状态。
|
|
||||||
- 医生工作台:保留现有页面和基础接口,不主动扩大功能范围,重构时以不影响当前项目运行为目标。
|
|
||||||
|
|
||||||
### 2.3 AI 写入规则
|
|
||||||
|
|
||||||
统一业务规则:
|
|
||||||
|
|
||||||
- AI 纯查询、解释、建议可以直接回复。
|
|
||||||
- AI 只要要写入用户健康相关数据,必须先让用户确认。
|
|
||||||
- 需要确认的写入包括但不限于:健康指标、用药计划、运动计划、健康档案修改。
|
|
||||||
- 饮食记录当前在饮食分析结果页由用户主动保存,保留该方式。
|
|
||||||
|
|
||||||
## 3. 目标架构
|
|
||||||
|
|
||||||
目标不是创建一个巨大的全能 Service,而是按业务模块拆分 Application Service:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Health.WebApi
|
|
||||||
Endpoints
|
|
||||||
Hubs
|
|
||||||
BackgroundServices
|
|
||||||
|
|
||||||
Health.Application
|
|
||||||
Auth
|
|
||||||
Users
|
|
||||||
HealthRecords
|
|
||||||
Medications
|
|
||||||
Diet
|
|
||||||
Reports
|
|
||||||
Exercise
|
|
||||||
Consultations
|
|
||||||
Doctors
|
|
||||||
Ai
|
|
||||||
Common
|
|
||||||
|
|
||||||
Health.Domain
|
|
||||||
Entities
|
|
||||||
Enums
|
|
||||||
DomainRules
|
|
||||||
|
|
||||||
Health.Infrastructure
|
|
||||||
Data
|
|
||||||
AI
|
|
||||||
Services
|
|
||||||
Storage
|
|
||||||
```
|
|
||||||
|
|
||||||
职责边界:
|
|
||||||
|
|
||||||
- `WebApi`:接收 HTTP/SignalR 请求,解析参数,返回响应。
|
|
||||||
- `Application`:承接业务用例、状态流转、权限校验、任务入队。
|
|
||||||
- `Domain`:保存核心实体、枚举和稳定业务规则。
|
|
||||||
- `Infrastructure`:EF Core、AI 客户端、短信、文件存储、推送、未来互联网医院适配。
|
|
||||||
|
|
||||||
## 4. 数据库读写收拢方式
|
|
||||||
|
|
||||||
短期先不强制引入 Repository 抽象,避免过度设计。第一阶段采用:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Endpoint -> Application Service -> AppDbContext
|
|
||||||
```
|
|
||||||
|
|
||||||
也就是说,数据库读写先统一进入对应业务 Service,而不是继续散落在 Endpoint 中。
|
|
||||||
|
|
||||||
后续如果业务复杂度继续提升,再考虑:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Application Service -> Repository / UnitOfWork -> AppDbContext
|
|
||||||
```
|
|
||||||
|
|
||||||
第一阶段优先收拢这些模块:
|
|
||||||
|
|
||||||
1. `ReportService`
|
|
||||||
2. `DietService`
|
|
||||||
3. `MedicationService`
|
|
||||||
4. `ExerciseService`
|
|
||||||
5. `HealthRecordService`
|
|
||||||
6. `ConsultationService`
|
|
||||||
|
|
||||||
## 5. 生产者-消费者管道
|
|
||||||
|
|
||||||
不是所有业务都需要管道。同步、快速、必须立即返回的操作仍走普通 Service。
|
|
||||||
|
|
||||||
适合管道的任务:
|
|
||||||
|
|
||||||
- 报告 AI 分析
|
|
||||||
- 饮食图片识别
|
|
||||||
- 处方图片识别
|
|
||||||
- 用药提醒推送
|
|
||||||
- 健康周报生成
|
|
||||||
- 后期互联网医院数据同步
|
|
||||||
|
|
||||||
当前阶段采用 .NET 内置 `Channel<T>` + `BackgroundService`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
业务 Service = 生产者
|
|
||||||
Channel<TJob> = 内存任务队列
|
|
||||||
BackgroundService = 消费者
|
|
||||||
AI/推送/外部服务 = 实际执行器
|
|
||||||
```
|
|
||||||
|
|
||||||
单服务器和当前用户规模下,内存队列足够作为第一阶段方案。后续如果出现多实例部署、任务不能丢、重试次数和死信队列等要求,再迁移到 Redis Stream、RabbitMQ 或 Hangfire。
|
|
||||||
|
|
||||||
## 6. 第一阶段落地范围
|
|
||||||
|
|
||||||
先从报告模块落地,因为它同时具备上传、AI、异步、状态流转、失败重试等典型场景。
|
|
||||||
|
|
||||||
### 6.1 报告模块目标
|
|
||||||
|
|
||||||
从当前:
|
|
||||||
|
|
||||||
```text
|
|
||||||
ReportEndpoints -> AppDbContext + Task.Run + AI 调用
|
|
||||||
```
|
|
||||||
|
|
||||||
演进为:
|
|
||||||
|
|
||||||
```text
|
|
||||||
ReportEndpoints
|
|
||||||
-> ReportService
|
|
||||||
-> 保存文件
|
|
||||||
-> 创建报告
|
|
||||||
-> 标记状态
|
|
||||||
-> 入队 ReportAnalysisJob
|
|
||||||
|
|
||||||
ReportAnalysisWorker
|
|
||||||
-> 消费 ReportAnalysisQueue
|
|
||||||
-> 调用 ReportAnalysisService / AI Analyzer
|
|
||||||
-> 更新报告状态
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 报告模块要保持的业务行为
|
|
||||||
|
|
||||||
- 上传只支持报告图片。
|
|
||||||
- 上传成功后状态为 `Analyzing`。
|
|
||||||
- AI 成功后进入 `PendingDoctor`,患者端展示 AI 预解读。
|
|
||||||
- AI 失败后进入 `AnalysisFailed`,不生成假摘要、假指标。
|
|
||||||
- 患者端可以查看原始报告图片。
|
|
||||||
- 医生审核不是当前核心闭环,状态可保留但不扩大功能。
|
|
||||||
|
|
||||||
## 7. 当前已落地进展
|
|
||||||
|
|
||||||
截至 2026-06-18,第一轮服务化已经完成以下模块:
|
|
||||||
|
|
||||||
1. `ReportService`:报告上传、图片校验、报告创建、重新分析、删除、AI 分析状态流转进入服务层;报告分析通过 `ReportAnalysisQueue` + `ReportAnalysisWorker` 异步消费。
|
|
||||||
2. `HealthRecordService`:健康指标列表、创建、更新、删除、最新值、趋势查询、异常判断进入服务层;AI 记数据工具复用同一套创建逻辑。
|
|
||||||
3. `ExerciseService`:运动计划创建、列表、详情、删除、打卡、AI 创建/查询/打卡进入服务层。
|
|
||||||
4. `MedicationService`:用药列表、创建、更新、删除、今日服药确认、按剂量打卡、提醒查询、AI 创建/查询/确认进入服务层。
|
|
||||||
5. `DietService`:饮食记录列表、保存、删除、热量/评分更新进入服务层。
|
|
||||||
|
|
||||||
其中以下模块已经继续演进为更严格的 Application Service + Repository 结构:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Endpoint
|
|
||||||
-> Health.Application.*Service
|
|
||||||
-> Health.Application.I*Repository
|
|
||||||
-> Health.Infrastructure.Ef*Repository
|
|
||||||
-> AppDbContext
|
|
||||||
```
|
|
||||||
|
|
||||||
已完成:
|
|
||||||
|
|
||||||
1. `HealthRecordService` + `IHealthRecordRepository` + `EfHealthRecordRepository`
|
|
||||||
2. `ExerciseService` + `IExerciseRepository` + `EfExerciseRepository`
|
|
||||||
3. `DietService` + `IDietRepository` + `EfDietRepository`
|
|
||||||
4. `MedicationService` + `IMedicationRepository` + `EfMedicationRepository`
|
|
||||||
5. `ReportService` + `IReportRepository` + `EfReportRepository` + `IReportFileStorage` + `LocalReportFileStorage`
|
|
||||||
|
|
||||||
报告模块中,`ReportAnalysisQueue` 和 `ReportAnalysisService` 仍属于 Infrastructure:前者是内存队列适配,后者需要调用 AI 客户端并通过 `IReportRepository` 更新报告状态,不再直接依赖 `AppDbContext`。
|
|
||||||
|
|
||||||
AI 写入确认机制已完成第一阶段落地:
|
|
||||||
|
|
||||||
```text
|
|
||||||
AI 写入工具调用
|
|
||||||
-> 创建 10 分钟有效的 PendingAiWriteCommand
|
|
||||||
-> 前端展示确认卡片,此时不写数据库
|
|
||||||
-> 用户点击确认
|
|
||||||
-> POST /api/ai/confirm-write/{commandId}
|
|
||||||
-> 校验当前用户和一次性命令
|
|
||||||
-> 执行对应 Application Service 写入
|
|
||||||
```
|
|
||||||
|
|
||||||
- 健康指标、创建/确认用药、创建/打卡运动、AI 修改健康档案均进入待确认流程。
|
|
||||||
- 纯查询工具继续直接执行。
|
|
||||||
- 命令只能由所属用户执行一次;执行失败时会恢复命令供用户重试,过期或服务重启后自动失效。
|
|
||||||
- AI 待确认命令已迁移到数据库表 `AiWriteCommands`,领取命令、业务写入和完成状态在同一事务内执行,避免重复写入。
|
|
||||||
- 报告分析任务已迁移到数据库表 `ReportAnalysisTasks`,支持服务重启恢复、原子领取、失败重试和最终失败状态。
|
|
||||||
- 项目当前仍使用 `EnsureCreated` 管理原有表;新增 `DatabaseSchemaMigrator` 和 `__AppSchemaMigrations` 版本表,为已有本地数据库安全补充基础设施表。
|
|
||||||
|
|
||||||
患者端其他业务收拢进展:
|
|
||||||
|
|
||||||
1. `HealthArchiveService` + `IHealthArchiveRepository`:健康档案页面、AI 查询和确认写入统一执行。
|
|
||||||
2. `AiConversationService` + `IAiConversationRepository`:会话创建、消息保存、历史列表和删除统一执行。
|
|
||||||
3. `PatientContextService`:统一组合健康档案、近期指标和当前用药。
|
|
||||||
4. `UserService` + `IUserRepository`:个人资料和账号注销统一执行;账号数据清理使用事务。
|
|
||||||
5. `CalendarService` + `ICalendarRepository`:聚合用药、运动、随访日历。
|
|
||||||
|
|
||||||
生产者/消费者管道现状:
|
|
||||||
|
|
||||||
- 报告分析:数据库持久化任务 + `ReportAnalysisWorker`。
|
|
||||||
- 饮食图片识别:任务持久化到 `DietImageAnalysisTasks`,由 `DietImageAnalysisWorker` 原子领取、失败重试和恢复处理中断任务;前端接口协议保持不变。
|
|
||||||
- 用药提醒:`MedicationReminderService` 负责扫描生产任务,任务持久化到 `MedicationReminderTasks` 并按药品/日期/时间唯一去重;`MedicationReminderWorker` 消费后写入 `NotificationOutbox`。真正的手机推送需在选定推送服务后消费 Outbox,目前不会把未发送通知标记为已推送。
|
|
||||||
- 报告、饮食和用药任务消费者均采用 1 到 5 秒的自适应空闲轮询,过期 Processing 任务每分钟恢复一次,避免每秒重复执行恢复更新。
|
|
||||||
- App 内用药提醒通过 `NotificationOutbox` + `/api/notifications/pending` 提供,患者端前台每 30 秒获取并展示,展示后回执去重;暂不接入系统级或厂商推送。
|
|
||||||
- `MaintenanceService` 每小时执行一次维护,自动删除超过 30 天的已完成/失败后台任务、通知 Outbox、过期 AI 写入命令和旧 AI 会话,不删除用户健康记录、饮食记录或用药计划。
|
|
||||||
- 登录、注册、验证码、Token 刷新和退出已收拢到 `IAuthService`;管理员医生管理和患者列表已收拢到 `IAdminService`,Endpoint 不再直接读写数据库。开发环境 `send-sms` 仍返回 `devCode` 供 Flutter 自动填入,非开发环境不返回验证码。
|
|
||||||
- AI 工具查询、确认写入和事务边界已收拢到 `IAiToolExecutionService`,AI Endpoint 不再直接持有 `AppDbContext`。
|
|
||||||
- 运动计划已从 `WeekStartDate + DayOfWeek` 周模板改为 `StartDate + EndDate + ReminderTime`,每日条目使用唯一的 `ScheduledDate`。连续 7 天、10 天或更长计划按真实日期生成,首页今日任务、健康日历、医生端只读详情和 AI 创建均使用同一日期模型。
|
|
||||||
- App 内运动提醒按每日条目的 `ScheduledDate` 和计划 `ReminderTime` 生成到 `NotificationOutbox`;已打卡和休息日不会提醒,同一每日条目只生成一次。
|
|
||||||
|
|
||||||
当前仍保留在 Endpoint 或旧 Handler 中的业务,需要后续逐步收拢:
|
|
||||||
|
|
||||||
- `ConsultationService`:医生聊天暂不作为当前核心业务,但基础权限和数据读写仍可继续服务化。
|
|
||||||
- `DoctorService` / `AdminDoctorService`:医生信息、患者列表、报告查看等目前不作为真实互联网医院流程,后续按老板和互联网医院接入方案调整。
|
|
||||||
- `CalendarService`:健康日历目前聚合运动、用药、饮食等多模块数据,适合在核心模块稳定后单独收拢。
|
|
||||||
- `User/ProfileService`:个人信息、健康档案、账号清理等可继续整理,尤其是 AI 修改健康档案前的确认规则。
|
|
||||||
|
|
||||||
## 8. 后续推广顺序
|
|
||||||
|
|
||||||
1. 报告:Application Service + 分析队列 + Worker
|
|
||||||
2. 饮食:图片识别任务队列,用户修正后保存
|
|
||||||
3. 用药:提醒扫描和推送任务解耦
|
|
||||||
4. 运动:计划创建、打卡规则收拢到 Service
|
|
||||||
5. 健康指标:记录、异常判断、趋势查询收拢到 Service
|
|
||||||
6. 问诊:互联网医院接入前只收拢基础接口,不扩大聊天功能
|
|
||||||
7. 医生端:保留基础接口,后续根据互联网医院和老板决策再完善审核/聊天工作流
|
|
||||||
|
|
||||||
## 9. 不做的事
|
|
||||||
|
|
||||||
当前阶段暂不做:
|
|
||||||
|
|
||||||
- 不一次性重构全项目。
|
|
||||||
- 不立即引入 RabbitMQ/Kafka 等重型组件。
|
|
||||||
- 不强制引入复杂 Repository 层。
|
|
||||||
- 不改变当前患者端主要交互。
|
|
||||||
- 不把医生聊天/医生审核作为当前核心业务流。
|
|
||||||
|
|
||||||
## 10. 验收标准
|
|
||||||
|
|
||||||
第一阶段完成后应满足:
|
|
||||||
|
|
||||||
- 报告 Endpoint 不再直接承载主要业务流程。
|
|
||||||
- 报告 AI 分析不再使用 `Task.Run`。
|
|
||||||
- 报告分析任务通过队列进入后台 Worker。
|
|
||||||
- 报告状态流转集中在 Application Service。
|
|
||||||
- 上传、列表、详情、删除、查看原图、分析失败状态保持可用。
|
|
||||||
- 后端编译通过,前端报告页分析通过。
|
|
||||||
@@ -1,819 +0,0 @@
|
|||||||
# 健康管家 — 配色设计文档 v3.0
|
|
||||||
|
|
||||||
> 2026-06-12 更新:清理未使用色,新增医生端色系,全面页面配色拆解
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. AppColors 色彩表(定义在 `app_colors.dart`)
|
|
||||||
|
|
||||||
### 主色
|
|
||||||
|
|
||||||
| 常量 | 色号 | 色块 | 用途 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| `primary` | `#8B5CF6` | 🟣🟣🟣🟣🟣 | 用户端主紫 |
|
|
||||||
| `primaryLight` | `#A78BFA` | 🟣 | 浅紫/渐变 |
|
|
||||||
| `primaryDark` | `#7C3AED` | 🟣 | 深紫/按下 |
|
|
||||||
| `doctorBlue` | `#0891B2` | 🔵 | **医生端主色** |
|
|
||||||
|
|
||||||
### 背景
|
|
||||||
|
|
||||||
| 常量 | 色号 | 色块 | 用途 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| `background` | `#F0ECFF` | ⬜ | 患者端页面底 |
|
|
||||||
| `cardBackground` | `#FFFFFF` | ⬜ | 卡片白 |
|
|
||||||
| `cardInner` | `#F5F2FF` | ⬜ | 卡片内底 |
|
|
||||||
| `pageGrey` | `#F5F5F5` | ⬜ | 医生端页面底 |
|
|
||||||
| `iconBg` | `#E8E0FA` | ⬜ | 图标底 |
|
|
||||||
| `avatarBg` | `#F0F0FF` | ⬜ | 头像底 |
|
|
||||||
|
|
||||||
### 文字
|
|
||||||
|
|
||||||
| 常量 | 色号 | 色块 | 用途 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| `textPrimary` | `#1F2937` | ⬛⬛⬛ | 主文字 |
|
|
||||||
| `textSecondary` | `#6B7280` | ⬛⬛ | 副文字 |
|
|
||||||
| `textHint` | `#9CA3AF` | ⬛ | 提示 |
|
|
||||||
| `textOnGradient` | `#FFFFFF` | ⬜ | 渐变上白色文字 |
|
|
||||||
|
|
||||||
### 边框
|
|
||||||
|
|
||||||
| 常量 | 色号 | 色块 |
|
|
||||||
|------|------|------|
|
|
||||||
| `border` | `#E5E7EB` | ⬜ |
|
|
||||||
| `borderLight` | `#F3F4F6` | ⬜ |
|
|
||||||
|
|
||||||
### 功能色
|
|
||||||
|
|
||||||
| 常量 | 色号 | 色块 | 含义 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| `success` | `#10B981` | 🟢 | 成功/正常/绿色 |
|
|
||||||
| `successLight` | `#D1FAE5` | 🟢 | 成功浅底 |
|
|
||||||
| `error` | `#EF4444` | 🔴 | 错误/异常/红色 |
|
|
||||||
| `errorLight` | `#FEE2E2` | 🔴 | 错误浅底 |
|
|
||||||
| `warning` | `#F59E0B` | 🟡 | 警告/待定 |
|
|
||||||
| `warningLight` | `#FEF3C7` | 🟡 | 警告浅底 |
|
|
||||||
| `blueMeasure` | `#3B82F6` | 🔵 | 指标蓝色 |
|
|
||||||
|
|
||||||
### 渐变
|
|
||||||
|
|
||||||
| 常量 | 方向 | 色值 |
|
|
||||||
|------|------|------|
|
|
||||||
| `primaryGradient` | 上→下 | `#8B5CF6` → `#A78BFA` |
|
|
||||||
| `bgGradient` | 左→右 | `#F0ECFF` → `#EBF4FF` (4:6比例) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 登录页 (`login_page.dart`)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────┐
|
|
||||||
│ │
|
|
||||||
│ [❤] 图标 │ color: _accent (用户=primary, 医生=doctorBlue)
|
|
||||||
│ 健康管家 │ color: textPrimary
|
|
||||||
│ 登录你的账号 │ color: textSecondary
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────────┐ │
|
|
||||||
│ │ [用户] │ [医生] │ │ 选中: 对应_accent背景+白字
|
|
||||||
│ │ 用户卡 │ 医生卡 │ │ 未选中: 白底+textPrimary字+border边框
|
|
||||||
│ └──────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────────┐ │ 背景: #F5F5F5
|
|
||||||
│ │ 手机号 │ │ 文字: textPrimary
|
|
||||||
│ └──────────────────────────┘ │ 提示: textHint
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────┐ ┌──────┐ │
|
|
||||||
│ │ 验证码 │ │获取 │ │ 左侧同手机号
|
|
||||||
│ └─────────────────┘ │验证码│ │ 按钮: _accent底+白字
|
|
||||||
│ └──────┘ │ 倒计时: #F5F5F5底+_accent字
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────────┐ │ 仅注册+医生时显示
|
|
||||||
│ │ 医生审核码 │ │ 同手机号样式
|
|
||||||
│ └──────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ☑ 已阅读并同意《服务协议》 │ 未勾选: 白+border边框
|
|
||||||
│ 和《隐私政策》 │ 已勾选: _accent底+白勾
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────────┐ │
|
|
||||||
│ │ 登 录 / 注 册 │ │ 按钮: _accent底+白字
|
|
||||||
│ └──────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ 没有账号?去注册 / 已有账号? │ color: _accent
|
|
||||||
│ │
|
|
||||||
└──────────────────────────────────┘
|
|
||||||
大背景: #FFFFFF (白色)
|
|
||||||
```
|
|
||||||
|
|
||||||
| 区域 | 背景色 | 文字色 | 边框色 |
|
|
||||||
|------|--------|--------|--------|
|
|
||||||
| 页面底 | `#FFFFFF` | — | — |
|
|
||||||
| 输入框 | `#F5F5F5` | `textPrimary` | 无 |
|
|
||||||
| 角色卡片(选中) | `primary`或`doctorBlue` | `#FFFFFF` | 同背景 |
|
|
||||||
| 角色卡片(未选中) | `#FFFFFF` | `textPrimary` | `border (#E5E7EB)` |
|
|
||||||
| 获取验证码按钮 | `_accent` | `#FFFFFF` | 无 |
|
|
||||||
| 主按钮 | `_accent` | `#FFFFFF` | 无 |
|
|
||||||
| 协议链接 | 透明 | `_accent` | — |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 患者端首页 (`home_page.dart`)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────┐
|
|
||||||
│ ☰ 健康管家 ⚙ │ AppBar: 紫底渐变(navGradient?)
|
|
||||||
│ │
|
|
||||||
│ [AI对话区域] │
|
|
||||||
│ ┌──────────────────────────┐ │
|
|
||||||
│ │ 患者消息(右) │ │ 气泡: primary底+白字
|
|
||||||
│ │ AI消息(左) │ │ 气泡: cardBackground+textPrimary
|
|
||||||
│ │ 医生消息(左) │ │ 气泡: #FEFEFF+ #D8DCFD边框
|
|
||||||
│ └──────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────────┐ │
|
|
||||||
│ │ 输入框 [发送]│ │ 输入: background底+textPrimary
|
|
||||||
│ └──────────────────────────┘ │ 发送: blueMeasure(可用时)
|
|
||||||
│ │
|
|
||||||
└──────────────────────────────────┘
|
|
||||||
大背景: bgGradient (紫→蓝横向渐变)
|
|
||||||
```
|
|
||||||
|
|
||||||
| 区域 | 背景色 | 文字色 | 边框色 |
|
|
||||||
|------|--------|--------|--------|
|
|
||||||
| 页面底 | `bgGradient` 渐变 | — | — |
|
|
||||||
| 用户气泡 | `primary` | `#FFFFFF` | 无 |
|
|
||||||
| AI气泡 | `cardBackground` | `textPrimary` | `#E2E8F0` |
|
|
||||||
| 医生气泡 | `#FEFEFF` | `textPrimary` | `#D8DCFD` |
|
|
||||||
| 输入栏 | `background` | `textPrimary` | 无 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 侧边抽屉 — 患者端 (`health_drawer.dart`)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────┐
|
|
||||||
│ [头像] 用户名 │ bg: transparent(融入bgGradient)
|
|
||||||
│ 手机号 [设置] │
|
|
||||||
│ │
|
|
||||||
│ ┌────────┐ ┌────────┐ │
|
|
||||||
│ │🔵蓝牙 │ │📁档案 │ │ 白底 + #C8DDFD 边框
|
|
||||||
│ └────────┘ └────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌ 健康仪表盘 ────────┐ │
|
|
||||||
│ │ 血压 心率 血糖 血氧 体重│ 白底 + #C8DDFD 边框
|
|
||||||
│ └────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌ 功能 ──────────────┐ │
|
|
||||||
│ │ 报告 日历 饮食 随访 │ │ 白底 + #C8DDFD 边框
|
|
||||||
│ └────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌ VIP服务 ──────────┐ │
|
|
||||||
│ └────────────────────┘ │ 白底 + #C8DDFD 边框
|
|
||||||
│ │
|
|
||||||
│ ┌ 保险 ────────────┐ │
|
|
||||||
│ └────────────────────┘ │ 白底 + #C8DDFD 边框
|
|
||||||
└──────────────────────────┘
|
|
||||||
大背景: bgGradient
|
|
||||||
```
|
|
||||||
|
|
||||||
| 区域 | 背景色 | 文字色 | 边框 |
|
|
||||||
|------|--------|--------|------|
|
|
||||||
| 抽屉底 | `bgGradient` | — | — |
|
|
||||||
| 信息区 | transparent | `textPrimary`/`textSecondary` | 无 |
|
|
||||||
| 分类卡片 | `cardBackground` (#FFF) | — | `#C8DDFD` (1.5px) |
|
|
||||||
| 分类标题 | `primaryGradient` 紫渐变胶囊 | `#FFFFFF` | — |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 侧边抽屉 — 医生端 (`doctor_drawer.dart`)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────┐
|
|
||||||
│ ┌──────────────────────┐ │
|
|
||||||
│ │ [头像] │ │ bg: doctorBlue (#0891B2)
|
|
||||||
│ │ 医生姓名 │ │ 文字: #FFFFFF
|
|
||||||
│ │ 点击完善信息 │ │
|
|
||||||
│ └──────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ 📊 工作台 │ 选中: doctorBlue字+doctorBlue淡底
|
|
||||||
│ 👥 患者管理 │ 未选中: textPrimary字+无色
|
|
||||||
│ 💬 问诊列表 │
|
|
||||||
│ 📋 报告审核 │
|
|
||||||
│ 📅 复查随访 │
|
|
||||||
│ ──────────────────────── │
|
|
||||||
│ ⚙ 设置 │
|
|
||||||
│ 🚪 退出登录 │
|
|
||||||
└──────────────────────────┘
|
|
||||||
大背景: #FFFFFF
|
|
||||||
```
|
|
||||||
|
|
||||||
| 区域 | 背景色 | 文字色 | 选中态 |
|
|
||||||
|------|--------|--------|--------|
|
|
||||||
| 抽屉底 | `#FFFFFF` | — | — |
|
|
||||||
| 头部 | `doctorBlue` | `#FFFFFF` | — |
|
|
||||||
| 菜单项(选中) | `doctorBlue` 8%透明 | `doctorBlue` | — |
|
|
||||||
| 菜单项(未选中) | transparent | `textPrimary` | — |
|
|
||||||
| 头像 | `#FFFFFF` 24%透明 | `#FFFFFF` | — |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 医生工作台 (`doctor_dashboard_page.dart`)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────┐
|
|
||||||
│ ┌ 请完善个人信息 ───────────┐ │ bg: #E0F2FE, 字: doctorBlue
|
|
||||||
│ └────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────┐ ┌──────────┐ │
|
|
||||||
│ │ 👥 患者 │ │ 💬 问诊 │ │ 统计卡片: 白底
|
|
||||||
│ │ 总数 N │ │ 进行中N │ │ 图标底: 各色10%透明
|
|
||||||
│ └──────────┘ └──────────┘ │
|
|
||||||
│ ┌──────────┐ ┌──────────┐ │
|
|
||||||
│ │ 📋 报告 │ │ 📅 随访 │ │
|
|
||||||
│ │ 待审核N │ │ 今日 N │ │
|
|
||||||
│ └──────────┘ └──────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌ 待回复问诊 (N项) ────────┐ │ 白底, 绿色图标
|
|
||||||
│ │ 患者A 待回复 > │ │
|
|
||||||
│ │ 患者B 待回复 > │ │
|
|
||||||
│ └──────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌ 待审核报告 (N项) ────────┐ │ 白底, 黄色图标
|
|
||||||
│ └──────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌ 今日随访 (N项) ──────────┐ │ 白底, 红色图标
|
|
||||||
│ └──────────────────────────┘ │
|
|
||||||
└──────────────────────────────────┘
|
|
||||||
大背景: pageGrey (#F5F5F5)
|
|
||||||
```
|
|
||||||
|
|
||||||
| 卡片 | 图标色 | 卡底色 | 文字色 |
|
|
||||||
|------|--------|--------|--------|
|
|
||||||
| 患者总数 | `blueMeasure` (#3B82F6) | `#FFFFFF` | `textPrimary` |
|
|
||||||
| 进行中问诊 | `success` (#10B981) | `#FFFFFF` | `textPrimary` |
|
|
||||||
| 待审核报告 | `warning` (#F59E0B) | `#FFFFFF` | `textPrimary` |
|
|
||||||
| 今日随访 | `error` (#EF4444) | `#FFFFFF` | `textPrimary` |
|
|
||||||
| 待回复问诊 | `success` | `#FFFFFF` | `textPrimary` |
|
|
||||||
| 待审核报告 | `warning` | `#FFFFFF` | `textPrimary` |
|
|
||||||
| 今日随访 | `error` | `#FFFFFF` | `textPrimary` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 患者列表 (`doctor_patients_page.dart`)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────┐
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ 🔍 搜索患者姓名或手机号 │ │ 白底, 无边框, textPrimary
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ [头像] 张三 > │ │ 白底卡片, 14px圆角
|
|
||||||
│ │ 138xxxx │ │ 头像底: avatarBg (#F0F0FF)
|
|
||||||
│ └──────────────────────────────┘ │ 名字: textPrimary (16px 粗)
|
|
||||||
│ ┌──────────────────────────────┐ │ 手机: textHint (13px)
|
|
||||||
│ │ [头像] 李四 > │ │ 箭头: textHint
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
│ ... │
|
|
||||||
└──────────────────────────────────┘
|
|
||||||
大背景: pageGrey (#F5F5F5)
|
|
||||||
```
|
|
||||||
|
|
||||||
| 区域 | 背景色 | 文字色 | 边框 |
|
|
||||||
|------|--------|--------|------|
|
|
||||||
| 搜索栏 | `cardBackground` (#FFF) | `textPrimary` | 无 |
|
|
||||||
| 患者卡片 | `cardBackground` (#FFF) | — | 无 |
|
|
||||||
| 头像 | `avatarBg` (#F0F0FF) | `primary` | 无 |
|
|
||||||
| 名字 | — | `textPrimary` | — |
|
|
||||||
| 副信息 | — | `textHint` | — |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 患者详情 (`doctor_patient_detail_page.dart`)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────┐
|
|
||||||
│ ← 患者详情 │ 白底AppBar
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ [头像] 张三 │ │ 白底卡片
|
|
||||||
│ │ 138xxxx · 男 · 1990 │ │ 头像底: avatarBg (#F0F0FF)
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌ 健康档案 ──────────────────┐ │
|
|
||||||
│ │ 诊断 高血压 │ │ 白底卡片
|
|
||||||
│ │ 手术史 心脏搭桥 2023-01 │ │ 标签: textHint (13px)
|
|
||||||
│ │ 过敏史 青霉素、头孢 │ │ 值: 14px
|
|
||||||
│ │ 慢病 糖尿病 │ │
|
|
||||||
│ │ 家族史 父亲高血压 │ │
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌ 健康指标 ──────────────────┐ │
|
|
||||||
│ │ 血压 122/80 mmHg │ │ 白底卡片
|
|
||||||
│ │ 心率 72 次/分 │ │ 异常值: error 色
|
|
||||||
│ │ 血糖 5.6 mmol/L │ │
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌ 当前用药 ──────────────────┐ │
|
|
||||||
│ │ 阿司匹林 100mg 每日 │ │ 白底卡片
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌ 报告(N) ────┐ ┌ 随访(N) ────┐│ 入口卡片
|
|
||||||
│ └──────────────┘ └──────────────┘│
|
|
||||||
└──────────────────────────────────┘
|
|
||||||
大背景: pageGrey (#F5F5F5)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 问诊列表 (`doctor_consultations_page.dart`)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────┐
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ [头像] 患者A [AI中] │ │ 白底卡片, 圆角14
|
|
||||||
│ │ 最新消息预览... │ │ 头像: avatarBg
|
|
||||||
│ └──────────────────────────────┘ │ 状态标签: 各色10%底+对应色字
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ [头像] 患者B [等待医生] │ │ AI中: #6366F1
|
|
||||||
│ └──────────────────────────────┘ │ 等待医生: warning
|
|
||||||
│ ┌──────────────────────────────┐ │ 已回复: success
|
|
||||||
│ │ [头像] 患者C [已回复] │ │ 已关闭: textHint
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
└──────────────────────────────────┘
|
|
||||||
大背景: pageGrey (#F5F5F5)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 报告审核列表 (`doctor_reports_page.dart`)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────┐
|
|
||||||
│ [全部] [待审核] [已审核] │ 筛选标签: 选中=primary底+白字
|
|
||||||
│ │ 未选中=白底+border边框
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ 📋 患者A [待审核] │ │ 白底卡片, 圆角14
|
|
||||||
│ │ 血常规 · 2026-06-11 │ │ 图标: primary
|
|
||||||
│ └──────────────────────────────┘ │ 状态: warning/ success
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ 📋 患者B [已审核] │ │
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
└──────────────────────────────────┘
|
|
||||||
大背景: pageGrey (#F5F5F5)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 报告审核详情 (`doctor_report_detail_page.dart`)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────┐
|
|
||||||
│ ← 报告审核 │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ [头像] 患者A [待审核] │ │ 白底卡片
|
|
||||||
│ │ 血常规 · Image │ │ 头像: avatarBg
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌ AI 预分析 ─────────────────┐ │
|
|
||||||
│ │ 摘要文字... │ │ 白底卡片, 左侧紫色竖条
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌ 指标检测 ─────────────────┐ │
|
|
||||||
│ │ 白细胞 7.5 正常 │ │ 白底卡片
|
|
||||||
│ │ 红细胞 4.1 偏低 │ │ 正常: success 绿
|
|
||||||
│ │ 血红蛋白 125 偏低 │ │ 偏高: error 红
|
|
||||||
│ └──────────────────────────────┘ │ 偏低: warning 黄
|
|
||||||
│ │
|
|
||||||
│ [查看原始报告] │ 白底+primary字+border边框
|
|
||||||
│ │
|
|
||||||
│ ─────── 医生审核 ─────── │
|
|
||||||
│ │
|
|
||||||
│ 严重程度: │
|
|
||||||
│ [正常] [异常] [严重] [危急] │ 选中: 对应色底+白字
|
|
||||||
│ │ 正常=success, 异常=warning
|
|
||||||
│ 建议模板: │ 严重=error, 危急=#991B1B
|
|
||||||
│ [药量调整] [复查建议] [生活方式] │ 选中: #EFF6FF底+#0891B2字+#0891B2边框
|
|
||||||
│ [进一步检查] [专科转诊] [无需] │ 未选中: 白底+border边框+textSecondary字
|
|
||||||
│ │
|
|
||||||
│ 评语: │
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ (多行输入) │ │ 白底, 无边框
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌ 提交审核 ──────────────────┐ │
|
|
||||||
│ └──────────────────────────────┘ │ doctorBlue底+白字
|
|
||||||
│ │
|
|
||||||
│ (已审核时) │
|
|
||||||
│ ┌ ✅ 审核已完成 ─────────────┐ │ 白底+#D1FAE5边框
|
|
||||||
│ │ 严重程度: 异常 │ │ 标签: textHint
|
|
||||||
│ │ 建议: 复查建议、生活方式 │ │
|
|
||||||
│ │ 评语: ... │ │
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
└──────────────────────────────────┘
|
|
||||||
大背景: pageGrey (#F5F5F5)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 随访列表 (`doctor_followups_page.dart`)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────┐
|
|
||||||
│ [全部] [待完成] [已完成] [+] │ 筛选: 同报告页
|
|
||||||
│ │ 添加: primary图标
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ 术后一个月复查 [待完成] │ │ 白底卡片
|
|
||||||
│ │ 患者A · 2026-06-18 09:00 │ │ 待完成: warning标签
|
|
||||||
│ │ 备注: ... │ │ 已完成: success标签
|
|
||||||
│ │ [编辑] [完成] [删除] │ │ 按钮: textHint/success/error
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
└──────────────────────────────────┘
|
|
||||||
大背景: pageGrey (#F5F5F5)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. 随访编辑 (`doctor_followup_edit_page.dart`)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────┐
|
|
||||||
│ ← 新建随访 / 编辑随访 │
|
|
||||||
│ │
|
|
||||||
│ 患者 │
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ ▼ 选择患者 │ │ 白底, 下拉选择器
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ 随访标题 │
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ 例:术后一个月复查 │ │ 白底, 无边框
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ 随访时间 │
|
|
||||||
│ ┌──────────────┐ ┌────────────┐ │
|
|
||||||
│ │ 2026-06-18 │ │ 09:00 │ │ 白底卡片, 点击弹出选择器
|
|
||||||
│ └──────────────┘ └────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ 备注 │
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ (多行) │ │ 白底, 无边框
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌ 创建随访 / 保存修改 ────────┐ │
|
|
||||||
│ └──────────────────────────────┘ │ doctorBlue底+白字
|
|
||||||
└──────────────────────────────────┘
|
|
||||||
大背景: pageGrey (#F5F5F5)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. 医生设置 (`doctor_settings_page.dart`)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────┐
|
|
||||||
│ ← 设置 │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ 🔔 推送通知 [开关] │ │ 白底, 圆角14
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ 📄 隐私政策 > │ │
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ 📃 用户协议 > │ │
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ 🗑 删除账号 > │ │ 红色文字
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ 退出登录 │ │ error字+#FECACA边框
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
└──────────────────────────────────┘
|
|
||||||
大背景: pageGrey (#F5F5F5)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. 医生个人信息 (`doctor_profile_page.dart`)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────┐
|
|
||||||
│ ← 个人信息 │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ 姓名 │ │ 白底输入框
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ 职称 │ │ 白底输入框
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ 科室 │ │ 白底输入框
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ 医院 │ │ 白底输入框
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ 保存 │ │ doctorBlue底+白字
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
└──────────────────────────────────┘
|
|
||||||
大背景: pageGrey (#F5F5F5)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. 健康概览趋势页 (`trend_page.dart`)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────┐
|
|
||||||
│ [血压] [心率] [血糖] [血氧] [体重] 指标chip切换
|
|
||||||
│ 🔴 🟡 🔵 🟢 🟣 颜色来自硬编码
|
|
||||||
│ │
|
|
||||||
│ ┌ 趋势图 (fl_chart) ──────────┐ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ 📈 折线图 30天趋势 │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ 历史记录 (N条) │
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ 🫀 6月12日 18:30 │ │ 白底卡片(左滑删除)
|
|
||||||
│ │ 122/80 mmHg │ │ 异常值: error红
|
|
||||||
│ └──────────────────────────────┘ │ 正常值: #1A1A1A
|
|
||||||
│ ┌──────────────────────────────┐ │ 删除背景: error红
|
|
||||||
│ │ 💓 6月12日 08:00 │ │
|
|
||||||
│ │ 72 次/分 │ │
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
└──────────────────────────────────┘
|
|
||||||
大背景: bgGradient渐变
|
|
||||||
```
|
|
||||||
|
|
||||||
| 指标 | 色号 | 色块 |
|
|
||||||
|------|------|------|
|
|
||||||
| 血压 | `#EF4444` (error) | 🔴 |
|
|
||||||
| 心率 | `#F59E0B` (warning) | 🟡 |
|
|
||||||
| 血糖 | `#4F6EF7` | 🔵 |
|
|
||||||
| 血氧 | `#20C997` | 🟢 |
|
|
||||||
| 体重 | `#845EF7` | 🟣 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 16. 蓝牙设备管理 (`device_management_page.dart`)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────┐
|
|
||||||
│ 蓝牙设备 [+] │ AppBar: #FFFFFF
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ [蓝牙] 血压计 🟢 已连接 │ │ 已连接: success绿边框+绿点
|
|
||||||
│ │ CB:1C:DF:93:7F:7A │ │ 未连接: border灰边框+灰点
|
|
||||||
│ │ 上次同步: 6/12 18:30 │ │ 点击重连(未连接时)
|
|
||||||
│ └──────────────────────────────┘ │ 头像底: successLight/ #F5F5F5
|
|
||||||
│ │
|
|
||||||
│ ┌ 最近测量 ──────────────────┐ │
|
|
||||||
│ │ 122/80 mmHg │ │ 白底卡片
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ 解绑设备 │ │ error字+#FECACA边框
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌ 使用说明 ──────────────────┐ │
|
|
||||||
│ │ 1. 血压计装好电池... │ │
|
|
||||||
│ │ 2. 按开始键测量... │ │
|
|
||||||
│ │ 3. 点击设备栏自动连接... │ │
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
└──────────────────────────────────┘
|
|
||||||
大背景: pageGrey (#F5F5F5)
|
|
||||||
|
|
||||||
空状态:
|
|
||||||
[蓝牙图标] 灰色 #BBBBBB
|
|
||||||
暂无设备
|
|
||||||
点击右上角 + 添加血压计
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 17. 蓝牙扫描/连接 (`device_scan_page.dart`)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────┐
|
|
||||||
│ ← BLESmart_xxx │
|
|
||||||
│ │
|
|
||||||
│ 扫描中/已发现 N 台设备 │ 状态栏: iconBg底
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ [蓝牙] BLESmart... [连接] │ │ 白底卡片, #EEEEEE边框
|
|
||||||
│ │ CB:1C:DF... · 信号强 │ │ 连接按钮: primary底+白字
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ [重新扫描] │ primary字+border边框
|
|
||||||
│ │
|
|
||||||
│ (无设备时居中显示) │
|
|
||||||
│ [脉动动画] │ #F0F0FF底, primary图标
|
|
||||||
│ 正在搜索蓝牙设备... │ textHint
|
|
||||||
│ 请确保血压计处于通信模式 │ #CCCCCC
|
|
||||||
│ │
|
|
||||||
│ (已连接时居中显示) │
|
|
||||||
│ [✓ 图标] │ #D1FAE5底/#F0F0FF底
|
|
||||||
│ 测量完成 / 设备已连接 │ textPrimary
|
|
||||||
│ 122/80 mmHg │ textPrimary 大字
|
|
||||||
│ 脉搏 78 bpm │ #EFF6FF底, primary字
|
|
||||||
│ 数据已自动同步 │ textHint
|
|
||||||
└──────────────────────────────────┘
|
|
||||||
大背景: #FFFFFF
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 18. 健康档案页 (`HealthArchivePage` in remaining_pages.dart)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────┐
|
|
||||||
│ ← 健康档案 │
|
|
||||||
│ │
|
|
||||||
│ 基本信息: 姓名/性别/出生日期 │ 输入框: backgroundSoft
|
|
||||||
│ 既往病史/手术史/过敏/慢病/饮食 │
|
|
||||||
│ │
|
|
||||||
│ [保存] │ primary底+白字
|
|
||||||
└──────────────────────────────────┘
|
|
||||||
大背景: bgGradient
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 19. 复查随访页-患者端 (`FollowUpListPage` in remaining_pages.dart)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────┐
|
|
||||||
│ ← 复查随访 │
|
|
||||||
│ │
|
|
||||||
│ (空状态) │
|
|
||||||
│ [📅图标] 暂无随访安排 │ textHint色
|
|
||||||
│ 医生创建随访后将显示在这里 │
|
|
||||||
│ │
|
|
||||||
│ (有数据) │
|
|
||||||
│ ┌──────────────────────────────┐ │
|
|
||||||
│ │ 随访标题 [即将到来] │ │ 白底卡片
|
|
||||||
│ │ 医生名 · 科室 │ │ 状态: primary/success/error
|
|
||||||
│ │ 时间 │ │
|
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
└──────────────────────────────────┘
|
|
||||||
大背景: bgGradient
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 20. 启动闪屏 (`app.dart` _RootNavigator)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────┐
|
|
||||||
│ │
|
|
||||||
│ │
|
|
||||||
│ ❤ 心形图标 │ primary色
|
|
||||||
│ │
|
|
||||||
│ 健康管家 │ textPrimary
|
|
||||||
│ │
|
|
||||||
│ │
|
|
||||||
└──────────────────────────────────┘
|
|
||||||
大背景: #FFFFFF
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 颜色使用频率排名
|
|
||||||
|
|
||||||
| 颜色 | 使用文件数 | 最常用途 |
|
|
||||||
|------|-----------|---------|
|
|
||||||
| `textPrimary` #1F2937 | 28 | 全项目主文字 |
|
|
||||||
| `textHint` #9CA3AF | 28 | 全项目提示文字 |
|
|
||||||
| `primary` #8B5CF6 | 26 | 用户端主色/按钮/选中 |
|
|
||||||
| `error` #EF4444 | 21 | 错误/删除 |
|
|
||||||
| `textSecondary` #6B7280 | 19 | 副文字 |
|
|
||||||
| `border` #E5E7EB | 13 | 通用边框 |
|
|
||||||
| `cardInner` #F5F2FF | 11 | 卡片内底 |
|
|
||||||
| `success` #10B981 | 10 | 成功/绿色状态 |
|
|
||||||
| `warning` #F59E0B | 7 | 警告/黄色状态 |
|
|
||||||
| `pageGrey` #F5F5F5 | 7 | 医生端页面底 |
|
|
||||||
| `doctorBlue` #0891B2 | 7 | 医生端主色 |
|
|
||||||
| `avatarBg` #F0F0FF | 6 | 头像底 |
|
|
||||||
---
|
|
||||||
|
|
||||||
## 21. 图标与底色对照表
|
|
||||||
|
|
||||||
### 全局图标
|
|
||||||
|
|
||||||
| 位置 | 图标 | 图标色 | 底色 | 底色号 |
|
|
||||||
|------|------|--------|------|--------|
|
|
||||||
| 登录页心形 | favorite | `_accent` (用户紫/医生蓝) | 无 | — |
|
|
||||||
| 登录页成功 | check_circle | `#059669` | `#D1FAE5` | successLight |
|
|
||||||
| 抽屉菜单项 | 各种 | `primary` | 无 | — |
|
|
||||||
| 抽屉蓝牙入口 | bluetooth_rounded | `primary` | 无 | — |
|
|
||||||
| 空状态图标 | inbox_outlined | `textHint` | 无 | — |
|
|
||||||
| 空状态图标 | bluetooth_disabled | `#BBBBBB` | `#F0F0F0` | — |
|
|
||||||
| 药瓶确认 | check_circle_outline | `textHint` | 无 | — |
|
|
||||||
| 添加按钮(FAB) | add | `#FFFFFF` | `primary` | — |
|
|
||||||
| 侧边栏AI图标 | smart_toy | `primaryLight` | 无 | — |
|
|
||||||
| 侧边栏头像 | person | `textHint` | `#FFFFFF` | — |
|
|
||||||
| AI消息头像 | health_and_safety | `primary` | 无 | — |
|
|
||||||
|
|
||||||
### 医生端
|
|
||||||
|
|
||||||
| 位置 | 图标 | 图标色 | 底色 | 底色号 |
|
|
||||||
|------|------|--------|------|--------|
|
|
||||||
| 抽屉头部 | person | `#FFFFFF` | `#FFFFFF` 24%透明 | — |
|
|
||||||
| 抽屉菜单(选中) | 各种 | `doctorBlue` | `doctorBlue` 8%透明 | — |
|
|
||||||
| 抽屉菜单(未选中) | 各种 | `textHint` | 无 | — |
|
|
||||||
| 统计卡片-患者 | people | `blueMeasure` | `blueMeasure` 10%透明 | — |
|
|
||||||
| 统计卡片-问诊 | chat | `success` | `success` 10%透明 | — |
|
|
||||||
| 统计卡片-报告 | description | `warning` | `warning` 10%透明 | — |
|
|
||||||
| 统计卡片-随访 | event_note | `error` | `error` 10%透明 | — |
|
|
||||||
| 待办图标 | chat_outlined 等 | `success`/`warning`/`error` | 无 | — |
|
|
||||||
| 完善信息提示 | info_outline | `doctorBlue` | `#E0F2FE` | — |
|
|
||||||
| 患者列表头像 | person/male/female | `primary` | `avatarBg` #F0F0FF | — |
|
|
||||||
| 患者详情头像 | person文字 | `primary` | `avatarBg` #F0F0FF | — |
|
|
||||||
| 问诊列表头像 | person文字 | `primary` | `avatarBg` #F0F0FF | — |
|
|
||||||
| 报告列表图标 | description | `primary` | 无 | — |
|
|
||||||
| 报告详情头像 | person文字 | `primary` | `avatarBg` #F0F0FF | — |
|
|
||||||
| 审核完成图标 | check_circle | `success` | 无 | — |
|
|
||||||
| AI预分析竖条 | (Container) | — | `#6366F1` | — |
|
|
||||||
| 设置列表图标 | notifications/description/article/delete | `textPrimary` (删除为红) | 无 | — |
|
|
||||||
| 返回箭头 | arrow_back | `textPrimary` | 无 | — |
|
|
||||||
| 抽屉汉堡菜单 | menu | `textPrimary` | 无 | — |
|
|
||||||
|
|
||||||
### 蓝牙设备页
|
|
||||||
|
|
||||||
| 位置 | 图标 | 图标色 | 底色 | 底色号 |
|
|
||||||
|------|------|--------|------|--------|
|
|
||||||
| 设备卡片(已连接) | bluetooth | `success` | `successLight` | — |
|
|
||||||
| 设备卡片(未连接) | bluetooth | `#BBBBBB` | `#F5F5F5` | — |
|
|
||||||
| 连接状态点(已连接) | (Container圆) | — | `success`+shadow | — |
|
|
||||||
| 连接状态点(未连接) | (Container圆) | — | `#BBBBBB` | — |
|
|
||||||
| 扫描中动画 | bluetooth_searching | `primary` | `#F0F0FF` | — |
|
|
||||||
| 扫描脉动环 | (Container) | — | `primary` 8%透明 | — |
|
|
||||||
| 已连接图标 | bluetooth_connected | `primary` | `#F0F0FF` | — |
|
|
||||||
| 测量完成图标 | check_rounded | `success` | `successLight` | — |
|
|
||||||
|
|
||||||
### 健康概览趋势页
|
|
||||||
|
|
||||||
| 位置 | 图标/色 | 图标色 | 底色 |
|
|
||||||
|------|---------|--------|------|
|
|
||||||
| 血压chip | favorite_rounded (🫀) | `error` (#EF4444) | `error` 20%透明 |
|
|
||||||
| 心率chip | monitor_heart (💓) | `warning` (#F59E0B) | `warning` 20%透明 |
|
|
||||||
| 血糖chip | bloodtype (🩸) | `#4F6EF7` | `#4F6EF7` 20%透明 |
|
|
||||||
| 血氧chip | air (🫁) | `#20C997` | `#20C997` 20%透明 |
|
|
||||||
| 体重chip | monitor_weight (⚖️) | `#845EF7` | `#845EF7` 20%透明 |
|
|
||||||
| 历史记录项图标 | emoji文字 | `primary` | `各色20%透明` |
|
|
||||||
| 删除背景 | delete_outline | `#FFFFFF` | `error` |
|
|
||||||
|
|
||||||
### 饮食页
|
|
||||||
|
|
||||||
| 位置 | 图标 | 图标色 | 底色 |
|
|
||||||
|------|------|--------|------|
|
|
||||||
| 添加食物 | add_circle_outline | `_dietAccent` (#F0A060) | 无 |
|
|
||||||
| AI按钮 | auto_awesome | `_dietAccent` (#F0A060) | 无 |
|
|
||||||
| 食物列表项 | — | — | `#FFFBF5` |
|
|
||||||
|
|
||||||
### 套餐卡片
|
|
||||||
|
|
||||||
| 位置 | 图标 | 图标色 | 底色 |
|
|
||||||
|------|------|--------|------|
|
|
||||||
| 功能列表项图标 | — | `#F5A623` | `#FFF8EE` |
|
|
||||||
| 套餐头 | workspace_premium | `#FFFFFF` | `primaryGradient` |
|
|
||||||
| 前进箭头 | chevron_right | `textHint` | 无 |
|
|
||||||
|
|
||||||
### 问诊聊天
|
|
||||||
|
|
||||||
| 位置 | 图标 | 图标色 | 底色 |
|
|
||||||
|------|------|--------|------|
|
|
||||||
| AI头像 | smart_toy | `primaryLight` | — |
|
|
||||||
| 在线指示点 | (Container圆) | — | `#F0F2FF` |
|
|
||||||
| 健康图标 | — | `blueMeasure` | `#DBEAFE` |
|
|
||||||
| 运动图标 | — | `warning` | `warningLight` |
|
|
||||||
| 用药图标 | — | `#EC4899` | `#FCE7F3` |
|
|
||||||
| 建议灯泡 | lightbulb_outline | `primary` | 无 |
|
|
||||||
| Agent卡片图标 | 各种 | 各Agent accent色 | 各Agent iconBg色 |
|
|
||||||
|
|
||||||
### 设置/通知页
|
|
||||||
|
|
||||||
| 位置 | 图标 | 图标色 | 底色 |
|
|
||||||
|------|------|--------|------|
|
|
||||||
| 通知项图标 | 各种 | `iconColor` (#9B8AF7) | `iconBg` (#E8E0FA) |
|
|
||||||
|
|
||||||
### 统一规则
|
|
||||||
|
|
||||||
| 场景 | 图标色 | 底色 |
|
|
||||||
|------|--------|------|
|
|
||||||
| **可点击图标** | `primary` | 无/透明 |
|
|
||||||
| **不可点击图标** | `textHint` | 无/透明 |
|
|
||||||
| **成功/正常状态** | `success` (#10B981) | `successLight` (#D1FAE5) |
|
|
||||||
| **警告/待处理** | `warning` (#F59E0B) | `warningLight` (#FEF3C7) |
|
|
||||||
| **错误/删除** | `error` (#EF4444) | `errorLight` (#FEE2E2) |
|
|
||||||
| **医生端强调** | `doctorBlue` (#0891B2) | `doctorBlue` 淡底 |
|
|
||||||
| **禁用/空状态** | `#BBBBBB` | `#F0F0F0` |
|
|
||||||
| **头像/人员底** | `primary` | `avatarBg` (#F0F0FF) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
> 文档中标注的色号均为定义在 AppColors 中的常量名。要修改任何地方的颜色,告诉我"XX页面的XX区域换成XX色"即可。
|
|
||||||
@@ -1,313 +0,0 @@
|
|||||||
# 医生端合并至App — 详细设计文档(已确认版)
|
|
||||||
|
|
||||||
> 目标:将 `doctor_web` (React SPA) 的功能完整迁移到 `health_app` (Flutter),通过注册时选择"用户/医生"身份实现双端合一。
|
|
||||||
>
|
|
||||||
> 本文档所有决策均已经过逐一讨论和确认。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、已确认设计决策总表
|
|
||||||
|
|
||||||
| # | 决策项 | 结论 |
|
|
||||||
|---|--------|------|
|
|
||||||
| 1 | 角色体系 | **方案A** — User表加Role字段 + 新建DoctorProfile子表,废弃旧Doctor表 |
|
|
||||||
| 2 | 手机号角色 | **一个手机号只能一个角色** |
|
|
||||||
| 3 | 种子医生 | **全部删除**(王建国/李芳/张明),重新注册测试 |
|
|
||||||
| 4 | 审核码 | 硬编码 `6666`,注册医生时校验 |
|
|
||||||
| 5 | 代码位置 | 放在 `health_app` 同一项目,同一安装包 |
|
|
||||||
| 6 | 开发节奏 | **一次性全量做完**,不分期 |
|
|
||||||
| 7 | 患者端 | 开发期间保持可用,患者问诊聊天保留 |
|
|
||||||
| 8 | 医生主色调 | **医疗蓝 `#0891B2`** + 白色 |
|
|
||||||
| 9 | 深色模式 | 不做,仅浅色 |
|
|
||||||
| 10 | 导航结构 | **侧边抽屉**,与患者端一致 |
|
|
||||||
| 11 | 报告文件预览 | 图片直接内嵌查看,PDF调用系统查看器 |
|
|
||||||
| 12 | 医生头像 | 支持从相册上传 |
|
|
||||||
| 13 | 所有医生端点 | 加JWT鉴权 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、注册流程
|
|
||||||
|
|
||||||
```
|
|
||||||
注册页
|
|
||||||
├─ ① 选择身份:[用户] [医生] ← 第一步,先选
|
|
||||||
├─ ② 输入手机号 → 获取短信验证码 ← 通用
|
|
||||||
├─ ③ 输入验证码 ← 通用
|
|
||||||
├─ ④ (仅医生) 输入审核码 [6666] ← 选医生才弹出
|
|
||||||
├─ ⑤ 勾选同意《隐私政策》《用户协议》 ← 可点击查看详情页
|
|
||||||
└─ ⑥ 点击注册
|
|
||||||
```
|
|
||||||
|
|
||||||
- 文案统一用"用户"而非"患者"
|
|
||||||
- 医生注册时不填姓名/职称/科室/医院,登录后在设置里补全
|
|
||||||
- 登录时不需要选身份,自动进入注册时选择的身份端
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、多端登录体系(新增)
|
|
||||||
|
|
||||||
### 3.1 账号核心原则
|
|
||||||
|
|
||||||
- **手机号 = 主标识**,微信/Apple 都是绑定到手机号上
|
|
||||||
- **注册时定身份,永久不可改**(除非删除账号重来)
|
|
||||||
- 一个手机号只能绑定一个微信 + 一个 Apple ID
|
|
||||||
- 登录方式可选(手机号/微信/Apple),但身份由注册时的选择决定
|
|
||||||
|
|
||||||
### 3.2 注册流程(手机号注册 + 立刻绑定)
|
|
||||||
|
|
||||||
```
|
|
||||||
注册页
|
|
||||||
├─ ① 选择身份:[用户] [医生]
|
|
||||||
├─ ② 输入手机号 → 短信验证码
|
|
||||||
├─ ③ (仅医生) 审核码 6666
|
|
||||||
├─ ④ 勾选同意《隐私政策》《用户协议》
|
|
||||||
├─ ⑤ 点击注册
|
|
||||||
└─ ⑥ 注册成功后弹绑定页:
|
|
||||||
├─ [绑定微信]
|
|
||||||
├─ [绑定Apple]
|
|
||||||
└─ [跳过,以后再绑]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.3 后续登录方式
|
|
||||||
|
|
||||||
注册后,登录页直接显示三个按钮:
|
|
||||||
|
|
||||||
```
|
|
||||||
登录页
|
|
||||||
├─ [微信登录] → 查手机号 → 自动进入对应身份端
|
|
||||||
├─ [Apple登录] → 查手机号 → 自动进入对应身份端
|
|
||||||
└─ [手机号登录] → 短信验证 → 自动进入对应身份端
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.4 删除账号(≠ 退出登录)
|
|
||||||
|
|
||||||
- 在设置页独立入口,不与退出登录混淆
|
|
||||||
- 确认后彻底删除:用户数据、健康记录、问诊记录、绑定关系
|
|
||||||
- Apple 用户额外调用 Apple revocation API
|
|
||||||
- 删除后手机号释放,可重新注册(可换身份)
|
|
||||||
|
|
||||||
### 3.5 微信/Apple 平台准备
|
|
||||||
|
|
||||||
| 微信开放平台 | 需要新的 AppID,绑定 `com.datalumina.YYA` |
|
|
||||||
|-------------|------------------------------------------|
|
|
||||||
| Apple Sign In | Apple Developer 账号开启 Capability |
|
|
||||||
| Flutter包 | 微信用 `fluwx`,Apple 用 `sign_in_with_apple` |
|
|
||||||
|
|
||||||
> AppID 暂时空着,等申请下来再填。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、登录后路由(不变)
|
|
||||||
|
|
||||||
```
|
|
||||||
登录成功
|
|
||||||
├─ Role = 'User' → 现有患者端(AI对话首页 + 侧边抽屉)
|
|
||||||
└─ Role = 'Doctor' → 医生端(工作台Dashboard + 侧边抽屉)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、医生职责与功能
|
|
||||||
|
|
||||||
### 4.1 患者范围(初期)
|
|
||||||
|
|
||||||
所有注册用户都是所有医生的患者。后期VIP体系再做患者分配。
|
|
||||||
|
|
||||||
### 4.2 工作台 Dashboard
|
|
||||||
|
|
||||||
- 统计卡片:患者总数 / 进行中问诊 / 待审核报告 / 今日随访
|
|
||||||
- 待办事项列表(全部可点击快捷进入):
|
|
||||||
- 未回复问诊消息数
|
|
||||||
- 待审核报告数
|
|
||||||
- 今日随访对象
|
|
||||||
- 每个待办项都是快捷入口,点击直接进入对应工作页
|
|
||||||
|
|
||||||
### 4.3 患者管理
|
|
||||||
|
|
||||||
- 搜索框(姓名/手机号)+ 分页加载(上拉更多)
|
|
||||||
- 点击患者进入详情页
|
|
||||||
- **详情页结构**:
|
|
||||||
- 默认展示:基本信息(姓名/性别/年龄/电话)、健康档案(病史/手术史/过敏/慢病/家族史)、当前用药
|
|
||||||
- 按钮"更多信息" → 展开:趋势图/饮食记录/运动计划/报告列表/随访记录
|
|
||||||
|
|
||||||
### 4.4 问诊聊天
|
|
||||||
|
|
||||||
- 保持现有流程:患者发起 → AI先接待 → 医生后续介入
|
|
||||||
- AI行为暂不改动(后续优化)
|
|
||||||
- 电话功能:留UI入口,功能暂不做
|
|
||||||
- 问诊列表显示:患者头像+姓名、最后消息预览、状态、时间
|
|
||||||
- 聊天页面复用现有 `DoctorChatPage`,但需要新建医生端Provider(使用 `/api/doctor/consultations/*` 端点,senderType='Doctor')
|
|
||||||
- SignalR 实时通信保留(已有实现)
|
|
||||||
|
|
||||||
### 4.5 报告审核
|
|
||||||
|
|
||||||
- 全功能迁移,包含:
|
|
||||||
1. AI预分析摘要
|
|
||||||
2. 指标表格(正常/异常颜色标记)
|
|
||||||
3. 严重程度4级评定(正常/异常/严重/危急)
|
|
||||||
4. 建议模板多选(药量调整/复诊建议/生活建议/其他)
|
|
||||||
5. 自定义评语(可选)
|
|
||||||
6. 原始报告查看(图片内嵌/PDF系统查看器)
|
|
||||||
7. 提交审核
|
|
||||||
- 所有医生都能看到所有患者的报告
|
|
||||||
|
|
||||||
### 4.6 复查随访
|
|
||||||
|
|
||||||
- 医生创建随访计划(标题+时间+备注)
|
|
||||||
- 到期只提醒医生 → 医生手动联系患者
|
|
||||||
- 随访列表 + 新建/编辑/标记完成/删除
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、导航与页面结构
|
|
||||||
|
|
||||||
### 5.1 医生端侧边抽屉
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────┐
|
|
||||||
│ [头像] 在线/离线 🔵 │ ← 点击头像→个人信息编辑页
|
|
||||||
│ 姓名 职称 科室 │ ← 在线状态可切换
|
|
||||||
│ │
|
|
||||||
│ ━━━━━━━━━━━━━━━━━━━ │
|
|
||||||
│ 📊 工作台 │ ← 默认首页
|
|
||||||
│ 👥 患者管理 │
|
|
||||||
│ 💬 问诊列表 │
|
|
||||||
│ 📋 报告审核 │
|
|
||||||
│ 📅 复查随访 │
|
|
||||||
│ ⚙️ 设置 │
|
|
||||||
│ ━━━━━━━━━━━━━━━━━━━ │
|
|
||||||
│ 🚪 退出登录 │
|
|
||||||
└─────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
- 点击抽屉项 → 主页面切换到对应页面
|
|
||||||
- 在线/离线状态在抽屉顶部头像区快速切换
|
|
||||||
|
|
||||||
### 5.2 "设置"页面内容
|
|
||||||
|
|
||||||
- 推送通知开关
|
|
||||||
- 隐私政策查看
|
|
||||||
- 用户协议查看
|
|
||||||
- 退出登录
|
|
||||||
|
|
||||||
### 5.3 "个人信息"页面(点击头像进入)
|
|
||||||
|
|
||||||
- 姓名 / 职称 / 科室 / 医院
|
|
||||||
- 头像上传(相册选图)
|
|
||||||
|
|
||||||
### 5.4 医生首次登录
|
|
||||||
|
|
||||||
直接进工作台,但顶部显示提示条"请完善个人信息",点击跳转到个人信息编辑页。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、路由表
|
|
||||||
|
|
||||||
| 路由名 | 页面 | 用户端 | 医生端 |
|
|
||||||
|--------|------|--------|--------|
|
|
||||||
| `login` | 登录/注册 | ✅ | ✅ |
|
|
||||||
| `home` | 首页(AI对话/工作台) | ✅ | ✅ (根据Role) |
|
|
||||||
| `doctorPatients` | 患者列表 | — | ✅ |
|
|
||||||
| `doctorPatientDetail` | 患者详情 | — | ✅ |
|
|
||||||
| `doctorChat` | 问诊聊天 | ✅(复用) | ✅(新Provider) |
|
|
||||||
| `doctorConsultations` | 问诊列表 | — | ✅ |
|
|
||||||
| `doctorReports` | 报告列表 | — | ✅ |
|
|
||||||
| `doctorReportDetail` | 报告审核 | — | ✅ |
|
|
||||||
| `doctorFollowUps` | 随访列表 | — | ✅ |
|
|
||||||
| `doctorFollowUpEdit` | 随访编辑 | — | ✅ |
|
|
||||||
| `doctorSettings` | 医生设置 | — | ✅ |
|
|
||||||
| `doctorProfile` | 个人信息编辑 | — | ✅ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 八、数据库变更
|
|
||||||
|
|
||||||
### 7.1 Users 表加字段
|
|
||||||
|
|
||||||
```sql
|
|
||||||
ALTER TABLE "Users" ADD COLUMN "Role" TEXT NOT NULL DEFAULT 'User'; -- 'User' | 'Doctor'
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 新建 DoctorProfiles 表
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE "DoctorProfiles" (
|
|
||||||
"Id" UUID PRIMARY KEY,
|
|
||||||
"UserId" UUID NOT NULL REFERENCES "Users"("Id"),
|
|
||||||
"Name" TEXT,
|
|
||||||
"Title" TEXT,
|
|
||||||
"Department" TEXT,
|
|
||||||
"Hospital" TEXT,
|
|
||||||
"AvatarUrl" TEXT,
|
|
||||||
"IsOnline" BOOLEAN DEFAULT false,
|
|
||||||
"IsActive" BOOLEAN DEFAULT true,
|
|
||||||
"CreatedAt" TIMESTAMP DEFAULT now(),
|
|
||||||
"UpdatedAt" TIMESTAMP DEFAULT now()
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.3 清理
|
|
||||||
|
|
||||||
- 删除旧 `Doctors` 表种子数据
|
|
||||||
- 迁移 Consultation 表的 DoctorId 外键关联
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 九、后端变更
|
|
||||||
|
|
||||||
### 8.1 JWT 增加 Role Claim
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
new Claim("Role", user.Role)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.2 所有 `/api/doctor/*` 端点
|
|
||||||
|
|
||||||
- 添加 `.RequireAuthorization()`
|
|
||||||
- 添加 Role 校验(Role = "Doctor")
|
|
||||||
- 从 JWT 获取医生信息替代硬编码"王建国"
|
|
||||||
|
|
||||||
### 8.3 注册接口改造
|
|
||||||
|
|
||||||
- 支持 Role 参数
|
|
||||||
- 医生注册时校验审核码 6666
|
|
||||||
- 医生注册创建 DoctorProfile 空记录
|
|
||||||
|
|
||||||
### 8.4 审核码配置
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// appsettings.json 或环境变量
|
|
||||||
const string DoctorInviteCode = "6666";
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 十、UI设计规范
|
|
||||||
|
|
||||||
### 9.1 颜色
|
|
||||||
|
|
||||||
| 用途 | 颜色 |
|
|
||||||
|------|------|
|
|
||||||
| 主色 | `#0891B2` (医疗蓝) |
|
|
||||||
| 背景 | `#FFFFFF` / `#F5F5F5` |
|
|
||||||
| 卡片 | 白色 + 浅阴影 |
|
|
||||||
| 成功/已连接 | `#10B981` |
|
|
||||||
| 状态标签 | 待处理(橙) / 已处理(绿) / 异常(红) |
|
|
||||||
| 文字 | `#1A1A1A` / `#999999` |
|
|
||||||
|
|
||||||
### 9.2 与患者端的视觉区分
|
|
||||||
|
|
||||||
- 患者端:紫色渐变 `#5B8DEF`
|
|
||||||
- 医生端:医疗蓝 `#0891B2`
|
|
||||||
- 抽屉结构和布局保持一致
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 十一、实施参考
|
|
||||||
|
|
||||||
- 新增约 12 条路由
|
|
||||||
- 新增约 10 个页面组件
|
|
||||||
- 新增约 4 个 Provider
|
|
||||||
- 修改约 15 个后端端点
|
|
||||||
- 修改注册/登录流程
|
|
||||||
- 总计预估 6-8 天
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
# 欧姆龙 J735 蓝牙血压计 — 实施计划
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、当前状态
|
|
||||||
|
|
||||||
- 设备已购:欧姆龙 J735
|
|
||||||
- 前端占位:`DeviceManagementPage` 目前是空壳(显示"暂无绑定设备"),路由 `devices` 已注册,入口在 ProfilePage → 设备管理
|
|
||||||
- 项目风格:淡紫清新风(`AppTheme`),Material 3 + shadcn_ui + Riverpod
|
|
||||||
- 本地存储:SQLite(`LocalDatabase`,kv_store 表)
|
|
||||||
- 后端:已有 `POST /api/health-records` 支持 BloodPressure 类型,Source 枚举需加 `Device`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、实施范围
|
|
||||||
|
|
||||||
### 2.1 新增依赖
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# pubspec.yaml
|
|
||||||
flutter_blue_plus: ^1.34.0 # BLE 核心
|
|
||||||
permission_handler: ^11.3.0 # 蓝牙权限
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 新增文件(6 个)
|
|
||||||
|
|
||||||
```
|
|
||||||
health_app/lib/
|
|
||||||
├── services/
|
|
||||||
│ └── omron_ble_service.dart # BLE 连接 + SFLOAT 协议解析
|
|
||||||
├── models/
|
|
||||||
│ └── bp_reading.dart # 血压数据模型
|
|
||||||
├── providers/
|
|
||||||
│ └── omron_device_provider.dart # 设备绑定状态 + 实时读数
|
|
||||||
├── pages/
|
|
||||||
│ └── device/
|
|
||||||
│ ├── device_scan_page.dart # 扫描设备页
|
|
||||||
│ └── device_bind_page.dart # 已绑定设备管理页(替换空壳)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 修改文件(3 个)
|
|
||||||
|
|
||||||
| 文件 | 改动内容 |
|
|
||||||
|------|---------|
|
|
||||||
| `pages/remaining_pages.dart` | 替换 `DeviceManagementPage` 空壳为 `DeviceBindPage` |
|
|
||||||
| `core/app_router.dart` | `devices` 路由指向新页面 |
|
|
||||||
| `providers/data_providers.dart` | 新增 `omronBleServiceProvider`、`bpReadingsProvider` |
|
|
||||||
| 后端 `health_enums.cs` | `HealthRecordSource` 加 `Device` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、分步实施(3 天)
|
|
||||||
|
|
||||||
### Day 1 — BLE 服务层
|
|
||||||
|
|
||||||
**目标**:手机能搜到 J735、连接上、收到蓝牙数据并正确解析
|
|
||||||
|
|
||||||
1. 添加 `flutter_blue_plus` + `permission_handler` 依赖
|
|
||||||
2. 配置 Android 蓝牙权限(`AndroidManifest.xml`)+ iOS 权限(`Info.plist`)
|
|
||||||
3. 实现 `BpReading` 数据模型(systolic/diastolic/pulse/timestamp/status)
|
|
||||||
4. 实现 `OmronBleService`:
|
|
||||||
- `scan()` — 过滤蓝牙名含 `OMRON`/`HEM`/`BLEsmart` 且有 `0x1810` 服务的设备
|
|
||||||
- `connect()` — 连接 → discoverServices → 订阅 `0x2A35` Indicate
|
|
||||||
- `_parseReading()` — SFLOAT 解码 + Flags 解析(kPa 转换、体动、心律等)
|
|
||||||
- `_syncTime()` — 写入 `0x2A08` 同步时间
|
|
||||||
- `disconnect()` / `dispose()`
|
|
||||||
5. 用 nRF Connect 抓包验证原始 HEX → SFLOAT 解码结果与屏幕一致
|
|
||||||
|
|
||||||
**产出**:`OmronBleService` 可独立运行,单元测试验证 SFLOAT 解码
|
|
||||||
|
|
||||||
### Day 2 — UI 页面
|
|
||||||
|
|
||||||
**目标**:实现扫描绑定页 + 已绑定管理页,风格对齐项目淡紫主题
|
|
||||||
|
|
||||||
#### 扫描绑定页 (`DeviceScanPage`)
|
|
||||||
- AppBar 标题"添加血压计",返回按钮
|
|
||||||
- 顶部:扫描状态指示(转圈 + "正在扫描..." / "扫描完成,发现 N 台设备")
|
|
||||||
- 空状态:未发现设备时显示使用说明卡片(装电池 → 长按蓝牙键 → 手机靠近 → 检查权限)
|
|
||||||
- 设备列表:每行显示 BLE 设备名 + 信号强度 + MAC + "连接"按钮
|
|
||||||
- 连接中:按钮变 loading
|
|
||||||
- 连接成功:SnackBar "已连接 OMRON xxx",返回上一页并传递设备信息
|
|
||||||
- 底部:重新扫描按钮
|
|
||||||
|
|
||||||
#### 已绑定管理页 (`DeviceBindPage`,替换空壳)
|
|
||||||
- **未绑定状态**:大蓝牙图标 + "未绑定设备" + 说明文字 + "添加设备"按钮,点击跳扫描页
|
|
||||||
- **已绑定状态**:白底圆角卡片
|
|
||||||
- 设备图标(紫色渐变圆)+ 设备名
|
|
||||||
- MAC 地址(灰色小字)
|
|
||||||
- 上次同步时间
|
|
||||||
- "解绑设备"按钮(红色文字)
|
|
||||||
|
|
||||||
**风格要点**(保证不出现"全黑"):
|
|
||||||
- 背景色:`AppTheme.bg`(淡紫白 `#FAF9FF`)
|
|
||||||
- 卡片:白色 `Colors.white`,圆角 `AppTheme.rLg`(20),`AppTheme.shadowCard` 阴影
|
|
||||||
- 按钮:紫色实心(`AppTheme.primary`)+ 圆角 `AppTheme.rPill`
|
|
||||||
- 空状态图标:`Colors.grey[300]`,文字 `AppTheme.textHint`
|
|
||||||
- 使用 shadcn_ui 图标(`LucideIcons`)
|
|
||||||
- 与 SettingsPage、ProfilePage 保持一致的 AppBar 风格
|
|
||||||
|
|
||||||
**产出**:两个 UI 页面,风格与项目统一,从 ProfilePage → 设备管理可进入
|
|
||||||
|
|
||||||
### Day 3 — 数据同步 + 联调
|
|
||||||
|
|
||||||
1. **Provider 层**:`omronDeviceProvider` 管理绑定状态(MAC/name/lastSync 存入 SQLite)
|
|
||||||
2. **自动同步**:收到 BLE 读数后自动 `POST /api/health-records`
|
|
||||||
```dart
|
|
||||||
{
|
|
||||||
'type': 'BloodPressure',
|
|
||||||
'systolic': reading.systolic,
|
|
||||||
'diastolic': reading.diastolic,
|
|
||||||
'source': 'Device',
|
|
||||||
'unit': 'mmHg',
|
|
||||||
'recordedAt': reading.timestamp.toUtc().toIso8601String(),
|
|
||||||
}
|
|
||||||
```
|
|
||||||
3. **去重逻辑**:同一时间戳(精确到秒)+ 同一值 → 跳过
|
|
||||||
4. **健康概览刷新**:同步后 `ref.invalidate(latestHealthProvider)`
|
|
||||||
5. **真机 + J735 联调**:
|
|
||||||
- 扫描 → 连接 → 测量 → 数据上传 → 健康概览显示新数据
|
|
||||||
- 断连 → 重新打开 App → 再次连接
|
|
||||||
- iOS 和 Android 双平台测试
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、交互流程
|
|
||||||
|
|
||||||
```
|
|
||||||
用户操作 App 行为
|
|
||||||
──────── ────────
|
|
||||||
设置 → 蓝牙血压计 打开 DeviceBindPage(已绑定/未绑定)
|
|
||||||
│
|
|
||||||
├─ 未绑定
|
|
||||||
│ └─ 点"添加设备" 打开 DeviceScanPage
|
|
||||||
│ └─ 扫到 J735 点击"连接"
|
|
||||||
│ └─ 连接成功 返回 DeviceBindPage(已绑定状态)
|
|
||||||
│
|
|
||||||
└─ 已绑定
|
|
||||||
└─ 血压计测量 血压计按开始键 → 测量完成
|
|
||||||
│ BLE Indicate 推送数据
|
|
||||||
│ App 收到 120/80
|
|
||||||
│ SnackBar: "已同步: 120/80 mmHg"
|
|
||||||
│ 自动写入 HealthRecords
|
|
||||||
│ 健康概览刷新
|
|
||||||
└─ 查看 点"健康概览"可看到新数据
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、预期效果
|
|
||||||
|
|
||||||
- 从 ProfilePage → 设备管理,不再是全黑空壳,而是漂亮的设备管理页面
|
|
||||||
- 点击"添加设备"打开扫描页,15 秒内搜索到 J735
|
|
||||||
- 点击"连接" 2 秒内完成绑定
|
|
||||||
- J735 测量完毕后,App 自动收到数据(无需手动操作)
|
|
||||||
- 数据自动写入健康记录,健康概览实时刷新
|
|
||||||
- 同一数据不重复录入
|
|
||||||
- 重新打开 App 后已绑定设备状态保留
|
|
||||||
- iOS + Android 双平台均可使用
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、风险点
|
|
||||||
|
|
||||||
| 风险 | 应对 |
|
|
||||||
|------|------|
|
|
||||||
| J735 蓝牙名称与预期不符 | 不硬编码名称,用 `0x1810` 服务 UUID 过滤;首次连接后打印实际名称 |
|
|
||||||
| Android 12+ 蓝牙权限弹窗被拒 | `permission_handler` 检测权限状态,引导用户去设置开启 |
|
|
||||||
| 设备已被系统蓝牙占用 | 扫描页提示"如搜不到请先取消系统蓝牙配对" |
|
|
||||||
| SFLOAT 解码与屏幕值有偏差 | 用 nRF Connect 抓 HEX 对比,误差 > 1 则调整解码 |
|
|
||||||
| iOS BLE 后台断连 | 前台连接同步,完成后断开;不做后台持久连接 |
|
|
||||||
@@ -1,825 +0,0 @@
|
|||||||
# 欧姆龙蓝牙血压计接入方案(iOS + Android 双平台)
|
|
||||||
|
|
||||||
|
|
||||||
## 一、选购建议
|
|
||||||
|
|
||||||
### 选用型号:J735
|
|
||||||
|
|
||||||
| 项目 | 详情 |
|
|
||||||
|------|------|
|
|
||||||
| **型号** | 欧姆龙 J735 |
|
|
||||||
| **价格** | ¥265-389(百亿补贴约 ¥265,日常 ¥350) |
|
|
||||||
| **蓝牙** | ✅ BLE 4.0,标准协议 `0x1810` |
|
|
||||||
| **精度** | ±3mmHg,AAMI 认证 |
|
|
||||||
| **特点** | 双人模式(各60组)、360°袖带、心律检测、体动检测、高血压红屏预警 |
|
|
||||||
| **产地** | 日本原装进口 |
|
|
||||||
| **购买** | 京东/天猫搜索「欧姆龙 J735」 |
|
|
||||||
|
|
||||||
### 为什么不需要官方 SDK
|
|
||||||
|
|
||||||
J735 的蓝牙通信走的是 **Bluetooth SIG 国际标准协议**(Blood Pressure Service `0x1810`),不是欧姆龙私有协议。任何支持 BLE GATT 的蓝牙库都能直连,跟品牌无关。
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────┐
|
|
||||||
│ flutter_blue_plus (开源) │
|
|
||||||
│ ↓ BLE GATT 标准协议 │
|
|
||||||
│ ↓ Service: 0x1810 (Blood Pressure) │
|
|
||||||
│ ↓ Char: 0x2A35 (Measurement) │
|
|
||||||
│ ↓ │
|
|
||||||
│ 欧姆龙 J735 ←── 标准蓝牙芯片 ──→ 任何 BLE 血压计 │
|
|
||||||
└──────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
欧姆龙官方 SDK 做的是封装这些标准协议 + 提供云端API,不是必需的。我们的方案直接走标准 BLE,零依赖、零费用、全平台兼容。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、通信协议(实测可靠)
|
|
||||||
|
|
||||||
欧姆龙蓝牙血压计严格遵循 **Bluetooth SIG Blood Pressure Service 1.1.1**,这是国际标准协议,不是私有协议。
|
|
||||||
|
|
||||||
### 2.1 GATT 服务结构
|
|
||||||
|
|
||||||
```
|
|
||||||
Blood Pressure Service (0x1810)
|
|
||||||
├── Blood Pressure Measurement (0x2A35) [Indicate] ← 测量数据在这里
|
|
||||||
├── Blood Pressure Feature (0x2A49) [Read]
|
|
||||||
├── Intermediate Cuff Pressure (0x2A36) [Notify] ← 充气过程中的实时压力(可选)
|
|
||||||
└── Date Time (0x2A08) [Write] ← 同步时间
|
|
||||||
```
|
|
||||||
|
|
||||||
128-bit UUID 格式(Android/iOS 都用这个):
|
|
||||||
```
|
|
||||||
Blood Pressure Service: 00001810-0000-1000-8000-00805F9B34FB
|
|
||||||
Blood Pressure Measurement: 00002A35-0000-1000-8000-00805F9B34FB
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 测量数据格式(`0x2A35`)
|
|
||||||
|
|
||||||
BLE 推送的原始字节数组,设备每次测量完成后自动 Indicate(需客户端确认):
|
|
||||||
|
|
||||||
```
|
|
||||||
┌────────┬──────────┬───────────┬────────┬──────────┬─────────┬─────────┬──────────────┐
|
|
||||||
│ Byte 0 │ Byte 1-2 │ Byte 3-4 │Byte 5-6│Byte 7-13 │Byte14-15│ Byte 16 │ Byte 17-18 │
|
|
||||||
│ Flags │ 收缩压 │ 舒张压 │ 平均压 │ 时间戳 │ 脉搏 │ 用户ID │ 测量状态 │
|
|
||||||
│ │ SFLOAT │ SFLOAT │ SFLOAT │ (可选) │ (可选) │ (可选) │ (可选) │
|
|
||||||
└────────┴──────────┴───────────┴────────┴──────────┴─────────┴─────────┴──────────────┘
|
|
||||||
|
|
||||||
Flags byte 各位含义:
|
|
||||||
bit 0 = 1 → kPa,0 → mmHg
|
|
||||||
bit 1 = 1 → 含时间戳
|
|
||||||
bit 2 = 1 → 含脉搏
|
|
||||||
bit 3 = 1 → 含用户 ID
|
|
||||||
bit 4 = 1 → 含测量状态(体动/袖带/心律等)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 SFLOAT 解码(IEEE-11073 16-bit)
|
|
||||||
|
|
||||||
```dart
|
|
||||||
// 通用解码函数(iOS/Android 一致)
|
|
||||||
double decodeSFloat(int raw) {
|
|
||||||
int mantissa = raw & 0x0FFF; // 低 12 位
|
|
||||||
if (mantissa & 0x0800 != 0) { // 符号扩展
|
|
||||||
mantissa = mantissa | 0xFFFFF000;
|
|
||||||
}
|
|
||||||
int exponent = (raw >> 12) & 0x0F; // 高 4 位
|
|
||||||
if (exponent & 0x08 != 0) {
|
|
||||||
exponent = exponent | 0xFFFFFFF0;
|
|
||||||
}
|
|
||||||
return mantissa * pow(10, exponent);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
实测例子(HEM-7600T 实测数据):
|
|
||||||
```
|
|
||||||
原始: [0xDE, 0x88, 0xF4, 0x20, 0xF3, 0xFF, 0x07, 0xE4, 0x07, 0x01, 0x13, 0x16, 0x37, 0x00, 0xBC, 0xF2, 0x01, 0x44, 0x01]
|
|
||||||
解析:
|
|
||||||
Flags = 0xDE → mmHg, 含时间戳, 含脉搏, 含用户ID, 含测量状态
|
|
||||||
收缩压 = decodeSFloat(0xF488) = 116.0 mmHg
|
|
||||||
舒张压 = decodeSFloat(0xF320) = 80.0 mmHg
|
|
||||||
脉搏 = decodeSFloat(0xF2BC) = 70 bpm
|
|
||||||
时间 = 2020-01-19 22:55:00
|
|
||||||
状态 = 心律不齐检测到
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.4 官方协议出处
|
|
||||||
|
|
||||||
- Bluetooth SIG Blood Pressure Service 1.1.1: [bluetooth.com/specifications/specs/blood-pressure-service-1-1-1](https://www.bluetooth.com/specifications/specs/blood-pressure-service-1-1-1/)
|
|
||||||
- 欧姆龙 B2B SDK 开发者门户: [public.omronhealthcare.com.cn/b2bsdk](https://public.omronhealthcare.com.cn/b2bsdk/)
|
|
||||||
- 欧姆龙全球开发者 API: [omronhealthcare.com/api](https://omronhealthcare.com/api)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、Flutter 实现(iOS + Android 双平台)
|
|
||||||
|
|
||||||
### 3.1 依赖
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# pubspec.yaml
|
|
||||||
dependencies:
|
|
||||||
flutter_blue_plus: ^1.34.0 # BLE 核心库(iOS + Android)
|
|
||||||
permission_handler: ^11.3.0 # 权限管理
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 目录结构
|
|
||||||
|
|
||||||
```
|
|
||||||
lib/
|
|
||||||
├── services/
|
|
||||||
│ └── omron_ble_service.dart # 欧姆龙 BLE 连接 + 协议解析
|
|
||||||
├── providers/
|
|
||||||
│ └── omron_device_provider.dart
|
|
||||||
├── pages/
|
|
||||||
│ └── device/
|
|
||||||
│ ├── device_scan_page.dart
|
|
||||||
│ └── device_bind_page.dart
|
|
||||||
└── models/
|
|
||||||
└── bp_reading.dart # 血压数据模型
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.3 核心代码
|
|
||||||
|
|
||||||
#### 3.3.1 数据模型
|
|
||||||
|
|
||||||
```dart
|
|
||||||
class BpReading {
|
|
||||||
final int systolic; // 收缩压
|
|
||||||
final int diastolic; // 舒张压
|
|
||||||
final int? pulse; // 脉搏(可选)
|
|
||||||
final DateTime timestamp;
|
|
||||||
final String? userId; // 设备用户ID(J760 双人模式)
|
|
||||||
final bool isKpa; // 是否 kPa 单位
|
|
||||||
final bool hasBodyMovement; // 体动
|
|
||||||
final bool hasIrregularPulse; // 心律不齐
|
|
||||||
|
|
||||||
// ... 构造函数
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.3.2 BLE 服务
|
|
||||||
|
|
||||||
```dart
|
|
||||||
import 'dart:async';
|
|
||||||
import 'dart:math';
|
|
||||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
|
||||||
|
|
||||||
class OmronBleService {
|
|
||||||
static const bpServiceUuid = '00001810-0000-1000-8000-00805F9B34FB';
|
|
||||||
static const bpMeasurementUuid = '00002A35-0000-1000-8000-00805F9B34FB';
|
|
||||||
static const bpFeatureUuid = '00002A49-0000-1000-8000-00805F9B34FB';
|
|
||||||
static const dateTimeUuid = '00002A08-0000-1000-8000-00805F9B34FB';
|
|
||||||
|
|
||||||
BluetoothDevice? _device;
|
|
||||||
StreamSubscription<List<int>>? _subscription;
|
|
||||||
final _readingsController = StreamController<BpReading>.broadcast();
|
|
||||||
Stream<BpReading> get readings => _readingsController.stream;
|
|
||||||
|
|
||||||
// ── 扫描 ──
|
|
||||||
Stream<ScanResult> scan({Duration timeout = const Duration(seconds: 15)}) {
|
|
||||||
return FlutterBluePlus.scan(
|
|
||||||
timeout: timeout,
|
|
||||||
withServices: [Guid(bpServiceUuid)], // 只扫描有血压服务的设备
|
|
||||||
).where((r) {
|
|
||||||
final name = r.device.platformName.toUpperCase();
|
|
||||||
return name.contains('OMRON') || name.contains('HEM') || name.contains('BLEsmart');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 连接 ──
|
|
||||||
Future<void> connect(BluetoothDevice device) async {
|
|
||||||
_device = device;
|
|
||||||
await device.connect(autoConnect: false);
|
|
||||||
await device.discoverServices();
|
|
||||||
|
|
||||||
final services = await device.discoverServices();
|
|
||||||
final bpService = services.firstWhere(
|
|
||||||
(s) => s.uuid.str128.toUpperCase() == bpServiceUuid.toUpperCase(),
|
|
||||||
);
|
|
||||||
|
|
||||||
final measurementChar = bpService.characteristics.firstWhere(
|
|
||||||
(c) => c.uuid.str128.toUpperCase() == bpMeasurementUuid.toUpperCase(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 欧姆龙用 Indicate(不是 Notify),flutter_blue_plus 自动处理
|
|
||||||
await measurementChar.setNotifyValue(true);
|
|
||||||
|
|
||||||
_subscription = measurementChar.lastValueStream.listen(_parseReading);
|
|
||||||
|
|
||||||
// 连接后同步当前时间到设备(让测量时间戳准确)
|
|
||||||
await _syncTime(bpService);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 解析测量数据 ──
|
|
||||||
void _parseReading(List<int> raw) {
|
|
||||||
if (raw.length < 7) return;
|
|
||||||
|
|
||||||
final flags = raw[0];
|
|
||||||
final isKpa = (flags & 0x01) != 0;
|
|
||||||
final hasTimestamp = (flags & 0x02) != 0;
|
|
||||||
final hasPulse = (flags & 0x04) != 0;
|
|
||||||
final hasUserId = (flags & 0x08) != 0;
|
|
||||||
final hasStatus = (flags & 0x10) != 0;
|
|
||||||
|
|
||||||
int systolic = _decodeSFloat(raw[1] | (raw[2] << 8)).round();
|
|
||||||
int diastolic = _decodeSFloat(raw[3] | (raw[4] << 8)).round();
|
|
||||||
// byte 5-6: MAP (舍去)
|
|
||||||
|
|
||||||
if (isKpa) {
|
|
||||||
systolic = (systolic * 7.50062).round();
|
|
||||||
diastolic = (diastolic * 7.50062).round();
|
|
||||||
}
|
|
||||||
|
|
||||||
int offset = 7;
|
|
||||||
DateTime timestamp = DateTime.now();
|
|
||||||
|
|
||||||
if (hasTimestamp && raw.length >= offset + 7) {
|
|
||||||
int year = raw[offset] | (raw[offset + 1] << 8);
|
|
||||||
int month = raw[offset + 2];
|
|
||||||
int day = raw[offset + 3];
|
|
||||||
int hour = raw[offset + 4];
|
|
||||||
int minute = raw[offset + 5];
|
|
||||||
int second = raw[offset + 6];
|
|
||||||
timestamp = DateTime(year, month, day, hour, minute, second);
|
|
||||||
offset += 7;
|
|
||||||
}
|
|
||||||
|
|
||||||
int? pulse;
|
|
||||||
if (hasPulse && raw.length >= offset + 2) {
|
|
||||||
pulse = _decodeSFloat(raw[offset] | (raw[offset + 1] << 8)).round();
|
|
||||||
offset += 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
String? userId;
|
|
||||||
if (hasUserId && raw.length > offset) {
|
|
||||||
userId = raw[offset].toString();
|
|
||||||
offset += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool bodyMovement = false, irregularPulse = false;
|
|
||||||
if (hasStatus && raw.length >= offset + 2) {
|
|
||||||
int status = raw[offset] | (raw[offset + 1] << 8);
|
|
||||||
bodyMovement = (status & 0x0001) != 0;
|
|
||||||
irregularPulse = (status & 0x0800) != 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
_readingsController.add(BpReading(
|
|
||||||
systolic: systolic, diastolic: diastolic, pulse: pulse,
|
|
||||||
timestamp: timestamp, userId: userId, isKpa: isKpa,
|
|
||||||
hasBodyMovement: bodyMovement, hasIrregularPulse: irregularPulse,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SFLOAT 解码 ──
|
|
||||||
static double _decodeSFloat(int raw) {
|
|
||||||
int mantissa = raw & 0x0FFF;
|
|
||||||
if (mantissa & 0x0800 != 0) mantissa |= 0xFFFFF000;
|
|
||||||
int exponent = (raw >> 12) & 0x0F;
|
|
||||||
if (exponent & 0x08 != 0) exponent |= 0xFFFFFFF0;
|
|
||||||
return mantissa * pow(10, exponent);
|
|
||||||
}
|
|
||||||
|
|
||||||
double pow(double x, int exp) {
|
|
||||||
if (exp == 0) return 1;
|
|
||||||
double r = 1;
|
|
||||||
for (int i = 0; i < exp.abs(); i++) r *= x;
|
|
||||||
return exp > 0 ? r : 1 / r;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 同步时间 ──
|
|
||||||
Future<void> _syncTime(BluetoothService service) async {
|
|
||||||
try {
|
|
||||||
final dtChar = service.characteristics.firstWhere(
|
|
||||||
(c) => c.uuid.str128.toUpperCase() == dateTimeUuid.toUpperCase(),
|
|
||||||
);
|
|
||||||
final now = DateTime.now();
|
|
||||||
await dtChar.write([
|
|
||||||
now.year & 0xFF, now.year >> 8,
|
|
||||||
now.month, now.day,
|
|
||||||
now.hour, now.minute, now.second,
|
|
||||||
]);
|
|
||||||
} catch (_) {
|
|
||||||
// 部分型号不支持写入时间,忽略
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 断开 ──
|
|
||||||
Future<void> disconnect() async {
|
|
||||||
_subscription?.cancel();
|
|
||||||
await _device?.disconnect();
|
|
||||||
_device = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
void dispose() {
|
|
||||||
disconnect();
|
|
||||||
_readingsController.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.3.3 数据同步到后端
|
|
||||||
|
|
||||||
```dart
|
|
||||||
// 在 Provider 中监听
|
|
||||||
final omronReadingsProvider = StreamProvider<BpReading>((ref) {
|
|
||||||
return ref.read(omronBleServiceProvider).readings;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 监听并自动同步
|
|
||||||
ref.listen(omronReadingsProvider, (_, next) {
|
|
||||||
next.whenData((reading) async {
|
|
||||||
final api = ref.read(apiClientProvider);
|
|
||||||
await api.post('/api/health-records', data: {
|
|
||||||
'type': 'BloodPressure',
|
|
||||||
'systolic': reading.systolic,
|
|
||||||
'diastolic': reading.diastolic,
|
|
||||||
'source': 'Device',
|
|
||||||
'unit': 'mmHg',
|
|
||||||
'recordedAt': reading.timestamp.toIso8601String(),
|
|
||||||
});
|
|
||||||
ref.invalidate(latestHealthProvider); // 刷新健康概览
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.4 平台权限配置
|
|
||||||
|
|
||||||
#### Android (`AndroidManifest.xml`)
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<!-- Android 12+ -->
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
|
||||||
<!-- Android 11 及以下需要定位权限来扫描蓝牙 -->
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
|
|
||||||
android:maxSdkVersion="30" />
|
|
||||||
```
|
|
||||||
|
|
||||||
#### iOS (`Info.plist`)
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
|
||||||
<string>需要使用蓝牙连接欧姆龙血压计以同步测量数据</string>
|
|
||||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
|
||||||
<string>需要使用蓝牙连接血压计</string>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、交互流程设计
|
|
||||||
|
|
||||||
### 4.1 用户操作流程
|
|
||||||
|
|
||||||
```
|
|
||||||
1. 打开 App → 设置 → "蓝牙血压计"
|
|
||||||
2. 点击"扫描设备"
|
|
||||||
3. 看到列表:OMRON HEM-7600T / BLEsmart_xxxx
|
|
||||||
4. 点击连接 → 自动绑定(存储 MAC 地址)
|
|
||||||
5. 之后每次打开 App 自动连接已绑定设备
|
|
||||||
6. 血压计测量完成 → App 自动收到数据 → 弹窗确认 → 存入健康概览
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 前端页面代码
|
|
||||||
|
|
||||||
#### 扫描绑定页 (`device_scan_page.dart`)
|
|
||||||
|
|
||||||
```dart
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
|
||||||
import '../../services/omron_ble_service.dart';
|
|
||||||
|
|
||||||
class DeviceScanPage extends ConsumerStatefulWidget {
|
|
||||||
const DeviceScanPage({super.key});
|
|
||||||
@override ConsumerState<DeviceScanPage> createState() => _DeviceScanPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DeviceScanPageState extends ConsumerState<DeviceScanPage> {
|
|
||||||
final _ble = OmronBleService();
|
|
||||||
final _results = <ScanResult>[];
|
|
||||||
bool _scanning = false;
|
|
||||||
String? _connectingId;
|
|
||||||
|
|
||||||
@override void initState() {
|
|
||||||
super.initState();
|
|
||||||
_startScan();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _startScan() async {
|
|
||||||
setState(() { _scanning = true; _results.clear(); });
|
|
||||||
|
|
||||||
// Android 12+ 需要先请求权限
|
|
||||||
await FlutterBluePlus.turnOn();
|
|
||||||
|
|
||||||
_ble.scan().listen((result) {
|
|
||||||
if (!_results.any((r) => r.device.remoteId == result.device.remoteId)) {
|
|
||||||
setState(() => _results.add(result));
|
|
||||||
}
|
|
||||||
}).onDone(() {
|
|
||||||
if (mounted) setState(() => _scanning = false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 15 秒后自动停止扫描
|
|
||||||
Future.delayed(const Duration(seconds: 15), () {
|
|
||||||
FlutterBluePlus.stopScan();
|
|
||||||
if (mounted) setState(() => _scanning = false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _connect(ScanResult result) async {
|
|
||||||
setState(() => _connectingId = result.device.remoteId.toString());
|
|
||||||
try {
|
|
||||||
await _ble.connect(result.device);
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('已连接 ${result.device.platformName}')),
|
|
||||||
);
|
|
||||||
Navigator.pop(context, result.device); // 返回设备信息
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('连接失败: $e'), backgroundColor: Colors.red),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _connectingId = null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(title: const Text('添加血压计')),
|
|
||||||
body: Column(children: [
|
|
||||||
// 扫描状态
|
|
||||||
Container(
|
|
||||||
width: double.infinity, padding: const EdgeInsets.all(16),
|
|
||||||
color: const Color(0xFFF0F2FF),
|
|
||||||
child: Row(children: [
|
|
||||||
if (_scanning) ...[
|
|
||||||
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
],
|
|
||||||
Text(_scanning ? '正在扫描...' : '扫描完成,发现 ${_results.length} 台设备',
|
|
||||||
style: const TextStyle(fontSize: 14)),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 使用说明(没有设备时)
|
|
||||||
if (!_scanning && _results.isEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(32),
|
|
||||||
child: Column(children: [
|
|
||||||
Icon(Icons.bluetooth_disabled, size: 64, color: Colors.grey[300]),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Text('未发现设备', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500)),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
_tipCard('1', '请确保血压计已装电池'),
|
|
||||||
_tipCard('2', '长按血压计蓝牙键 3 秒,直到图标闪烁'),
|
|
||||||
_tipCard('3', '手机靠近血压计(1 米内)'),
|
|
||||||
_tipCard('4', '关闭其他已连接血压计的手机'),
|
|
||||||
_tipCard('5', 'Android 用户请确保已授予蓝牙和定位权限'),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 设备列表
|
|
||||||
Expanded(
|
|
||||||
child: ListView.builder(
|
|
||||||
itemCount: _results.length,
|
|
||||||
itemBuilder: (ctx, i) {
|
|
||||||
final r = _results[i];
|
|
||||||
final isConnecting = _connectingId == r.device.remoteId.toString();
|
|
||||||
final name = r.device.platformName.isNotEmpty
|
|
||||||
? r.device.platformName : '未知设备';
|
|
||||||
final signal = r.rssi;
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
leading: CircleAvatar(
|
|
||||||
backgroundColor: const Color(0xFFEDF2FF),
|
|
||||||
child: Icon(Icons.bluetooth, color: signal > -60 ? const Color(0xFF4F6EF7) : Colors.grey),
|
|
||||||
),
|
|
||||||
title: Text(name, style: const TextStyle(fontWeight: FontWeight.w500)),
|
|
||||||
subtitle: Text('信号: ${_rssiLabel(signal)} · MAC: ${r.device.remoteId}'),
|
|
||||||
trailing: isConnecting
|
|
||||||
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
|
|
||||||
: ElevatedButton.icon(
|
|
||||||
onPressed: () => _connect(r),
|
|
||||||
icon: const Icon(Icons.link, size: 16),
|
|
||||||
label: const Text('连接'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 重新扫描按钮
|
|
||||||
if (!_scanning && _results.isNotEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: SizedBox(width: double.infinity, child: OutlinedButton.icon(
|
|
||||||
onPressed: _startScan,
|
|
||||||
icon: const Icon(Icons.refresh),
|
|
||||||
label: const Text('重新扫描'),
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _tipCard(String num, String text) => Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
|
||||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
Container(width: 20, height: 20, alignment: Alignment.center,
|
|
||||||
decoration: BoxDecoration(color: const Color(0xFF8B9CF7), borderRadius: BorderRadius.circular(10)),
|
|
||||||
child: Text(num, style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600))),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(child: Text(text, style: TextStyle(fontSize: 13, color: Colors.grey[600]))),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
String _rssiLabel(int rssi) {
|
|
||||||
if (rssi > -55) return '强';
|
|
||||||
if (rssi > -70) return '中';
|
|
||||||
return '弱';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 已绑定设备管理页 (`device_manager_page.dart`)
|
|
||||||
|
|
||||||
```dart
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import '../../services/local_database.dart';
|
|
||||||
|
|
||||||
class DeviceManagerPage extends ConsumerStatefulWidget {
|
|
||||||
const DeviceManagerPage({super.key});
|
|
||||||
@override ConsumerState<DeviceManagerPage> createState() => _DeviceManagerPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DeviceManagerPageState extends ConsumerState<DeviceManagerPage> {
|
|
||||||
String? _boundMac;
|
|
||||||
String? _boundName;
|
|
||||||
String? _lastSync;
|
|
||||||
bool _loading = true;
|
|
||||||
|
|
||||||
@override void initState() {
|
|
||||||
super.initState();
|
|
||||||
_loadBoundDevice();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadBoundDevice() async {
|
|
||||||
final db = ref.read(localDatabaseProvider);
|
|
||||||
_boundMac = await db.read('bp_device_mac');
|
|
||||||
_boundName = await db.read('bp_device_name');
|
|
||||||
_lastSync = await db.read('bp_last_sync');
|
|
||||||
if (mounted) setState(() => _loading = false);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _unbind() async {
|
|
||||||
final ok = await showDialog<bool>(context: context, builder: (ctx) => AlertDialog(
|
|
||||||
title: const Text('解绑设备'),
|
|
||||||
content: const Text('解绑后需重新扫描连接,确定吗?'),
|
|
||||||
actions: [
|
|
||||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
|
||||||
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定解绑')),
|
|
||||||
],
|
|
||||||
));
|
|
||||||
if (ok != true) return;
|
|
||||||
final db = ref.read(localDatabaseProvider);
|
|
||||||
await db.delete('bp_device_mac');
|
|
||||||
await db.delete('bp_device_name');
|
|
||||||
await db.delete('bp_last_sync');
|
|
||||||
setState(() { _boundMac = null; _boundName = null; _lastSync = null; });
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _goScan() async {
|
|
||||||
final device = await Navigator.push(context, MaterialPageRoute(builder: (_) => const DeviceScanPage()));
|
|
||||||
if (device != null) {
|
|
||||||
final db = ref.read(localDatabaseProvider);
|
|
||||||
await db.write('bp_device_mac', device.remoteId.toString());
|
|
||||||
await db.write('bp_device_name', device.platformName);
|
|
||||||
_loadBoundDevice();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(title: const Text('蓝牙血压计')),
|
|
||||||
body: _loading
|
|
||||||
? const Center(child: CircularProgressIndicator())
|
|
||||||
: _boundMac == null
|
|
||||||
? _emptyState()
|
|
||||||
: _boundCard(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _emptyState() => Center(
|
|
||||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
|
||||||
Icon(Icons.bluetooth_disabled, size: 64, color: Colors.grey[300]),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Text('未绑定设备', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500)),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
const Text('连接欧姆龙血压计,自动同步测量数据', style: TextStyle(color: Color(0xFF999999))),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: _goScan,
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: const Text('添加设备'),
|
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF8B9CF7), foregroundColor: Colors.white),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget _boundCard() => Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16),
|
|
||||||
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 8)]),
|
|
||||||
child: Column(children: [
|
|
||||||
CircleAvatar(radius: 30, backgroundColor: const Color(0xFFEDF2FF),
|
|
||||||
child: const Icon(Icons.bluetooth_connected, color: Color(0xFF4F6EF7))),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(_boundName ?? '血压计', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text('MAC: $_boundMac', style: const TextStyle(fontSize: 13, color: Color(0xFF999999))),
|
|
||||||
if (_lastSync != null) ...[
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text('上次同步: $_lastSync', style: const TextStyle(fontSize: 12, color: Color(0xFFBBBBBB))),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: _unbind,
|
|
||||||
icon: const Icon(Icons.delete_outline, color: Colors.red),
|
|
||||||
label: const Text('解绑设备', style: TextStyle(color: Colors.red)),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 交互细节
|
|
||||||
|
|
||||||
- **测量中提示**:设备开始充气时 `0x2A36`(Intermediate Cuff Pressure)会推送实时压力,可显示充气动画
|
|
||||||
- **测量完成**:收到 `0x2A35` Indicate 后,弹 SnackBar 显示 `"已同步: 120/80 mmHg"`
|
|
||||||
- **自动记录**:无需用户手动确认,直接写入 HealthRecords 表
|
|
||||||
- **防重复**:同一时间戳(精确到秒)+ 同一值 → 不重复录入
|
|
||||||
- **多用户设备(J760)**:如果 Flags 含 User ID bit,弹出选择"这是谁的数据?"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、欧姆龙官方 SDK vs 自行解析
|
|
||||||
|
|
||||||
| 对比 | 官方 B2B SDK | 自行解析 BLE GATT |
|
|
||||||
|------|-------------|-------------------|
|
|
||||||
| 复杂度 | 低(已封装) | 中(需理解协议) |
|
|
||||||
| 费用 | 需申请/可能收费 | 免费 |
|
|
||||||
| 支持范围 | 仅欧姆龙 | 所有 BLE 血压计 |
|
|
||||||
| 维护 | SDK 更新需适配 | 标准协议不变 |
|
|
||||||
| iOS 支持 | 有 | ✅ flutter_blue_plus |
|
|
||||||
| Android 支持 | 有 | ✅ flutter_blue_plus |
|
|
||||||
| 获取方式 | [public.omronhealthcare.com.cn/b2bsdk](https://public.omronhealthcare.com.cn/b2bsdk/) | — |
|
|
||||||
|
|
||||||
**推荐路线**:
|
|
||||||
1. **先用自行解析方案**(本文档方案),因为协议是标准 BLE,不依赖第三方
|
|
||||||
2. 如果遇到兼容性问题(特定型号数据解析异常),再申请欧姆龙官方 SDK 作为补充
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、后端改动
|
|
||||||
|
|
||||||
### 6.1 DB 新增表(可选)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE IF NOT EXISTS "DeviceBindings" (
|
|
||||||
"Id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
"UserId" UUID NOT NULL REFERENCES "Users"("Id"),
|
|
||||||
"DeviceType" TEXT DEFAULT 'BloodPressure',
|
|
||||||
"DeviceModel" TEXT, -- 'OMRON J732'
|
|
||||||
"DeviceMac" TEXT, -- 'AA:BB:CC:DD:EE:FF'
|
|
||||||
"DeviceSerial" TEXT,
|
|
||||||
"LastSyncAt" TIMESTAMPTZ,
|
|
||||||
"CreatedAt" TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 后端几乎无需改动
|
|
||||||
|
|
||||||
现有 `POST /api/health-records` 已支持 `BloodPressure` + `systolic/diastolic`,只需确保 `Source` 枚举有 `Device`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public enum HealthRecordSource {
|
|
||||||
Manual,
|
|
||||||
AiEntry,
|
|
||||||
Device // ← 新增
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、实施计划(5 天)
|
|
||||||
|
|
||||||
| 天 | 任务 | 产出 |
|
|
||||||
|----|------|------|
|
|
||||||
| **Day 1** | flutter_blue_plus 集成 + 权限配置 + SFLOAT 单元测试 | BLE 服务骨架 |
|
|
||||||
| **Day 2** | 扫描/连接/解析/断连完整实现 | OmronBleService |
|
|
||||||
| **Day 3** | 设备扫描页 + 绑定管理页 + 设置入口 | UI 完成 |
|
|
||||||
| **Day 4** | 数据同步到后端 + 去重逻辑 + 健康概览刷新 | 端到端打通 |
|
|
||||||
| **Day 5** | 真机 + J735 联调测试、异常处理 | 上线就绪 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 八、J735 到货后首次调试指南
|
|
||||||
|
|
||||||
### 8.1 开箱检查
|
|
||||||
|
|
||||||
收到货后先确认设备正常:
|
|
||||||
|
|
||||||
1. 装电池(4 节 AA)或插电源适配器
|
|
||||||
2. 按「开始/停止」键,袖带充气 → 屏幕显示数值 → 确认测量功能正常
|
|
||||||
3. 长按蓝牙键(屏幕出现蓝牙图标闪烁)→ 进入配对模式
|
|
||||||
|
|
||||||
### 8.2 用官方 App 验证蓝牙
|
|
||||||
|
|
||||||
这一步是验证蓝牙硬件没问题:
|
|
||||||
|
|
||||||
1. 手机下载 **OMRON Connect**(iOS App Store / Android 应用市场)
|
|
||||||
2. 注册账号 → 添加设备 → 选择 J735
|
|
||||||
3. 血压计长按蓝牙键进入配对模式
|
|
||||||
4. App 扫描连接 → 测量一次 → 确认数据同步到 App
|
|
||||||
|
|
||||||
如果官方 App 能正常同步,说明硬件没问题,接下来就是我们的代码对接。
|
|
||||||
|
|
||||||
### 8.3 用 nRF Connect 抓包验证
|
|
||||||
|
|
||||||
nRF Connect 是 Nordic 官方的免费 BLE 调试工具,可以直接看到设备广播的原始数据。**这是开发前最重要的验证步骤**:
|
|
||||||
|
|
||||||
1. 手机安装 **nRF Connect**(iOS App Store / Android Google Play)
|
|
||||||
2. 打开 J735 蓝牙配对模式
|
|
||||||
3. nRF Connect 扫描 → 找到 `OMRON` 或 `BLEsmart` 开头的设备 → 点击连接
|
|
||||||
4. 找到 `Blood Pressure` 服务(UUID `0x1810`)
|
|
||||||
5. 订阅 `Blood Pressure Measurement` Characteristic(UUID `0x2A35`)
|
|
||||||
6. 在血压计上测量一次
|
|
||||||
7. nRF Connect 会收到 Indicate 推送 → 截图保存原始 HEX 数据
|
|
||||||
|
|
||||||
这个 HEX 数据就是我们要解析的原始字节。用它来验证我们的 SFLOAT 解码是否正确。
|
|
||||||
|
|
||||||
### 8.4 BLE 名称确认
|
|
||||||
|
|
||||||
不同批次 J735 的蓝牙名称可能不同,常见的有:
|
|
||||||
|
|
||||||
- `OMRON HEM-xxxx`
|
|
||||||
- `BLEsmart_xxxx`
|
|
||||||
- `OMRON-BPM`
|
|
||||||
|
|
||||||
首次连接后用 `device.platformName` 打印出来,后续扫描过滤就用这个。
|
|
||||||
|
|
||||||
### 8.5 常见坑
|
|
||||||
|
|
||||||
| 坑 | 现象 | 解决 |
|
|
||||||
|----|------|------|
|
|
||||||
| **设备已被手机系统蓝牙连接** | flutter_blue_plus 搜不到 | 系统设置里取消配对 |
|
|
||||||
| **未订阅 Characteristic** | 收不到数据 | 必须 `setNotifyValue(true)` |
|
|
||||||
| **忘记请求权限** | Android 扫描返回空列表 | 检查蓝牙+定位权限 |
|
|
||||||
| **SFLOAT 计算错误** | 数据差很多 | 用 nRF Connect 的 HEX 对比解码结果 |
|
|
||||||
| **iOS BLE 后台限制** | App 切后台后断连 | 前台测量时连接,测量完断开 |
|
|
||||||
| **J735 是 Indicate 不是 Notify** | 数据只来一次 | flutter_blue_plus 对 Indicate 自动处理,但每次数据后需设备收到确认才会发下一条 |
|
|
||||||
|
|
||||||
### 8.6 测试清单
|
|
||||||
|
|
||||||
```
|
|
||||||
□ 扫描能看到 J735
|
|
||||||
□ 点击连接成功
|
|
||||||
□ discoverServices 能找到 0x1810 服务
|
|
||||||
□ setNotifyValue(true) 后收到测量数据
|
|
||||||
□ SFLOAT 解码结果与屏幕显示一致(允许 ±1 误差)
|
|
||||||
□ 数据成功 POST 到 /api/health-records
|
|
||||||
□ 健康概览刷新后显示新数据
|
|
||||||
□ 断连后重新打开 App 能再次连接
|
|
||||||
□ 同一测量不重复录入(去重逻辑)
|
|
||||||
□ iOS 和 Android 双平台都测试通过
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 九、风险与备选
|
|
||||||
|
|
||||||
| 风险 | 应对 |
|
|
||||||
|------|------|
|
|
||||||
| 公司 WiFi 环境蓝牙干扰大 | 手机靠近血压计(< 5 米) |
|
|
||||||
| Android 后台被杀 | 前台 Service 保持 BLE 连接 |
|
|
||||||
| iOS BLE 限制(App 后台) | 前台连接时同步历史数据 |
|
|
||||||
| J735 协议与标准有偏差 | 用 nRF Connect 抓包对比,微调解码 |
|
|
||||||
| 多个血压计混淆 | 按 MAC 地址绑定,UI 显示设备名称 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 参考资源
|
|
||||||
|
|
||||||
- Bluetooth SIG Blood Pressure Service 1.1: https://www.bluetooth.com/specifications/specs/blood-pressure-service-1-1-1/
|
|
||||||
- 欧姆龙 B2B SDK 中国: https://public.omronhealthcare.com.cn/b2bsdk/
|
|
||||||
- 欧姆龙全球开发者 API: https://omronhealthcare.com/api
|
|
||||||
- flutter_blue_plus: https://pub.dev/packages/flutter_blue_plus
|
|
||||||
@@ -1,570 +0,0 @@
|
|||||||
# 健康项目深度分析报告
|
|
||||||
|
|
||||||
日期:2026-06-17
|
|
||||||
|
|
||||||
## 1. 分析范围与结论概览
|
|
||||||
|
|
||||||
本次分析覆盖了 Flutter 客户端、ASP.NET Core 后端、AI 问诊/饮食识别链路、路由、数据存储、UI 结构、测试与工程配置。执行过的主要检查包括:
|
|
||||||
|
|
||||||
- `flutter analyze`:当前客户端有 162 条 analyzer/lint 问题,其中包含错误导入、未使用代码、异步上下文风险、废弃 API、调试输出等。
|
|
||||||
- `dotnet test`:后端测试未能真正跑完,原因是正在运行的 `Health.WebApi` 进程锁住了 `bin/Debug/net10.0` 下的 DLL,导致构建复制失败。
|
|
||||||
- 重点阅读:`api_client.dart`、`chat_provider.dart`、`app_router.dart`、`data_providers.dart`、`local_database.dart`、`Program.cs`、`auth_endpoints.cs`、`ai_chat_endpoints.cs`、`file_endpoints.cs`、测试目录等。
|
|
||||||
|
|
||||||
整体判断:项目功能面铺得很广,已经具备“患者端健康管理 + AI 助手 + 饮食识别 + 用药/报告/日历/随访 + 医生/管理员”的雏形。但目前最大问题不是单个页面丑,而是功能深度、数据安全、接口契约、状态管理、UI 体系和测试可信度还没有收拢。现在已经到了需要“先稳核心链路,再美化体验”的阶段。
|
|
||||||
|
|
||||||
## 2. 最需要优先处理的问题
|
|
||||||
|
|
||||||
| 优先级 | 问题 | 影响 | 建议 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| P0 | 短信验证码接口返回 `devCode`,登录页还会自动填充 | 真实环境下等于绕过验证码安全 | 仅开发环境返回,生产环境绝不返回;前端删除自动填充逻辑 |
|
|
||||||
| P0 | SSE token 放在 query 中,后端用 `ReadJwtToken` 读取但没有验证签名 | 可被伪造用户 ID,存在严重认证风险 | SSE 也必须走标准 JWT 验证,或使用一次性短票据 |
|
|
||||||
| P0 | AI 会话按 `conversationId` 查询后未确认归属用户 | 可能跨用户写入/读取会话 | 所有 conversation 查询都加 `UserId == currentUserId` |
|
|
||||||
| P1 | 文件上传前后端契约不一致 | 图片上传后前端拿不到 URL,AI 图片消息可能只保存本地路径 | 后端返回可访问 URL,或前端按后端返回结构处理 |
|
|
||||||
| P1 | token 和健康数据存在本地 SQLite,未加密 | 手机丢失或被调试时有泄露风险 | 使用 secure storage 保存 token,敏感健康数据考虑加密 |
|
|
||||||
| P1 | CORS 允许任意 Origin 且允许 Credentials | Web 场景下有跨站风险 | 按环境配置白名单 |
|
|
||||||
| P1 | 后端用 `EnsureCreatedAsync` 而不是 migrations | 数据库升级不可控 | 建立 EF migrations 流程 |
|
|
||||||
| P1 | UI 设计体系不统一 | 页面之间像不同产品拼接,后期维护难 | 建立颜色、间距、字体、组件规范 |
|
|
||||||
| P2 | Flutter 存在大量未使用代码、旧页面、调试输出 | 维护成本高,也容易藏 bug | 分模块清理,保留真实业务入口 |
|
|
||||||
| P2 | 测试依赖运行中的本地服务和不安全 dev 行为 | CI 不可靠,安全问题被测试固化 | 单元/集成测试分层,测试环境用隔离配置 |
|
|
||||||
|
|
||||||
## 3. 功能层面分析
|
|
||||||
|
|
||||||
### 3.1 功能广度够,但关键流程深度不够
|
|
||||||
|
|
||||||
目前功能入口很多:AI 问诊、记数据、饮食拍照、用药、报告、运动、健康档案、复查随访、医生端、管理员端等。这个方向是对的,但现在许多功能更像“有入口、有页面、有部分数据”,还没有形成足够扎实的闭环。
|
|
||||||
|
|
||||||
建议优先把以下闭环做深:
|
|
||||||
|
|
||||||
1. 健康数据闭环:记录数据、展示趋势、异常提示、医生/AI 解读、复查建议。
|
|
||||||
2. 饮食闭环:图片识别、用户修正、营养统计、长期趋势、与血糖/体重关联。
|
|
||||||
3. 用药闭环:药品计划、提醒、服药打卡、漏服记录、医生可见。
|
|
||||||
4. 报告闭环:上传报告、AI 解析、结构化指标、异常项追踪、历史对比。
|
|
||||||
|
|
||||||
现在的问题是入口比闭环多。用户第一次点进去可能觉得丰富,但长期使用时会发现很多地方缺少持续价值。
|
|
||||||
|
|
||||||
### 3.2 健康仪表盘需要从“展示数值”升级到“解释状态”
|
|
||||||
|
|
||||||
血压、心率、血糖、血氧、体重这些指标是同等级核心指标,UI 上横向平铺是合理的。但产品上还需要补足:
|
|
||||||
|
|
||||||
- 每个指标显示采集时间,避免用户误以为是最新状态。
|
|
||||||
- 显示单位和正常范围。
|
|
||||||
- 显示趋势,例如较上次升高/下降。
|
|
||||||
- 异常时给出明确状态,而不是只换颜色。
|
|
||||||
- 区分数据来源:手动录入、蓝牙设备、报告解析、医生录入。
|
|
||||||
|
|
||||||
医疗健康类应用不能只好看,还要让用户理解“现在是否安全、下一步做什么”。
|
|
||||||
|
|
||||||
### 3.3 AI 功能需要更强的边界
|
|
||||||
|
|
||||||
AI 问诊、饮食分析、报告解读是项目亮点,但要注意:
|
|
||||||
|
|
||||||
- AI 建议不能替代医生诊断,需要在关键场景给出边界提示。
|
|
||||||
- AI 生成结果要允许用户修正,尤其是饮食热量、报告指标、药品信息。
|
|
||||||
- AI 使用了用户健康上下文,应有授权说明、数据范围说明、删除机制。
|
|
||||||
- AI 工具调用失败时,前端需要展示可理解的失败状态,而不是静默失败。
|
|
||||||
|
|
||||||
## 4. UI/UX 分析
|
|
||||||
|
|
||||||
### 4.1 当前最大 UI 问题:没有稳定设计系统
|
|
||||||
|
|
||||||
项目里已经有 `AppColors`、`AppTheme`,也有多处页面自己的渐变、阴影、圆角、卡片样式。最近的页面修改又加入了更多独立渐变。这样短期能调好某个页面,但长期会导致页面之间不统一。
|
|
||||||
|
|
||||||
建议建立一套稳定规则:
|
|
||||||
|
|
||||||
- 主色:用于导航、主要按钮、健康仪表盘重点区域。
|
|
||||||
- 功能色:饮食、用药、报告、运动、问诊等可以不同,但要同一明度和饱和度等级。
|
|
||||||
- 状态色:正常、警告、危险、完成、禁用必须固定。
|
|
||||||
- 卡片规则:圆角、阴影、边框、内边距统一。
|
|
||||||
- 图标规则:同一模块内图标线宽、尺寸、背景形状统一。
|
|
||||||
|
|
||||||
现在用户已经多次指出“颜色太花”“图标不一致”“侧边栏不好看”,本质就是设计 token 没有统一。
|
|
||||||
|
|
||||||
### 4.2 侧边栏应该是高频操作中心,不是杂物入口
|
|
||||||
|
|
||||||
侧边栏现在承担了个人信息、仪表盘、功能入口、设置等很多内容。建议侧边栏只保留高频且有明确层级的内容:
|
|
||||||
|
|
||||||
- 顶部:用户身份和健康状态摘要。
|
|
||||||
- 中部:核心健康指标,横向一屏看完。
|
|
||||||
- 功能入口:报告管理、饮食记录、用药管理、健康日历、复查随访、运动计划,两行三列。
|
|
||||||
- 底部:健康档案、设置。
|
|
||||||
|
|
||||||
个人信息区域不一定要卡片包起来,可以用更轻的排版。健康仪表盘可以做成视觉重点,但功能入口不应该抢它的层级。
|
|
||||||
|
|
||||||
### 4.3 页面质感不均衡
|
|
||||||
|
|
||||||
部分页面已经开始有精致的渐变和卡片,但另一些页面仍像占位页或默认列表页。尤其是:
|
|
||||||
|
|
||||||
- 个人信息页:信息架构需要重做,当前不够像一个正式健康档案/账号资料页。
|
|
||||||
- 设置页:应按账号、安全、通知、设备、隐私、关于分组,而不是堆按钮。
|
|
||||||
- 饮食分析页:餐次选择、识别结果、热量展示、AI 建议这几个区域需要更统一的卡片层级。
|
|
||||||
- 医生端/管理员端:如果后续要真实使用,需要比患者端更重视密度、筛选、表格和状态。
|
|
||||||
|
|
||||||
### 4.4 可访问性风险
|
|
||||||
|
|
||||||
当前大量使用渐变、浅色图标、小号文字和颜色区分状态。健康类应用的用户可能包含中老年人,建议:
|
|
||||||
|
|
||||||
- 核心数字字号足够大。
|
|
||||||
- 不只靠颜色表达异常,还要有文字。
|
|
||||||
- 保证按钮文字和图标对比度。
|
|
||||||
- 支持系统字体缩放。
|
|
||||||
- 重要按钮触控面积至少 44x44。
|
|
||||||
|
|
||||||
## 5. 前端工程分析
|
|
||||||
|
|
||||||
### 5.1 API 配置不适合真实移动端
|
|
||||||
|
|
||||||
> 2026-06-20 更新:已支持 `--dart-define=API_BASE_URL=...`,并保留 `http://localhost:5000` 作为 USB `adb reverse` 本地开发默认值。本节的环境配置问题已处理。
|
|
||||||
|
|
||||||
`health_app/lib/core/api_client.dart` 中 `baseUrl` 默认是 `http://localhost:5000`。这在 Android/iOS 真机上通常不可用,也不适合测试/生产环境切换。
|
|
||||||
|
|
||||||
建议:
|
|
||||||
|
|
||||||
- 使用 `--dart-define=API_BASE_URL=...`。
|
|
||||||
- 区分 dev/staging/prod。
|
|
||||||
- 本地 Android 模拟器使用 `10.0.2.2` 或局域网地址。
|
|
||||||
|
|
||||||
### 5.2 Token 存储不安全
|
|
||||||
|
|
||||||
当前 token 存在本地 SQLite key-value 中,例如 `access_token`、`refresh_token`。这对健康类应用风险偏高。
|
|
||||||
|
|
||||||
建议:
|
|
||||||
|
|
||||||
- token 改用 `flutter_secure_storage` 或平台安全存储。
|
|
||||||
- SQLite 中的敏感健康数据考虑加密。
|
|
||||||
- 退出登录时确认清理 token、用户缓存、会话缓存。
|
|
||||||
|
|
||||||
### 5.3 上传接口前后端不一致
|
|
||||||
|
|
||||||
前端 `uploadFile` 期望后端返回:
|
|
||||||
|
|
||||||
- `url`
|
|
||||||
- 或 `data.url`
|
|
||||||
|
|
||||||
但后端 `file_endpoints.cs` 返回的是文件列表,每项包含:
|
|
||||||
|
|
||||||
- `id`
|
|
||||||
- `name`
|
|
||||||
- `size`
|
|
||||||
|
|
||||||
这会导致 `ChatNotifier.sendImage` 中的 `uploadedUrl` 很可能是 null,最终消息只保存本地路径,跨设备、重启、后端 AI 处理都会出问题。
|
|
||||||
|
|
||||||
建议统一契约:
|
|
||||||
|
|
||||||
- 后端返回 `{ id, name, size, url, contentType }`。
|
|
||||||
- 前端保存 `remoteUrl`,本地路径只做临时预览。
|
|
||||||
- 上传失败要有明确 UI 反馈。
|
|
||||||
|
|
||||||
### 5.4 路由可靠性不足
|
|
||||||
|
|
||||||
`app_router.dart` 使用字符串 switch 和 `params['id']!`。如果参数缺失会直接崩溃。
|
|
||||||
|
|
||||||
建议:
|
|
||||||
|
|
||||||
- 至少对参数做空值保护。
|
|
||||||
- 核心页面可以逐步迁移到 typed route。
|
|
||||||
- 路由表按模块拆分,避免一个文件不断膨胀。
|
|
||||||
|
|
||||||
### 5.5 状态管理有隐患
|
|
||||||
|
|
||||||
`chat_provider.dart` 中有一些状态直接修改对象字段的写法,例如修改 `ChatMessage` 的 `content`、`type`、`metadata`、`confirmed`。这容易造成 Riverpod rebuild 不稳定、历史消息状态串联、难以调试。
|
|
||||||
|
|
||||||
建议:
|
|
||||||
|
|
||||||
- 消息对象尽量不可变。
|
|
||||||
- 更新消息时使用 copy/update list。
|
|
||||||
- loading、streaming、error 状态显式建模。
|
|
||||||
|
|
||||||
### 5.6 错误处理过于安静
|
|
||||||
|
|
||||||
> 2026-06-20 更新:`ApiClient` 已统一识别 `{ code, message }` 业务错误及 HTTP/Dio 网络错误,并转换为可直接展示的 `ApiException`。后端历史 endpoint 的 HTTP 状态码仍需按模块逐步规范,避免一次性破坏现有前端协议。
|
|
||||||
|
|
||||||
一些 provider 会 catch 异常后返回 fallback 数据,例如医生列表、运动计划。这个方式能让页面不崩,但会掩盖真实后端错误。
|
|
||||||
|
|
||||||
建议:
|
|
||||||
|
|
||||||
- demo 数据和真实数据明确分离。
|
|
||||||
- 网络失败时展示“加载失败/重试”,不要假装成功。
|
|
||||||
- 对关键接口增加日志和错误上报。
|
|
||||||
|
|
||||||
### 5.7 当前 Flutter analyzer 需要清理
|
|
||||||
|
|
||||||
本次 `flutter analyze` 返回 162 条问题。较重要的包括:
|
|
||||||
|
|
||||||
- `settings_pages.dart` 从 `data_providers.dart` 导入 `apiClientProvider`,但 provider 实际不在该文件中,属于当前明显错误。
|
|
||||||
- 多处 `use_build_context_synchronously`,异步后使用 `context` 前没有判断 mounted。
|
|
||||||
- BLE 使用废弃的 `BluetoothDevice.localName`。
|
|
||||||
- 大量 `avoid_print`,可能泄露请求、token、健康数据。
|
|
||||||
- 多个未使用方法、变量、页面,说明旧代码堆积严重。
|
|
||||||
|
|
||||||
建议先定一个目标:把 analyzer 从 162 条降到 0 或只剩明确允许的少量规则。
|
|
||||||
|
|
||||||
## 6. 后端工程与安全分析
|
|
||||||
|
|
||||||
### 6.1 短信验证码存在严重安全问题
|
|
||||||
|
|
||||||
`auth_endpoints.cs` 中发送短信验证码后会返回 `devCode`,前端登录页还会自动填充。这个行为如果进入真实环境,会直接破坏验证码意义。
|
|
||||||
|
|
||||||
另外还存在:
|
|
||||||
|
|
||||||
- 管理员手机号 `12345678910`。
|
|
||||||
- 固定验证码 `000000`。
|
|
||||||
- 缺少短信发送频率限制。
|
|
||||||
- 缺少验证码尝试次数限制。
|
|
||||||
- 验证码在部分失败流程中可能提前被标记已使用。
|
|
||||||
|
|
||||||
建议:
|
|
||||||
|
|
||||||
- 只有开发环境允许返回验证码。
|
|
||||||
- 生产环境必须接入真实短信服务。
|
|
||||||
- 加入 IP/手机号频率限制。
|
|
||||||
- 管理员登录改为独立安全流程。
|
|
||||||
|
|
||||||
### 6.2 SSE 认证方式有严重漏洞
|
|
||||||
|
|
||||||
AI SSE 接口允许从 query string 读取 token,而且后端使用 `ReadJwtToken` 读取用户 ID。`ReadJwtToken` 只解析 token,不验证签名、不验证过期、不验证 issuer/audience。
|
|
||||||
|
|
||||||
这意味着攻击者可能构造一个看似 JWT 的字符串,放入任意用户 ID claim,后端就可能当成有效用户。
|
|
||||||
|
|
||||||
建议:
|
|
||||||
|
|
||||||
- 禁止直接信任 query token。
|
|
||||||
- SSE 鉴权也必须经过 `JwtBearer` 验证。
|
|
||||||
- 如果浏览器 EventSource 不方便加 header,可以先用标准授权接口换一个短期一次性 stream token,服务端存储并校验。
|
|
||||||
|
|
||||||
### 6.3 会话归属校验不足
|
|
||||||
|
|
||||||
AI 聊天接口中根据 `conversationId` 查询会话后,需要确认该会话属于当前用户。否则只要知道或猜到 conversationId,就有跨用户写入/读取风险。
|
|
||||||
|
|
||||||
建议:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
var conversation = await db.Conversations
|
|
||||||
.FirstOrDefaultAsync(c => c.Id == convId && c.UserId == currentUserId);
|
|
||||||
```
|
|
||||||
|
|
||||||
所有报告、健康档案、饮食、聊天记录等用户资源都应遵循这个模式。
|
|
||||||
|
|
||||||
### 6.4 CORS 配置过宽
|
|
||||||
|
|
||||||
`Program.cs` 中 CORS 允许任意 origin,同时允许 credentials。这在 Web 客户端场景下风险很大。
|
|
||||||
|
|
||||||
建议:
|
|
||||||
|
|
||||||
- dev 环境允许 localhost。
|
|
||||||
- staging/prod 使用固定域名白名单。
|
|
||||||
- 不要在任意 origin 下允许 credentials。
|
|
||||||
|
|
||||||
### 6.5 数据库初始化方式不适合长期维护
|
|
||||||
|
|
||||||
后端使用 `EnsureCreatedAsync()`。这适合原型阶段,不适合真实项目迭代。后续字段变更、索引变更、数据迁移都会困难。
|
|
||||||
|
|
||||||
建议:
|
|
||||||
|
|
||||||
- 建立 EF Core migrations。
|
|
||||||
- 启动时只在开发环境自动迁移,生产环境由部署流程控制。
|
|
||||||
- 所有 schema 变更进入版本管理。
|
|
||||||
|
|
||||||
### 6.6 文件上传风险
|
|
||||||
|
|
||||||
`file_endpoints.cs` 当前只保存上传文件,没有看到严格的大小、类型、内容校验。
|
|
||||||
|
|
||||||
建议:
|
|
||||||
|
|
||||||
- 限制文件大小。
|
|
||||||
- 限制 MIME 类型和扩展名。
|
|
||||||
- 图片重新编码或扫描。
|
|
||||||
- 不直接信任用户文件名。
|
|
||||||
- 返回可访问 URL,并控制访问权限。
|
|
||||||
|
|
||||||
### 6.7 System.Drawing 跨平台风险
|
|
||||||
|
|
||||||
后端 AI 图片压缩使用 `System.Drawing` 相关 API,analyzer 给出了 CA1416 警告。这些 API 在非 Windows 环境不可靠。如果部署到 Linux 容器,可能出问题。
|
|
||||||
|
|
||||||
建议换成:
|
|
||||||
|
|
||||||
- ImageSharp
|
|
||||||
- SkiaSharp
|
|
||||||
- 或云端对象存储/图片处理服务
|
|
||||||
|
|
||||||
## 7. AI 与隐私合规分析
|
|
||||||
|
|
||||||
项目会把用户健康档案、健康记录、饮食图片、报告等数据送入 AI。健康数据属于高敏感数据,需要明确处理策略。
|
|
||||||
|
|
||||||
建议补齐:
|
|
||||||
|
|
||||||
- 用户授权:哪些数据会发送给 AI。
|
|
||||||
- 数据最小化:只传当前任务必要字段。
|
|
||||||
- 日志脱敏:不要记录完整模型响应、token、报告原文、图片隐私信息。
|
|
||||||
- 删除机制:用户删除账号后,AI 会话、上传文件、日志也要清理。
|
|
||||||
- 医疗免责声明:AI 只做健康建议,不替代诊断。
|
|
||||||
- 审计日志:医生/管理员访问患者数据需要留痕。
|
|
||||||
|
|
||||||
当前 `vlm_log_*.txt` 会记录模型响应,可能包含敏感结果,建议尽快调整为脱敏日志或仅开发环境启用。
|
|
||||||
|
|
||||||
## 8. 测试与工程流程
|
|
||||||
|
|
||||||
### 8.1 后端测试当前不可靠
|
|
||||||
|
|
||||||
`dotnet test` 未跑完,因为正在运行的 WebApi 进程锁住了构建输出 DLL。这说明本地开发/测试流程还不稳定。
|
|
||||||
|
|
||||||
建议:
|
|
||||||
|
|
||||||
- 测试使用独立输出目录或先停止运行中的服务。
|
|
||||||
- CI 中从干净环境执行。
|
|
||||||
- 单元测试不要依赖手动启动的 localhost 服务。
|
|
||||||
|
|
||||||
### 8.2 测试内容还偏浅
|
|
||||||
|
|
||||||
目前测试更像验证部分服务逻辑和开发流程,没有覆盖高风险业务:
|
|
||||||
|
|
||||||
- 验证码频控和错误次数。
|
|
||||||
- JWT 过期、刷新、伪造 token。
|
|
||||||
- 用户 A 不能访问用户 B 的会话/报告/健康数据。
|
|
||||||
- 文件上传大小/类型限制。
|
|
||||||
- AI SSE 中断、重试、失败展示。
|
|
||||||
- 饮食识别后用户修正和保存。
|
|
||||||
|
|
||||||
建议先补安全边界测试,再补 UI golden/screenshot 测试。
|
|
||||||
|
|
||||||
## 9. 具体 bug 与风险点清单
|
|
||||||
|
|
||||||
| 文件 | 问题 | 建议 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `health_app/lib/pages/settings/settings_pages.dart` | `apiClientProvider` 导入来源错误,analyzer 已报错 | 从正确 provider 文件导入,或统一 provider 暴露位置 |
|
|
||||||
| `health_app/lib/core/api_client.dart` | `baseUrl` 写死 localhost | 使用环境配置 |
|
|
||||||
| `health_app/lib/core/api_client.dart` | 上传返回结构与后端不匹配 | 统一上传响应 |
|
|
||||||
| `health_app/lib/core/api_client.dart` | `LogInterceptor` 打印请求/响应 body | 生产关闭,敏感字段脱敏 |
|
|
||||||
| `health_app/lib/core/local_database.dart` | token 存 SQLite | 改 secure storage |
|
|
||||||
| `health_app/lib/providers/chat_provider.dart` | 图片消息上传 URL 可能为空 | 修复上传契约和失败 UI |
|
|
||||||
| `health_app/lib/providers/chat_provider.dart` | 静默 catch | 展示错误状态并记录日志 |
|
|
||||||
| `health_app/lib/core/app_router.dart` | `params['id']!` 可能崩溃 | 参数校验和 fallback 页面 |
|
|
||||||
| `backend/src/Health.WebApi/Endpoints/auth_endpoints.cs` | 返回 `devCode` | 仅 dev 环境启用 |
|
|
||||||
| `backend/src/Health.WebApi/Endpoints/auth_endpoints.cs` | 固定管理员验证码 | 改正式认证流程 |
|
|
||||||
| `backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs` | query token 未验证签名 | 改标准 JWT 鉴权 |
|
|
||||||
| `backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs` | conversation 归属校验不足 | 查询时加入 UserId |
|
|
||||||
| `backend/src/Health.WebApi/Endpoints/file_endpoints.cs` | 文件类型/大小校验不足 | 加白名单和限制 |
|
|
||||||
| `backend/src/Health.WebApi/Program.cs` | CORS 过宽 | 按环境配置白名单 |
|
|
||||||
| `backend/src/Health.WebApi/Program.cs` | `EnsureCreatedAsync` | 改 migrations |
|
|
||||||
|
|
||||||
## 10. 建议整改路线
|
|
||||||
|
|
||||||
### 10.1 0 到 3 天:先堵住安全和明显 bug
|
|
||||||
|
|
||||||
1. 修复 SSE token 验证和 conversation 用户归属校验。
|
|
||||||
2. 删除生产环境 `devCode` 返回和前端自动填充验证码。
|
|
||||||
3. 修复文件上传返回结构。
|
|
||||||
4. 修复 `settings_pages.dart` 的错误导入。
|
|
||||||
5. 关闭生产环境请求/响应 body 日志。
|
|
||||||
6. 把 Flutter analyzer 里的真正错误先清零。
|
|
||||||
|
|
||||||
### 10.2 1 到 2 周:收拢核心体验
|
|
||||||
|
|
||||||
1. 建立统一设计 token:颜色、渐变、按钮、卡片、图标。
|
|
||||||
2. 重做侧边栏、个人信息页、设置页、饮食分析页的信息层级。
|
|
||||||
3. 健康仪表盘增加趋势、时间、单位、状态解释。
|
|
||||||
4. 清理 `remaining_pages.dart` 中旧页面和无入口页面。
|
|
||||||
5. 前端 API baseUrl 改成环境配置。
|
|
||||||
6. token 改安全存储。
|
|
||||||
|
|
||||||
### 10.3 1 个月:提升产品完整度
|
|
||||||
|
|
||||||
1. 建立 EF migrations 和 CI。
|
|
||||||
2. 增加安全测试:验证码、JWT、跨用户访问、上传。
|
|
||||||
3. 增加 AI 结果修正、置信度、失败重试。
|
|
||||||
4. 补齐隐私授权、数据删除、访问审计。
|
|
||||||
5. 医生端做成真正可用的工作台:患者筛选、风险排序、随访提醒。
|
|
||||||
6. 报告、饮食、用药、运动做长期趋势关联。
|
|
||||||
|
|
||||||
## 11. 第二轮深挖补充
|
|
||||||
|
|
||||||
这一轮额外对已有 `docs/BUG_REVIEW.md`、后端端点、AI Agent handler、SignalR、Flutter provider 生命周期、饮食/蓝牙/问诊页面做了交叉检查。需要注意:旧 bug 文档中有一些问题已经被修复或情况已经变化,本节以当前代码为准。
|
|
||||||
|
|
||||||
### 11.1 已确认仍然存在的高风险问题
|
|
||||||
|
|
||||||
| 优先级 | 位置 | 当前问题 | 风险说明 | 修复方向 |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| P0 | `backend/src/Health.WebApi/Endpoints/auth_endpoints.cs` | `/api/auth/send-sms` 仍返回 `devCode` | 任何客户端都能拿到验证码,短信验证等同失效 | 只在 Development 返回;生产环境完全移除 |
|
|
||||||
| P0 | `health_app/lib/pages/auth/login_page.dart` | 前端拿到 `devCode` 后自动填入验证码 | 把后端安全问题固化成产品行为 | 删除自动填充;开发环境可用 debug banner 或日志 |
|
|
||||||
| P0 | `backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs` | SSE token 走 query,且 fallback 用 `ReadJwtToken` 解析 | query token 会进日志;`ReadJwtToken` 不验证签名 | SSE 改标准鉴权,或先换一次性 stream ticket |
|
|
||||||
| P0 | `backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs` | 创建/续写 AI 会话时 `FindAsync(conversationId)` 未校验 `UserId` | 可能跨用户写入/读取会话 | `FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId)` |
|
|
||||||
| P0 | `backend/src/Health.WebApi/Hubs/ConsultationHub.cs` | SignalR Hub 没有鉴权,入组只靠 `consultationId` | 任意连接可加入任意问诊房间 | `MapHub().RequireAuthorization()`,Join 前校验用户或医生权限 |
|
|
||||||
| P0 | `backend/src/Health.WebApi/Hubs/ConsultationHub.cs` | `SendMessage` 按传入 `senderType` 和 `consultationId` 写库 | 客户端可冒充 Doctor/User,向任意会话发消息 | senderType 从 Claims/角色推导,不信任客户端 |
|
|
||||||
| P0 | `backend/src/Health.WebApi/Endpoints/consultation_endpoints.cs` | 患者发消息只查会话存在,未校验会话属于当前用户 | 用户 A 可向用户 B 的问诊发 HTTP 消息 | 查询加 `c.UserId == userId` |
|
|
||||||
| P0 | `backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs` | `/items/{itemId}/checkin` 只 `FindAsync(itemId)` | 用户可打卡/取消他人的运动计划项 | Include Plan 后校验 `Plan.UserId` |
|
|
||||||
| P0 | `backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs` | AI 运动 checkin 只按 itemId 操作 | 通过 AI 工具也可越权打卡 | 查询 item 时联表校验当前 userId |
|
|
||||||
| P0 | `backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs` | AI 用药 confirm 直接写 `MedicationLog`,不验证药品归属 | 可给他人药品写入服药记录 | 先查 `Medication.Id == medId && UserId == userId` |
|
|
||||||
| P1 | `backend/src/Health.WebApi/Endpoints/medication_endpoints.cs` | `/medications/{id}/confirm` 查 existing log 未限制 `UserId`,也未先确认药品归属 | 可能误删/写入不属于当前用户药品的日志 | 先查药品归属,再按 `MedicationId + UserId` 查日志 |
|
|
||||||
| P1 | `backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs` | 医生端已鉴权,但患者详情/报告/随访操作多处只按 id 查询 | 医生可能访问或修改非自己负责患者的数据 | 所有医生端资源都按医生关联患者过滤 |
|
|
||||||
| P1 | `backend/src/Health.WebApi/Endpoints/file_endpoints.cs` | 上传缺少大小、类型、内容校验,且返回结构与前端不匹配 | 恶意文件/超大文件风险,前端图片上传链路失败 | 加限制、白名单、URL 返回和访问控制 |
|
|
||||||
| P1 | `health_app/lib/pages/diet/diet_capture_page.dart` | `_fieldCtrls` 缓存 controller,但页面没有 dispose | 多次进入饮食分析页会泄漏 TextEditingController | 在 State `dispose()` 中遍历释放 |
|
|
||||||
| P1 | `health_app/lib/providers/consultation_provider.dart` | Hub/轮询 stop 需要手动调用,provider 自身没有自动释放 | 离开页面后可能继续 SignalR 或 5 秒轮询 | Notifier build 中注册 `ref.onDispose(stop)` |
|
|
||||||
| P1 | `health_app/lib/providers/chat_provider.dart` | SSE `_subscription` 和 timer 没看到 provider dispose 释放 | 聊天流中断/页面销毁后可能残留监听 | 注册 `ref.onDispose`,切换会话时取消旧流 |
|
|
||||||
| P1 | `backend/src/Health.WebApi/Program.cs` | `MapHub("/hubs/consultation")` 未 RequireAuthorization | 即使 API 鉴权,实时通道仍裸露 | Hub 映射处加鉴权并处理 token 传递 |
|
|
||||||
|
|
||||||
### 11.2 旧问题中已经变化或需要修正的判断
|
|
||||||
|
|
||||||
这些点在旧 `BUG_REVIEW.md` 里出现过,但当前代码已经不是原始状态,后续不要按旧结论机械修:
|
|
||||||
|
|
||||||
- `doctor_endpoints.cs` 不是“零授权”了:当前已有 `.RequireAuthorization()` 和医生角色过滤。真正问题是医生端数据授权粒度不够,部分详情/报告/随访接口没有限制到当前医生负责的患者。
|
|
||||||
- `open_ai_compatible_client.cs` 的 Vision content 当前已经是 `Content = contentParts`,不是把多模态数组序列化成字符串。旧的 VLM 序列化 bug 看起来已修复。
|
|
||||||
- `diet_agent_handler.cs` 和 `consultation_agent_handler.cs` 当前没有声明未实现工具,而是主动缩减为档案/记录查询。问题应描述为“AI Agent 能力和首页胶囊/欢迎卡片承诺不一致”,不是“声明工具但未实现”。
|
|
||||||
- `cleanup_service.cs` 当前已经先删 ConversationMessages 再删 Conversations,旧的 FK 删除顺序问题已修。
|
|
||||||
- `device_scan_page.dart` 当前 `dispose()` 已取消 scan/read/connection 订阅,不能继续作为确定泄漏 bug。仍建议检查 `OmronBleService` 的全局 provider 生命周期和断线重连边界。
|
|
||||||
- `widget_test.dart` 中 `primary == primaryLight` 的错误断言已经改掉,目前测试更大的问题是覆盖面太浅。
|
|
||||||
|
|
||||||
### 11.3 后端授权边界需要系统性重查
|
|
||||||
|
|
||||||
项目里很多接口已经 `.RequireAuthorization()`,但“已登录”不等于“有权操作这个资源”。建议建立统一规则:
|
|
||||||
|
|
||||||
| 资源 | 当前风险 | 应该怎么查 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| AI Conversation | 续写时未绑定 `UserId` | `Conversation.Id == id && Conversation.UserId == currentUserId` |
|
|
||||||
| Consultation HTTP | POST message 未绑定 `UserId` | `Consultation.Id == id && Consultation.UserId == currentUserId` |
|
|
||||||
| Consultation Hub | 入组/发消息无服务端权限判断 | Join 和 SendMessage 都查用户或医生是否有权进入该 consultation |
|
|
||||||
| ExercisePlanItem | checkin 只按 itemId | `ExercisePlanItem.Id == itemId && Item.Plan.UserId == currentUserId` |
|
|
||||||
| Medication confirm | 部分确认接口未先校验药品归属 | `Medication.Id == id && Medication.UserId == currentUserId` |
|
|
||||||
| Doctor patient detail | 医生按任意 patient id 查详情 | `User.Id == patientId && User.DoctorId == currentDoctorId` |
|
|
||||||
| Doctor report review | 医生按任意 report id 审阅 | `Report.User.DoctorId == currentDoctorId` |
|
|
||||||
| Doctor follow-up update/delete | 医生按任意 followUp id 操作 | `FollowUp.User.DoctorId == currentDoctorId` 或 `DoctorName/DoctorId` 绑定 |
|
|
||||||
|
|
||||||
建议在后端加一层可复用 helper,例如:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
static IQueryable<User> ScopePatientsToDoctor(AppDbContext db, Guid doctorId) =>
|
|
||||||
db.Users.Where(u => u.Role == "User" && u.DoctorId == doctorId);
|
|
||||||
```
|
|
||||||
|
|
||||||
所有医生端接口都从这个 scope 派生,不要每个 endpoint 手写判断。
|
|
||||||
|
|
||||||
### 11.4 API 语义和错误码问题
|
|
||||||
|
|
||||||
现在不少接口用 HTTP 200 包业务错误码,比如 401/403/404/400 都包成 `{ code, message }`。这种风格可以保留,但要注意两个问题:
|
|
||||||
|
|
||||||
- 对认证授权失败,HTTP 状态码最好仍返回 401/403,方便客户端、网关、日志系统识别。
|
|
||||||
- 业务错误码需要统一枚举,否则前端只能靠字符串判断。
|
|
||||||
|
|
||||||
建议定义统一错误码:
|
|
||||||
|
|
||||||
- `0` 成功
|
|
||||||
- `40001` 参数错误
|
|
||||||
- `40002` 登录过期
|
|
||||||
- `40003` 无权限
|
|
||||||
- `40004` 资源不存在
|
|
||||||
- `40005` 业务状态冲突
|
|
||||||
- `50000` 服务端异常
|
|
||||||
|
|
||||||
并让 `ExceptionMiddleware` 只处理意外异常,业务错误由 endpoint 明确返回。
|
|
||||||
|
|
||||||
### 11.5 数据模型和索引补充
|
|
||||||
|
|
||||||
当前 `AppDbContext` 已经配置了不少索引和枚举转换,比旧报告里“完全没有 FK/索引”的描述更好。但仍建议补:
|
|
||||||
|
|
||||||
- `RefreshToken(Token)` 唯一或普通索引:刷新 token 查询会频繁发生。
|
|
||||||
- `RefreshToken(UserId, IsRevoked, ExpiresAt)`:便于清理和会话管理。
|
|
||||||
- `Report(UserId, CreatedAt)`:报告列表按用户和时间查询。
|
|
||||||
- `FollowUp(UserId, ScheduledAt)`:随访日历、医生待办会用到。
|
|
||||||
- `DeviceToken(UserId)`:推送服务上线后需要。
|
|
||||||
- `Consultation(UserId, CreatedAt)` 和 `Consultation(Status, CreatedAt)`:患者历史和医生待办都会用到。
|
|
||||||
|
|
||||||
另外,核心关系建议显式配置删除行为,尤其是 User 删除时关联 Consultation、Report、Conversation、MedicationLog 的级联或手动删除策略。
|
|
||||||
|
|
||||||
### 11.6 AI Agent 产品能力不一致
|
|
||||||
|
|
||||||
现在首页上有多个 agent/胶囊入口,但后端能力不完全一致:
|
|
||||||
|
|
||||||
- 饮食 Agent 当前只保留健康档案查询,真正饮食识别在专门图片接口。
|
|
||||||
- 问诊 Agent 当前只保留健康记录和档案查询,转医生走专门问诊流程。
|
|
||||||
- 用药/运动 Agent 有创建、查询、确认能力,但确认工具存在所有权校验问题。
|
|
||||||
- 通用 Agent 如果聚合多个工具,需要明确“哪些动作会写数据,哪些只是查询”。
|
|
||||||
|
|
||||||
建议 UI 文案和后端能力统一:
|
|
||||||
|
|
||||||
- 欢迎卡片不要暗示当前 agent 能完成它实际上做不到的写操作。
|
|
||||||
- 会写入健康数据、药品、运动计划、档案的 AI 动作,必须有确认卡片。
|
|
||||||
- AI 工具调用结果应返回结构化状态,前端不要只靠自然语言判断成功。
|
|
||||||
|
|
||||||
### 11.7 前端状态和生命周期问题
|
|
||||||
|
|
||||||
> 2026-06-20 更新:健康最新值、用药列表、用药提醒、当前运动计划已改为 `autoDispose`;AI 确认写入后会同时刷新这些核心数据。报告和饮食使用各自 Notifier/页面加载流程,尚未强行合并成一个全局缓存。
|
|
||||||
|
|
||||||
前端现在能跑起来,但长期运行会有状态残留风险:
|
|
||||||
|
|
||||||
- `ConsultationChatNotifier` 里 Hub 和轮询 timer 需要自动释放。建议 `build()` 中调用 `ref.onDispose(stop)`。
|
|
||||||
- `ChatNotifier` 的 SSE 订阅、流式响应 timer 需要在 provider 销毁、切换 agent、重新发送时取消。
|
|
||||||
- 饮食页 `_fieldCtrls` 需要 `dispose()`,否则每次识别食物后 controller 累积。
|
|
||||||
- 多个 `FutureProvider` 没有 `autoDispose`,页面级数据会缓存很久。健康最新值、药品提醒、当前运动计划这类数据建议明确刷新策略。
|
|
||||||
- 很多页面删除后只本地 `_load()`,没有 `ref.invalidate(...)`,跨页面缓存可能不同步。
|
|
||||||
|
|
||||||
### 11.8 UI 深层问题:不是再加渐变,而是建立层级
|
|
||||||
|
|
||||||
最近 UI 调整集中在侧边栏、欢迎卡片、饮食页、设置页、个人信息页等。颜色已经比最初丰富,但仍需要注意:
|
|
||||||
|
|
||||||
- 颜色角色要固定:蓝色用于主行动/健康状态,橙色用于饮食,紫色用于报告,绿色用于记录/恢复,青色用于设备或运动,浅红用于提醒/风险。
|
|
||||||
- 欢迎卡片和胶囊按钮的图标必须同语义、同线宽、同背景形状。用户已经多次指出“不只是颜色一样,图标内容也要一样”,这说明视觉一致性比单个渐变更重要。
|
|
||||||
- 健康仪表盘应优先展示数字、单位、状态、更新时间。图标可以弱化,避免抢数字层级。
|
|
||||||
- 设置页不应只是按钮列表,应分为账号、安全、通知、设备、隐私、关于。
|
|
||||||
- 个人信息页应像正式档案:基础信息、医疗信息、健康偏好、绑定医生、账号安全分区展示。
|
|
||||||
- 功能入口两行三列是合理的,但每个入口不要堆摘要,图标 + 名称 + 必要状态即可。
|
|
||||||
|
|
||||||
### 11.9 功能缺口再细化
|
|
||||||
|
|
||||||
| 模块 | 当前缺口 | 建议 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 饮食记录 | 已有识别和保存,但历史记录编辑能力弱 | 支持编辑食物、份量、热量、餐次;支持从历史复制 |
|
|
||||||
| 报告管理 | 上传后异步 AI 分析,但失败/处理中状态不够细 | 增加 pending/analyzing/failed/retry 状态和轮询刷新 |
|
|
||||||
| 问诊 | 患者端创建即新会话,历史问诊入口弱 | 增加问诊历史、继续问诊、关闭问诊、评价医生 |
|
|
||||||
| 用药 | 提醒后台只记录日志,未实际推送 | 接入推送,支持漏服/补服/跳过原因 |
|
|
||||||
| 运动 | 有计划和打卡,但计划解释和详情不足 | 计划详情页、运动禁忌、完成趋势 |
|
|
||||||
| 健康日历 | 汇总用药/运动/随访,但和打卡状态联动有限 | 日历上直接展示已完成、未完成、逾期 |
|
|
||||||
| 医生端 | 已有工作台,但患者范围和流程需加强 | 风险患者排序、未读消息、待审报告、随访待办 |
|
|
||||||
| 管理员端 | 医生管理已有基础 | 增加操作审计、禁用账号、数据统计 |
|
|
||||||
|
|
||||||
### 11.10 更细的整改顺序
|
|
||||||
|
|
||||||
第一批必须先修安全:
|
|
||||||
|
|
||||||
1. 移除生产 `devCode` 和前端自动填充。
|
|
||||||
2. 修 SSE 认证,不再解析未验证 JWT。
|
|
||||||
3. AI conversation 按用户归属查询。
|
|
||||||
4. Consultation Hub 加鉴权、入组校验、senderType 服务端判定。
|
|
||||||
5. Consultation HTTP 发消息校验当前用户。
|
|
||||||
6. Exercise/Medication 的普通接口和 AI 工具都补所有权校验。
|
|
||||||
7. 医生端详情、报告、随访接口限制到当前医生负责患者。
|
|
||||||
|
|
||||||
第二批修接口契约和稳定性:
|
|
||||||
|
|
||||||
1. 文件上传返回 `{ id, name, size, url, contentType }`。
|
|
||||||
2. 前端 `uploadFile` 兼容后端 envelope 和 list 返回,失败要提示。
|
|
||||||
3. 饮食页 controller dispose。
|
|
||||||
4. Chat/Consultation provider 注册 `ref.onDispose`。
|
|
||||||
5. 后端补上传大小/类型限制。
|
|
||||||
6. 关闭生产 body 日志。
|
|
||||||
|
|
||||||
第三批做 UI 体系:
|
|
||||||
|
|
||||||
1. 把颜色、渐变、圆角、阴影、图标背景抽成设计 token。
|
|
||||||
2. 侧边栏、个人信息、设置、饮食分析页按同一设计语言重做。
|
|
||||||
3. 首页欢迎卡片和胶囊入口统一图标语义。
|
|
||||||
4. 健康仪表盘增加更新时间、单位、状态文字。
|
|
||||||
5. 对中老年用户做字号、对比度、触控面积检查。
|
|
||||||
|
|
||||||
第四批补测试:
|
|
||||||
|
|
||||||
1. 后端加越权测试:用户 A 不能操作用户 B 的 conversation/consultation/exercise/medication。
|
|
||||||
2. 加短信验证码生产环境不返回测试。
|
|
||||||
3. 加 SignalR Hub 入组权限测试。
|
|
||||||
4. 加文件上传类型/大小测试。
|
|
||||||
5. Flutter 加饮食保存、问诊连接释放、上传失败 UI 的 widget/provider 测试。
|
|
||||||
|
|
||||||
## 12. 总体建议
|
|
||||||
|
|
||||||
这个项目最有价值的方向是“围绕患者长期健康数据做 AI 辅助管理”,不是简单堆功能入口。接下来建议把项目重心从“页面多”转为“核心闭环扎实”:
|
|
||||||
|
|
||||||
- 患者每天愿意记录。
|
|
||||||
- 数据能被看懂。
|
|
||||||
- 异常能被发现。
|
|
||||||
- AI 建议能被修正和追踪。
|
|
||||||
- 医生能看到有价值的摘要。
|
|
||||||
- 安全和隐私经得起真实使用。
|
|
||||||
|
|
||||||
UI 上不要继续单页单独调色,应该先统一设计体系,再逐步替换页面。工程上先修安全和接口契约,再做大面积美化。这样项目会从“原型功能很多”变成“真正像一个可信赖的健康产品”。
|
|
||||||
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.
|
||||||
|
|
||||||
688
docs/superpowers/plans/2026-07-10-apple-sign-in.md
Normal file
@@ -0,0 +1,688 @@
|
|||||||
|
# Apple Sign-In 集成实施方案
|
||||||
|
|
||||||
|
> **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:** 为健康管家 App 集成 Sign in with Apple,让 iOS 用户可以通过 Apple ID 一键登录/注册。
|
||||||
|
|
||||||
|
**Architecture:** 后端新增 `POST /api/auth/apple-login` 端点,验证 Apple 返回的 identityToken(JWT),查找或创建 User 记录(无需手机号),返回 JWT token。前端添加 `sign_in_with_apple` Flutter 包,在登录页放置 Apple 登录按钮。
|
||||||
|
|
||||||
|
**Tech Stack:** Flutter 3.44 + Dart 3.12 | C# .NET 10 + EF Core + PostgreSQL | sign_in_with_apple 包 | Apple RSA 公钥验证
|
||||||
|
|
||||||
|
**设计决策:**
|
||||||
|
- User.Phone 改为可空(Apple 用户无手机号),PostgreSQL 对多个 NULL 值不违反唯一索引
|
||||||
|
- 新增 `AppleUserId` 字段(可空字符串 + 唯一索引)标识 Apple 用户
|
||||||
|
- Apple 登录首次无需绑定医生(doctorId 可为空),用户可在个人中心后续设置
|
||||||
|
- Apple identityToken 验证:缓存 Apple 公钥,本地验签,验证 audience/issuer/有效期
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: 后端 — User 实体 + DB 迁移
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/src/Health.Domain/Entities/user.cs`
|
||||||
|
- Modify: `backend/src/Health.Infrastructure/Data/app_db_context.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: User 实体添加 AppleUserId 字段,Phone 改为可空**
|
||||||
|
|
||||||
|
`backend/src/Health.Domain/Entities/user.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace Health.Domain.Entities;
|
||||||
|
|
||||||
|
public sealed class User
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string? Phone { get; set; } // 改为可空,Apple 用户无手机号
|
||||||
|
public string? AppleUserId { get; set; } // Apple 唯一标识
|
||||||
|
public string Role { get; set; } = "User";
|
||||||
|
public Guid? DoctorId { get; set; }
|
||||||
|
public string? Name { get; set; }
|
||||||
|
// ... 其余字段不变
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: AppDbContext 添加 AppleUserId 配置**
|
||||||
|
|
||||||
|
`backend/src/Health.Infrastructure/Data/app_db_context.cs` 的 User 配置段:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Entity<User>(e =>
|
||||||
|
{
|
||||||
|
e.HasIndex(u => u.Phone).IsUnique();
|
||||||
|
e.HasIndex(u => u.AppleUserId).IsUnique(); // 新增
|
||||||
|
e.Property(u => u.Role).HasMaxLength(32).HasDefaultValue("User");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 验证编译**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend/src/Health.WebApi && dotnet build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: 后端 — Apple JWT 验证服务
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `backend/src/Health.Infrastructure/Services/apple_token_validator.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 创建 AppleTokenValidator**
|
||||||
|
|
||||||
|
`backend/src/Health.Infrastructure/Services/apple_token_validator.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Apple IdentityToken 验证:下载公钥、验签、检查声明
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AppleTokenValidator
|
||||||
|
{
|
||||||
|
private const string AppleKeysUrl = "https://appleid.apple.com/auth/keys";
|
||||||
|
private const string AppleIssuer = "https://appleid.apple.com";
|
||||||
|
private static readonly HttpClient _http = new();
|
||||||
|
private static List<JsonWebKey>? _cachedKeys;
|
||||||
|
private static DateTime _keysExpiry = DateTime.MinValue;
|
||||||
|
private static readonly SemaphoreSlim _lock = new(1, 1);
|
||||||
|
|
||||||
|
private readonly string _clientId;
|
||||||
|
|
||||||
|
public AppleTokenValidator(IConfiguration config)
|
||||||
|
{
|
||||||
|
_clientId = config["APPLE_CLIENT_ID"] ?? "com.datalumina.YYA";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证 Apple identityToken,返回 sub(用户唯一标识)
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string> ValidateAsync(string identityToken, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var handler = new JwtSecurityTokenHandler();
|
||||||
|
// 先解码 header 获取 kid
|
||||||
|
var rawToken = handler.ReadJwtToken(identityToken);
|
||||||
|
var kid = rawToken.Header.Kid;
|
||||||
|
|
||||||
|
var keys = await GetKeysAsync(ct);
|
||||||
|
var key = keys.FirstOrDefault(k => k.KeyId == kid)
|
||||||
|
?? throw new SecurityTokenException($"No matching key for kid: {kid}");
|
||||||
|
|
||||||
|
var validationParams = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidIssuer = AppleIssuer,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidAudiences = [_clientId, "com.datalumina.YYA.signin"],
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
IssuerSigningKey = key,
|
||||||
|
ClockSkew = TimeSpan.FromMinutes(1),
|
||||||
|
};
|
||||||
|
|
||||||
|
var principal = handler.ValidateToken(identityToken, validationParams, out _);
|
||||||
|
var sub = principal.FindFirst("sub")?.Value;
|
||||||
|
if (string.IsNullOrWhiteSpace(sub))
|
||||||
|
throw new SecurityTokenException("Missing 'sub' claim in Apple identityToken");
|
||||||
|
|
||||||
|
return sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<List<JsonWebKey>> GetKeysAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
|
||||||
|
|
||||||
|
await _lock.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
|
||||||
|
|
||||||
|
var response = await _http.GetStringAsync(AppleKeysUrl, ct);
|
||||||
|
var jwks = new JsonWebKeySet(response);
|
||||||
|
_cachedKeys = jwks.Keys.ToList();
|
||||||
|
_keysExpiry = DateTime.UtcNow.AddHours(6); // 缓存 6 小时
|
||||||
|
return _cachedKeys;
|
||||||
|
}
|
||||||
|
finally { _lock.Release(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 注册服务到 DI**
|
||||||
|
|
||||||
|
在 `backend/src/Health.WebApi/Program.cs` 的 services 注册区添加:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Services.AddSingleton<AppleTokenValidator>();
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 验证编译**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend/src/Health.WebApi && dotnet build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: 后端 — AuthService 添加 Apple 登录
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/src/Health.Application/Auth/AuthContracts.cs`
|
||||||
|
- Modify: `backend/src/Health.Infrastructure/Auth/AuthService.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 添加 AppleLoginCommand DTO 和接口方法**
|
||||||
|
|
||||||
|
`backend/src/Health.Application/Auth/AuthContracts.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace Health.Application.Auth;
|
||||||
|
|
||||||
|
public sealed record AuthResult(int Code, object? Data, string? Message = null);
|
||||||
|
public sealed record RegisterCommand(string Phone, string SmsCode, string Name, Guid DoctorId);
|
||||||
|
public sealed record AppleLoginCommand(string IdentityToken, string? AuthorizationCode, string? Name); // 新增
|
||||||
|
|
||||||
|
public interface IAuthService
|
||||||
|
{
|
||||||
|
Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct);
|
||||||
|
Task<AuthResult> RegisterAsync(RegisterCommand command, CancellationToken ct);
|
||||||
|
Task<AuthResult> LoginAsync(string phone, string smsCode, CancellationToken ct);
|
||||||
|
Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct); // 新增
|
||||||
|
Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct);
|
||||||
|
Task LogoutAsync(string refreshToken, CancellationToken ct);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: AuthService 实现 AppleLoginAsync**
|
||||||
|
|
||||||
|
`backend/src/Health.Infrastructure/Auth/AuthService.cs`:
|
||||||
|
|
||||||
|
在类的顶部依赖注入区添加 `AppleTokenValidator`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed class AuthService(
|
||||||
|
AppDbContext db,
|
||||||
|
JwtProvider jwt,
|
||||||
|
SmsService sms,
|
||||||
|
AppleTokenValidator appleValidator // 新增
|
||||||
|
) : IAuthService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db = db;
|
||||||
|
private readonly JwtProvider _jwt = jwt;
|
||||||
|
private readonly SmsService _sms = sms;
|
||||||
|
private readonly AppleTokenValidator _appleValidator = appleValidator;
|
||||||
|
// ... 现有代码不变
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `LogoutAsync` 方法之后添加新方法,并修改 `AddTokens` 的 phone 参数类型为 `string?`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// 1. 验证 Apple identityToken
|
||||||
|
string appleUserId;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
appleUserId = await _appleValidator.ValidateAsync(command.IdentityToken, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Error(40005, $"Apple 登录验证失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 查找已有用户
|
||||||
|
var existingUser = await _db.Users.FirstOrDefaultAsync(x => x.AppleUserId == appleUserId, ct);
|
||||||
|
if (existingUser != null)
|
||||||
|
{
|
||||||
|
// 已有用户 → 直接登录
|
||||||
|
existingUser.UpdatedAt = DateTime.UtcNow;
|
||||||
|
if (!string.IsNullOrWhiteSpace(command.Name)) existingUser.Name ??= command.Name;
|
||||||
|
var tokens = AddTokens(existingUser.Id, existingUser.Phone, existingUser.Role);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return new AuthResult(0, new
|
||||||
|
{
|
||||||
|
tokens.accessToken, tokens.refreshToken,
|
||||||
|
user = new { existingUser.Id, existingUser.Phone, existingUser.Role, existingUser.Name, existingUser.Gender, existingUser.AvatarUrl, isNew = false }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 新用户 → 创建账户(无需手机号、无需选医生)
|
||||||
|
var newUser = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Phone = null, // Apple 用户未绑定手机
|
||||||
|
AppleUserId = appleUserId,
|
||||||
|
Role = "User",
|
||||||
|
Name = command.Name,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
_db.Users.Add(newUser);
|
||||||
|
_db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = newUser.Id });
|
||||||
|
_db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = newUser.Id });
|
||||||
|
var newTokens = AddTokens(newUser.Id, null, newUser.Role);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return new AuthResult(0, new
|
||||||
|
{
|
||||||
|
newTokens.accessToken, newTokens.refreshToken,
|
||||||
|
user = new { newUser.Id, Phone = (string?)null, newUser.Role, newUser.Name, isNew = true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
同时修改 `AddTokens` 方法签名,phone 改为可空:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private (string accessToken, string refreshToken) AddTokens(Guid userId, string? phone, string role)
|
||||||
|
{
|
||||||
|
var access = _jwt.GenerateAccessToken(userId, phone ?? "", role);
|
||||||
|
var refresh = _jwt.GenerateRefreshToken();
|
||||||
|
_db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), UserId = userId, Token = refresh, ExpiresAt = DateTime.UtcNow.AddDays(30) });
|
||||||
|
return (access, refresh);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明**: `AddTokens` 的 phone 参数改为 `string?`,`GenerateAccessToken` 接收到空字符串(Apple 用户无需手机号)。`RefreshAsync` 和 `RegisterAsync` 中现有的 `AddTokens` 调用无需修改(传入 `string?` 的 `user.Phone` 自动匹配)。Admin 的 `AddTokens(AdminId, AdminPhone, "Admin")`(`AdminPhone` 是 `string` 常量)也无需修改。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 验证编译**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend/src/Health.WebApi && dotnet build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: 后端 — 添加 Apple 登录端点
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/src/Health.WebApi/Endpoints/auth_endpoints.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 添加 AppleLoginRequest 和端点**
|
||||||
|
|
||||||
|
`backend/src/Health.WebApi/Endpoints/auth_endpoints.cs`:
|
||||||
|
|
||||||
|
在现有端点中添加:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 在 MapAuthEndpoints 方法内、logout 端点之后添加:
|
||||||
|
app.MapPost("/api/auth/apple-login", async (AppleLoginRequest request, IAuthService auth, CancellationToken ct) =>
|
||||||
|
ToResult(await auth.AppleLoginAsync(new AppleLoginCommand(request.IdentityToken, request.AuthorizationCode, request.Name), ct)));
|
||||||
|
```
|
||||||
|
|
||||||
|
在文件末尾添加 DTO:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed record AppleLoginRequest(string IdentityToken, string? AuthorizationCode, string? Name);
|
||||||
|
```
|
||||||
|
|
||||||
|
文件最终结果为:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Health.Application.Auth;
|
||||||
|
|
||||||
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
|
public static class AuthEndpoints
|
||||||
|
{
|
||||||
|
public static void MapAuthEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
app.MapPost("/api/auth/send-sms", async (SendSmsRequest request, IAuthService auth, CancellationToken ct) =>
|
||||||
|
ToResult(await auth.SendCodeAsync(request.Phone, app.Environment.IsDevelopment(), ct)));
|
||||||
|
app.MapPost("/api/auth/register", async (RegisterRequest request, IAuthService auth, CancellationToken ct) =>
|
||||||
|
ToResult(await auth.RegisterAsync(new RegisterCommand(request.Phone, request.SmsCode, request.Name, request.DoctorId), ct)));
|
||||||
|
app.MapPost("/api/auth/login", async (LoginRequest request, IAuthService auth, CancellationToken ct) =>
|
||||||
|
ToResult(await auth.LoginAsync(request.Phone, request.SmsCode, ct)));
|
||||||
|
app.MapPost("/api/auth/apple-login", async (AppleLoginRequest request, IAuthService auth, CancellationToken ct) =>
|
||||||
|
ToResult(await auth.AppleLoginAsync(new AppleLoginCommand(request.IdentityToken, request.AuthorizationCode, request.Name), ct)));
|
||||||
|
app.MapPost("/api/auth/refresh", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
||||||
|
ToResult(await auth.RefreshAsync(request.RefreshToken, ct)));
|
||||||
|
app.MapPost("/api/auth/logout", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
await auth.LogoutAsync(request.RefreshToken, ct);
|
||||||
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult ToResult(AuthResult result) =>
|
||||||
|
Results.Ok(new { code = result.Code, data = result.Data, message = result.Message });
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record SendSmsRequest(string Phone);
|
||||||
|
public sealed record RegisterRequest(string Phone, string SmsCode, string Name, Guid DoctorId);
|
||||||
|
public sealed record LoginRequest(string Phone, string SmsCode);
|
||||||
|
public sealed record AppleLoginRequest(string IdentityToken, string? AuthorizationCode, string? Name);
|
||||||
|
public sealed record RefreshRequest(string RefreshToken);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 验证编译**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend/src/Health.WebApi && dotnet build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: 后端 — 重启并测试端点连通性
|
||||||
|
|
||||||
|
- [ ] **Step 1: 重启后端服务**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend/src/Health.WebApi && dotnet run &
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 测试无 token 调用(预期返回 40005,token 无效)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -w "\nHTTP %{http_code}" -X POST "http://localhost:5000/api/auth/apple-login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"identityToken":"invalid","name":"test"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: HTTP 200, code=40005, message 包含"验证失败"
|
||||||
|
|
||||||
|
- [ ] **Step 3: 测试真实场景**
|
||||||
|
|
||||||
|
此时无法用 curl 直接测(需要真实的 Apple identityToken)。稍后在 Task 8 中从 App 端联合验证。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: 前端 — 添加 sign_in_with_apple 依赖
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `health_app/pubspec.yaml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 添加依赖**
|
||||||
|
|
||||||
|
`health_app/pubspec.yaml`,在 dependencies 区添加:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Apple 登录
|
||||||
|
sign_in_with_apple: ^6.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 安装依赖**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd health_app && flutter pub get
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: 前端 — AuthProvider 添加 Apple 登录方法
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `health_app/lib/providers/auth_provider.dart`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 添加 appleLogin 方法**
|
||||||
|
|
||||||
|
`health_app/lib/providers/auth_provider.dart`,在 `login()` 方法之后、`logout()` 方法之前添加:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
/// Apple 登录
|
||||||
|
Future<String?> appleLogin(String identityToken, String? authorizationCode, String? name) async {
|
||||||
|
try {
|
||||||
|
final api = ref.read(apiClientProvider);
|
||||||
|
final response = await api.post(
|
||||||
|
'/api/auth/apple-login',
|
||||||
|
data: {
|
||||||
|
'identityToken': identityToken,
|
||||||
|
if (authorizationCode != null) 'authorizationCode': authorizationCode,
|
||||||
|
if (name != null && name.isNotEmpty) 'name': name,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final data = response.data['data'];
|
||||||
|
if (data == null) return response.data['message'] ?? 'Apple 登录失败';
|
||||||
|
|
||||||
|
await api.saveTokens(data['accessToken'], data['refreshToken']);
|
||||||
|
final user = data['user'];
|
||||||
|
state = AuthState(
|
||||||
|
isLoggedIn: true,
|
||||||
|
isLoading: false,
|
||||||
|
user: UserInfo(
|
||||||
|
id: user['id'] ?? '',
|
||||||
|
phone: user['phone'] ?? '',
|
||||||
|
role: user['role'] ?? 'User',
|
||||||
|
name: user['name'],
|
||||||
|
avatarUrl: user['avatarUrl'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
return 'Apple 登录失败: $e';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**: `UserInfo` 的 `phone` 字段当前是 `required String`。Apple 用户可能无手机号。`user['phone']` 可能是 null。在 login() 方法中 Phone 断言 `phone: user['phone'] ?? ''` 已有兜底,appleLogin 保持一致。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 编译检查**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd health_app && flutter analyze lib/providers/auth_provider.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: 前端 — 登录页添加 Apple 登录按钮
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `health_app/lib/pages/auth/login_page.dart`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 添加 Apple 登录按钮组件和点击逻辑**
|
||||||
|
|
||||||
|
`health_app/lib/pages/auth/login_page.dart`:
|
||||||
|
|
||||||
|
顶部添加 import:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `_LoginPageState` 类中添加 Apple 登录方法(在 `_startCountdown` 方法之后):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Future<void> _appleSignIn() async {
|
||||||
|
if (_loading) return;
|
||||||
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
_error = null;
|
||||||
|
_successMsg = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final credential = await SignInWithApple.getAppleIDCredential(
|
||||||
|
scopes: [
|
||||||
|
AppleIDAuthorizationScopes.fullName,
|
||||||
|
],
|
||||||
|
webAuthenticationOptions: WebAuthenticationOptions(
|
||||||
|
clientId: 'com.datalumina.YYA.signin',
|
||||||
|
redirectUri: Uri.parse('https://erpapi.datalumina.cn/xiaomai/api/auth/apple-login'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final identityToken = credential.identityToken;
|
||||||
|
if (identityToken == null) {
|
||||||
|
setState(() {
|
||||||
|
_loading = false;
|
||||||
|
_error = 'Apple 登录未返回身份信息,请重试';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final name = credential.givenName != null && credential.familyName != null
|
||||||
|
? '${credential.familyName}${credential.givenName}'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final err = await ref.read(authProvider.notifier).appleLogin(
|
||||||
|
identityToken,
|
||||||
|
credential.authorizationCode,
|
||||||
|
name,
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _loading = false);
|
||||||
|
if (err != null) {
|
||||||
|
setState(() => _error = err);
|
||||||
|
} else {
|
||||||
|
_goHome();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_loading = false;
|
||||||
|
if (e is SignInWithAppleAuthorizationException) {
|
||||||
|
_error = 'Apple 登录已取消';
|
||||||
|
} else {
|
||||||
|
_error = 'Apple 登录失败: $e';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `_LoginFormCard` 的 children 中,在 `_PrimaryButton` 和"去注册"链接之间(`_PrimaryButton` 之后,`const SizedBox(height: 16)` 之前)插入 Apple 按钮:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
// --- Apple 登录按钮 ---
|
||||||
|
SignInWithAppleButton(
|
||||||
|
onPressed: _appleSignIn,
|
||||||
|
style: SignInWithAppleButtonStyle.whiteOutline,
|
||||||
|
height: 48,
|
||||||
|
cornerRadius: 14,
|
||||||
|
),
|
||||||
|
// --- 结束 ---
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
```
|
||||||
|
|
||||||
|
即在 build 方法中 `_LoginFormCard` child 的末尾(`_PrimaryButton` 之后、`GestureDetector` "去注册" 之前):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// ... 上面是 _PrimaryButton 的 const SizedBox(height: 22) 和 _PrimaryButton 本身
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
SignInWithAppleButton(
|
||||||
|
onPressed: _appleSignIn,
|
||||||
|
style: SignInWithAppleButtonStyle.whiteOutline,
|
||||||
|
height: 48,
|
||||||
|
cornerRadius: 14,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => setState(() {
|
||||||
|
_isLogin = !_isLogin;
|
||||||
|
_error = null;
|
||||||
|
_successMsg = null;
|
||||||
|
}),
|
||||||
|
// ... 去注册/去登录 链接
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 1 补充**: `_submit()` 方法中也需要加 `_loading` 检查,确保 Apple 按钮点击期间短信登录不响应。现有 `_submit` 已有 `setState(() => _loading = true)`,问题不大。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 编译检查**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd health_app && flutter analyze lib/pages/auth/login_page.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 编译 iOS 验证无语法错误**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd health_app && flutter build ios --debug --no-codesign
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: iOS — Xcode 配置 Sign in with Apple
|
||||||
|
|
||||||
|
- [ ] **Step 1: 在 Xcode 中启用 Sign in with Apple Capability**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
open health_app/ios/Runner.xcworkspace
|
||||||
|
```
|
||||||
|
|
||||||
|
在 Xcode 中操作:
|
||||||
|
1. 选择 Runner target → Signing & Capabilities
|
||||||
|
2. 点击 "+ Capability" → 选择 "Sign in with Apple"
|
||||||
|
3. 确认 .entitlements 文件自动生成了 `com.apple.developer.applesignin` entitlement
|
||||||
|
|
||||||
|
这也会自动在 `project.pbxproj` 中写入相关配置,后续 git diff 可以看到变化。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 验证 .entitlements 文件**
|
||||||
|
|
||||||
|
确认 `health_app/ios/Runner/Runner.entitlements` 包含:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<key>com.apple.developer.applesignin</key>
|
||||||
|
<array>
|
||||||
|
<string>Default</string>
|
||||||
|
</array>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10: App Store Connect 配置
|
||||||
|
|
||||||
|
- [ ] **Step 1: 在 App Store Connect 中启用 Sign in with Apple**
|
||||||
|
|
||||||
|
1. 打开 [App Store Connect](https://appstoreconnect.apple.com)
|
||||||
|
2. 进入 App 记录 → App 隐私 → Sign in with Apple
|
||||||
|
3. 确认已为 `com.datalumina.YYA` 这个 App ID 启用了 Sign in with Apple capability
|
||||||
|
|
||||||
|
如果还未创建 App Store Connect 上的 App 记录,需要在 Certificates, Identifiers & Profiles 中为 App ID 手动勾选 "Sign in with Apple" capability。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 验证计划
|
||||||
|
|
||||||
|
**后端单元验证:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 后端编译通过
|
||||||
|
cd backend/src/Health.WebApi && dotnet build
|
||||||
|
|
||||||
|
# 2. 启动后端
|
||||||
|
cd backend/src/Health.WebApi && dotnet run
|
||||||
|
|
||||||
|
# 3. 测试 Apple 登录端点(无 token 场景)
|
||||||
|
curl -s -X POST "http://localhost:5000/api/auth/apple-login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"identityToken":"test","name":"测试"}'
|
||||||
|
# 预期: HTTP 200, body 包含 code=40005
|
||||||
|
|
||||||
|
# 4. 测试现有发送短信端点未被影响
|
||||||
|
curl -s -X POST "http://localhost:5000/api/auth/send-sms" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"phone":"13800138000"}'
|
||||||
|
# 预期: HTTP 200, code=0
|
||||||
|
```
|
||||||
|
|
||||||
|
**前端验证:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 编译检查
|
||||||
|
cd health_app && flutter analyze
|
||||||
|
|
||||||
|
# 2. iOS 构建通过
|
||||||
|
flutter build ios --debug --no-codesign
|
||||||
|
|
||||||
|
# 3. 在模拟器上运行
|
||||||
|
flutter run -d "iPhone 17 Pro"
|
||||||
|
# 验证:登录页显示 Apple 登录按钮;点击后弹出 Apple ID 授权界面
|
||||||
|
```
|
||||||
|
|
||||||
|
**端到端验证(模拟器):**
|
||||||
|
1. 模拟器上启动 App → 进入登录页
|
||||||
|
2. 点击 Apple 登录按钮 → Apple 授权弹窗出现 → 选择"继续"
|
||||||
|
3. 后端验证 identityToken → 创建新用户或登录已有 → 返回 JWT → App 进入首页
|
||||||
|
4. 二次打开 App → 如果之前 Apple 登录成功 + refresh_token 有效 → 自动恢复登录态
|
||||||
|
|
||||||
|
**DB 验证:**
|
||||||
|
```sql
|
||||||
|
-- 确认 Apple 用户创建成功
|
||||||
|
SELECT id, phone, apple_user_id, name, role FROM "Users" WHERE apple_user_id IS NOT NULL;
|
||||||
|
```
|
||||||
@@ -5,6 +5,13 @@ plugins {
|
|||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载正式签名配置
|
||||||
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
val keystoreProperties = java.util.Properties()
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystoreProperties.load(keystorePropertiesFile.inputStream())
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.datalumina.YYA"
|
namespace = "com.datalumina.YYA"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
@@ -20,21 +27,28 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
|
||||||
applicationId = "com.datalumina.YYA"
|
applicationId = "com.datalumina.YYA"
|
||||||
// You can update the following values to match your application needs.
|
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 正式签名配置
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keyAlias = keystoreProperties["keyAlias"] as String?
|
||||||
|
keyPassword = keystoreProperties["keyPassword"] as String?
|
||||||
|
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
|
||||||
|
storePassword = keystoreProperties["storePassword"] as String?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
signingConfig = signingConfigs.getByName("release")
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
||||||
<application
|
<application
|
||||||
android:label="health_app"
|
android:label="小脉健康"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 39 KiB |
@@ -1,18 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
<!-- 启动页强制亮色:即使系统处于深色模式,也用白底品牌图,避免黑色闪屏 -->
|
||||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
<!-- Show a splash screen on the activity. Automatically removed when
|
|
||||||
the Flutter engine draws its first frame -->
|
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
</style>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
<!-- Flutter 引擎初始化期间的窗口背景:白底,避免深色模式下闪黑 -->
|
||||||
This theme determines the color of the Android Window while your
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
<item name="android:windowBackground">@android:color/white</item>
|
||||||
running.
|
|
||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
12
health_app/appinfo
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
IOS:
|
||||||
|
SHA1:
|
||||||
|
B1:17:1D:FD:7E:CA:CE:61:65:C2:2D:8B:A0:7C:57:5A:EB:EF:20:5E
|
||||||
|
SHA256:
|
||||||
|
D0:1E:AF:24:40:06:42:1B:FA:86:5E:3D:90:ED:FA:D7:3F:2D:E2:99:52:60:58:F4:85:4F:18:95:1E:B9:CC:96
|
||||||
|
MD5:
|
||||||
|
15:3B:89:ED:6B:C2:1B:6A:F9:6D:E0:71:44:54:CB:0F
|
||||||
|
|
||||||
|
Android:
|
||||||
|
SHA1: 48:48:22:D2:DA:B5:0D:4B:3F:D6:CA:8C:77:B1:36:B5:B5:8F:47:D1
|
||||||
|
SHA256: 0F:10:E7:B5:D0:8A:1E:4B:A6:67:EB:D4:D4:7F:00:C1:08:7E:EB:46:72:51:73:B2:AC:30:DB:69:18:DD:15:A2
|
||||||
|
MD5:D9:75:8F:17:66:17:3F:83:49:1A:82:34:59:9A:08:25
|
||||||
BIN
health_app/assets/branding/drawer_background_v1.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 906 KiB After Width: | Height: | Size: 1.5 MiB |
BIN
health_app/assets/branding/login_background_v1.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
1
health_app/assets/images/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -20,7 +20,5 @@
|
|||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<key>MinimumOSVersion</key>
|
|
||||||
<string>13.0</string>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||||
#include "Generated.xcconfig"
|
#include "Generated.xcconfig"
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||||
#include "Generated.xcconfig"
|
#include "Generated.xcconfig"
|
||||||
|
|||||||
43
health_app/ios/Podfile
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Apple Sign-In requires CocoaPods (plugin not yet on SPM)
|
||||||
|
platform :ios, '13.0'
|
||||||
|
|
||||||
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
|
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||||
|
|
||||||
|
project 'Runner', {
|
||||||
|
'Debug' => :debug,
|
||||||
|
'Profile' => :release,
|
||||||
|
'Release' => :release,
|
||||||
|
}
|
||||||
|
|
||||||
|
def flutter_root
|
||||||
|
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
|
||||||
|
unless File.exist?(generated_xcode_build_settings_path)
|
||||||
|
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
|
||||||
|
end
|
||||||
|
|
||||||
|
File.foreach(generated_xcode_build_settings_path) do |line|
|
||||||
|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
|
||||||
|
return matches[1].strip if matches
|
||||||
|
end
|
||||||
|
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
|
||||||
|
end
|
||||||
|
|
||||||
|
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
|
||||||
|
|
||||||
|
flutter_ios_podfile_setup
|
||||||
|
|
||||||
|
target 'Runner' do
|
||||||
|
use_frameworks!
|
||||||
|
|
||||||
|
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
||||||
|
target 'RunnerTests' do
|
||||||
|
inherit! :search_paths
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
post_install do |installer|
|
||||||
|
installer.pods_project.targets.each do |target|
|
||||||
|
flutter_additional_ios_build_settings(target)
|
||||||
|
end
|
||||||
|
end
|
||||||
22
health_app/ios/Podfile.lock
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
PODS:
|
||||||
|
- Flutter (1.0.0)
|
||||||
|
- sign_in_with_apple (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
|
||||||
|
DEPENDENCIES:
|
||||||
|
- Flutter (from `Flutter`)
|
||||||
|
- sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`)
|
||||||
|
|
||||||
|
EXTERNAL SOURCES:
|
||||||
|
Flutter:
|
||||||
|
:path: Flutter
|
||||||
|
sign_in_with_apple:
|
||||||
|
:path: ".symlinks/plugins/sign_in_with_apple/ios"
|
||||||
|
|
||||||
|
SPEC CHECKSUMS:
|
||||||
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
|
sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418
|
||||||
|
|
||||||
|
PODFILE CHECKSUM: 261798c42a06ff769f68b1209723cd96e5586217
|
||||||
|
|
||||||
|
COCOAPODS: 1.16.2
|
||||||
@@ -11,9 +11,12 @@
|
|||||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
|
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
|
B79095E86F02076D8BE3225F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2C5A7F45AA4D485688D46789 /* Pods_Runner.framework */; };
|
||||||
|
D33A34F4E410B032A1D843E3 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C6D436F0A8387C8ED95A60B0 /* Pods_RunnerTests.framework */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -42,12 +45,18 @@
|
|||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||||
|
2C5A7F45AA4D485688D46789 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
|
5D74CE3B9D4E625B6EC3403A /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
6331745AE8BC5EE4BCD290B9 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
65BAE750C7D318CC27513494 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||||
|
93E70FBCBD3EC11A08FAD800 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
@@ -55,19 +64,46 @@
|
|||||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
C52233F0D1C275E97BE00A91 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
C6D436F0A8387C8ED95A60B0 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
ECE998D63CAE2AC9FF401968 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
5B6028AD445E1E5F91C5C300 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
D33A34F4E410B032A1D843E3 /* Pods_RunnerTests.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||||
|
B79095E86F02076D8BE3225F /* Pods_Runner.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
|
04069A07808CEF12713E8A9C /* Pods */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
ECE998D63CAE2AC9FF401968 /* Pods-Runner.debug.xcconfig */,
|
||||||
|
6331745AE8BC5EE4BCD290B9 /* Pods-Runner.release.xcconfig */,
|
||||||
|
65BAE750C7D318CC27513494 /* Pods-Runner.profile.xcconfig */,
|
||||||
|
5D74CE3B9D4E625B6EC3403A /* Pods-RunnerTests.debug.xcconfig */,
|
||||||
|
93E70FBCBD3EC11A08FAD800 /* Pods-RunnerTests.release.xcconfig */,
|
||||||
|
C52233F0D1C275E97BE00A91 /* Pods-RunnerTests.profile.xcconfig */,
|
||||||
|
);
|
||||||
|
name = Pods;
|
||||||
|
path = Pods;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -76,9 +112,19 @@
|
|||||||
path = RunnerTests;
|
path = RunnerTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
789F6ED1EF63DAEA6E803F1D /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2C5A7F45AA4D485688D46789 /* Pods_Runner.framework */,
|
||||||
|
C6D436F0A8387C8ED95A60B0 /* Pods_RunnerTests.framework */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||||
@@ -94,6 +140,8 @@
|
|||||||
97C146F01CF9000F007C117D /* Runner */,
|
97C146F01CF9000F007C117D /* Runner */,
|
||||||
97C146EF1CF9000F007C117D /* Products */,
|
97C146EF1CF9000F007C117D /* Products */,
|
||||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||||
|
04069A07808CEF12713E8A9C /* Pods */,
|
||||||
|
789F6ED1EF63DAEA6E803F1D /* Frameworks */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -128,8 +176,10 @@
|
|||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
C41A5F4922F3BF2ACAF8E553 /* [CP] Check Pods Manifest.lock */,
|
||||||
331C807D294A63A400263BE5 /* Sources */,
|
331C807D294A63A400263BE5 /* Sources */,
|
||||||
331C807F294A63A400263BE5 /* Resources */,
|
331C807F294A63A400263BE5 /* Resources */,
|
||||||
|
5B6028AD445E1E5F91C5C300 /* Frameworks */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -145,18 +195,23 @@
|
|||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
0A8FFE29BE998F8F679CF732 /* [CP] Check Pods Manifest.lock */,
|
||||||
9740EEB61CF901F6004384FC /* Run Script */,
|
9740EEB61CF901F6004384FC /* Run Script */,
|
||||||
97C146EA1CF9000F007C117D /* Sources */,
|
97C146EA1CF9000F007C117D /* Sources */,
|
||||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||||
97C146EC1CF9000F007C117D /* Resources */,
|
97C146EC1CF9000F007C117D /* Resources */,
|
||||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||||
|
B60F509C791DA959BCD8E827 /* [CP] Embed Pods Frameworks */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
);
|
);
|
||||||
name = Runner;
|
name = Runner;
|
||||||
|
packageProductDependencies = (
|
||||||
|
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||||
|
);
|
||||||
productName = Runner;
|
productName = Runner;
|
||||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
@@ -190,6 +245,9 @@
|
|||||||
Base,
|
Base,
|
||||||
);
|
);
|
||||||
mainGroup = 97C146E51CF9000F007C117D;
|
mainGroup = 97C146E51CF9000F007C117D;
|
||||||
|
packageReferences = (
|
||||||
|
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
|
||||||
|
);
|
||||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
@@ -222,6 +280,28 @@
|
|||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXShellScriptBuildPhase section */
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
|
0A8FFE29BE998F8F679CF732 /* [CP] Check Pods Manifest.lock */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||||
|
"${PODS_ROOT}/Manifest.lock",
|
||||||
|
);
|
||||||
|
name = "[CP] Check Pods Manifest.lock";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
alwaysOutOfDate = 1;
|
alwaysOutOfDate = 1;
|
||||||
@@ -253,6 +333,45 @@
|
|||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||||
};
|
};
|
||||||
|
B60F509C791DA959BCD8E827 /* [CP] Embed Pods Frameworks */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
|
);
|
||||||
|
name = "[CP] Embed Pods Frameworks";
|
||||||
|
outputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
|
C41A5F4922F3BF2ACAF8E553 /* [CP] Check Pods Manifest.lock */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||||
|
"${PODS_ROOT}/Manifest.lock",
|
||||||
|
);
|
||||||
|
name = "[CP] Check Pods Manifest.lock";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
/* End PBXShellScriptBuildPhase section */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -348,8 +467,7 @@
|
|||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
};
|
};
|
||||||
@@ -361,14 +479,16 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
DEVELOPMENT_TEAM = KBM2RZ74VR;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp;
|
PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -378,13 +498,14 @@
|
|||||||
};
|
};
|
||||||
331C8088294A63A400263BE5 /* Debug */ = {
|
331C8088294A63A400263BE5 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 5D74CE3B9D4E625B6EC3403A /* Pods-RunnerTests.debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@@ -395,13 +516,14 @@
|
|||||||
};
|
};
|
||||||
331C8089294A63A400263BE5 /* Release */ = {
|
331C8089294A63A400263BE5 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 93E70FBCBD3EC11A08FAD800 /* Pods-RunnerTests.release.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
@@ -410,13 +532,14 @@
|
|||||||
};
|
};
|
||||||
331C808A294A63A400263BE5 /* Profile */ = {
|
331C808A294A63A400263BE5 /* Profile */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = C52233F0D1C275E97BE00A91 /* Pods-RunnerTests.profile.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
@@ -475,7 +598,6 @@
|
|||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
@@ -525,8 +647,7 @@
|
|||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
@@ -540,14 +661,16 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
DEVELOPMENT_TEAM = KBM2RZ74VR;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp;
|
PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@@ -562,14 +685,16 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
DEVELOPMENT_TEAM = KBM2RZ74VR;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp;
|
PRODUCT_BUNDLE_IDENTIFIER = com.datalumina.YYA;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -611,6 +736,20 @@
|
|||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||||
|
};
|
||||||
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = FlutterGeneratedPluginSwiftPackage;
|
||||||
|
};
|
||||||
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "dkcamera",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/zhangao0086/DKCamera",
|
||||||
|
"state" : {
|
||||||
|
"branch" : "master",
|
||||||
|
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "dkimagepickercontroller",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/zhangao0086/DKImagePickerController",
|
||||||
|
"state" : {
|
||||||
|
"branch" : "4.3.9",
|
||||||
|
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "dkphotogallery",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
|
||||||
|
"state" : {
|
||||||
|
"branch" : "master",
|
||||||
|
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "sdwebimage",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/SDWebImage/SDWebImage",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
|
||||||
|
"version" : "5.21.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swiftygif",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/kirualex/SwiftyGif.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
|
||||||
|
"version" : "5.4.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "tocropviewcontroller",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/TimOliver/TOCropViewController",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
|
||||||
|
"version" : "2.8.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 2
|
||||||
|
}
|
||||||
@@ -1,10 +1,28 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1510"
|
LastUpgradeVersion = "1510"
|
||||||
version = "1.3">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES">
|
buildImplicitDependencies = "YES">
|
||||||
|
<PreActions>
|
||||||
|
<ExecutionAction
|
||||||
|
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||||
|
<ActionContent
|
||||||
|
title = "Run Prepare Flutter Framework Script"
|
||||||
|
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||||
|
<EnvironmentBuildable>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</EnvironmentBuildable>
|
||||||
|
</ActionContent>
|
||||||
|
</ExecutionAction>
|
||||||
|
</PreActions>
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
@@ -52,9 +70,9 @@
|
|||||||
</Testables>
|
</Testables>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Release"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = ""
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
||||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||||
launchStyle = "0"
|
launchStyle = "0"
|
||||||
useCustomWorkingDirectory = "NO"
|
useCustomWorkingDirectory = "NO"
|
||||||
|
|||||||
@@ -4,4 +4,7 @@
|
|||||||
<FileRef
|
<FileRef
|
||||||
location = "group:Runner.xcodeproj">
|
location = "group:Runner.xcodeproj">
|
||||||
</FileRef>
|
</FileRef>
|
||||||
|
<FileRef
|
||||||
|
location = "group:Pods/Pods.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
</Workspace>
|
</Workspace>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "dkcamera",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/zhangao0086/DKCamera",
|
||||||
|
"state" : {
|
||||||
|
"branch" : "master",
|
||||||
|
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "dkimagepickercontroller",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/zhangao0086/DKImagePickerController",
|
||||||
|
"state" : {
|
||||||
|
"branch" : "4.3.9",
|
||||||
|
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "dkphotogallery",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
|
||||||
|
"state" : {
|
||||||
|
"branch" : "master",
|
||||||
|
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "sdwebimage",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/SDWebImage/SDWebImage",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
|
||||||
|
"version" : "5.21.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swiftygif",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/kirualex/SwiftyGif.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
|
||||||
|
"version" : "5.4.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "tocropviewcontroller",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/TimOliver/TOCropViewController",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
|
||||||
|
"version" : "2.8.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 2
|
||||||
|
}
|
||||||
@@ -2,12 +2,15 @@ import Flutter
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 935 KiB After Width: | Height: | Size: 752 KiB |
|
Before Width: | Height: | Size: 985 B After Width: | Height: | Size: 951 B |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 30 KiB |
@@ -2,10 +2,12 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
|
<true/>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Health App</string>
|
<string>小脉健康</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -13,7 +15,7 @@
|
|||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>health_app</string>
|
<string>小脉健康</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
@@ -24,6 +26,39 @@
|
|||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
|
<string>需要使用蓝牙连接欧姆龙血压计以同步测量数据</string>
|
||||||
|
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||||
|
<string>需要使用蓝牙连接血压计</string>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>需要使用相机拍摄饮食照片和体检报告,以便 AI 分析和记录</string>
|
||||||
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
|
<string>需要保存图片到相册</string>
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>需要访问相册以选取饮食照片、体检报告或个人头像</string>
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<false/>
|
||||||
|
<key>UISceneConfigurations</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIWindowSceneSessionRoleApplication</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UISceneClassName</key>
|
||||||
|
<string>UIWindowScene</string>
|
||||||
|
<key>UISceneConfigurationName</key>
|
||||||
|
<string>flutter</string>
|
||||||
|
<key>UISceneDelegateClassName</key>
|
||||||
|
<string>FlutterSceneDelegate</string>
|
||||||
|
<key>UISceneStoryboardFile</key>
|
||||||
|
<string>Main</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
|
<true/>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIMainStoryboardFile</key>
|
||||||
@@ -41,13 +76,5 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
|
||||||
<string>需要使用蓝牙连接欧姆龙血压计以同步测量数据</string>
|
|
||||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
|
||||||
<string>需要使用蓝牙连接血压计</string>
|
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
|
||||||
<true/>
|
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
10
health_app/ios/Runner/Runner.entitlements
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.developer.applesignin</key>
|
||||||
|
<array>
|
||||||
|
<string>Default</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -5,7 +6,9 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
|||||||
import 'core/app_router.dart';
|
import 'core/app_router.dart';
|
||||||
import 'core/app_theme.dart';
|
import 'core/app_theme.dart';
|
||||||
import 'core/navigation_provider.dart';
|
import 'core/navigation_provider.dart';
|
||||||
|
import 'pages/splash_page.dart';
|
||||||
import 'providers/auth_provider.dart';
|
import 'providers/auth_provider.dart';
|
||||||
|
import 'providers/data_providers.dart';
|
||||||
|
|
||||||
/// 健康管家 App 根组件
|
/// 健康管家 App 根组件
|
||||||
class HealthApp extends ConsumerWidget {
|
class HealthApp extends ConsumerWidget {
|
||||||
@@ -14,7 +17,7 @@ class HealthApp extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: '健康管家',
|
title: '小脉健康',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: AppTheme.lightTheme,
|
theme: AppTheme.lightTheme,
|
||||||
localizationsDelegates: const [
|
localizationsDelegates: const [
|
||||||
@@ -25,9 +28,69 @@ class HealthApp extends ConsumerWidget {
|
|||||||
supportedLocales: const [Locale('zh', 'CN'), Locale('zh')],
|
supportedLocales: const [Locale('zh', 'CN'), Locale('zh')],
|
||||||
locale: const Locale('zh'),
|
locale: const Locale('zh'),
|
||||||
home: const _RootNavigator(),
|
home: const _RootNavigator(),
|
||||||
// 注入 ShadTheme,让所有页面都能用 shadcn 组件
|
// 注入 ShadTheme + 启动闸门:Splash 盖在最上层,直到首页今日健康卡数据就绪
|
||||||
builder: (context, child) =>
|
// 外层包一层 GestureDetector:点击任意空白处取消输入框焦点(全 app 生效)
|
||||||
ShadTheme(data: AppTheme.shadTheme, child: child!),
|
builder: (context, child) => ShadTheme(
|
||||||
|
data: AppTheme.shadTheme,
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
child: _BootGate(child: child!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动是否就绪:auth 判定完成,且(已登录的普通用户)今日健康卡数据已到位。
|
||||||
|
/// 未登录或医生/管理员无需等待健康卡。
|
||||||
|
final appReadyProvider = Provider<bool>((ref) {
|
||||||
|
final auth = ref.watch(authProvider);
|
||||||
|
if (auth.isLoading) return false; // 还在判登录态 → 继续盖 Splash
|
||||||
|
if (!auth.isLoggedIn) return true; // 未登录 → 就绪,露出登录页
|
||||||
|
final role = auth.user?.role ?? 'User';
|
||||||
|
if (role != 'User') return true; // 医生/管理员首页不依赖今日健康卡
|
||||||
|
// 普通用户:等今日健康卡片数据(成功或失败都算就绪,避免卡死)
|
||||||
|
final health = ref.watch(latestHealthProvider);
|
||||||
|
return health.hasValue || health.hasError;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 启动闸门——就绪前在最上层覆盖 Splash,盖住登录页闪现与首页对话流初始态。
|
||||||
|
/// 带 8 秒安全兜底,避免离线时数据永不返回导致卡在启动页。
|
||||||
|
class _BootGate extends ConsumerStatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
const _BootGate({required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_BootGate> createState() => _BootGateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BootGateState extends ConsumerState<_BootGate> {
|
||||||
|
bool _timedOut = false;
|
||||||
|
Timer? _timer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_timer = Timer(const Duration(seconds: 8), () {
|
||||||
|
if (mounted) setState(() => _timedOut = true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_timer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final ready = ref.watch(appReadyProvider) || _timedOut;
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
widget.child,
|
||||||
|
if (!ready) const Positioned.fill(child: SplashPage()),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,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,
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import 'dart:io';
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'local_database.dart';
|
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://localhost:5000',
|
defaultValue: 'https://erpapi.datalumina.cn/xiaomai',
|
||||||
);
|
);
|
||||||
|
|
||||||
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,
|
||||||
baseUrl: baseUrl,
|
}) : _db = db,
|
||||||
connectTimeout: const Duration(seconds: 15),
|
_authExpiredNotifier = authExpiredNotifier,
|
||||||
receiveTimeout: const Duration(seconds: 60),
|
_dio = Dio(
|
||||||
headers: {'Content-Type': 'application/json'},
|
BaseOptions(
|
||||||
)) {
|
baseUrl: baseUrl,
|
||||||
|
connectTimeout: const Duration(seconds: 15),
|
||||||
|
receiveTimeout: const Duration(seconds: 60),
|
||||||
|
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,14 +157,15 @@ 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
|
||||||
? '请求超时,请稍后重试'
|
? '请求超时,请稍后重试'
|
||||||
: error.type == DioExceptionType.connectionError
|
: error.type == DioExceptionType.connectionError
|
||||||
? '无法连接服务器,请检查网络或后端地址'
|
? '无法连接服务器,请检查网络或后端地址'
|
||||||
: '请求失败,请稍后重试';
|
: '请求失败,请稍后重试';
|
||||||
final rawCode = body is Map ? body['code'] : null;
|
final rawCode = body is Map ? body['code'] : null;
|
||||||
throw ApiException(
|
throw ApiException(
|
||||||
message,
|
message,
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,37 +5,68 @@ class AppColors {
|
|||||||
AppColors._();
|
AppColors._();
|
||||||
|
|
||||||
static const Color primary = Color(0xFF7C5CFF);
|
static const Color primary = Color(0xFF7C5CFF);
|
||||||
static const Color primaryLight = Color(0xFFEDE8FF);
|
static const Color primaryLight = Color(0xFFEDE9FF);
|
||||||
static const Color primarySoft = Color(0xFFF6F3FF);
|
static const Color primarySoft = Color(0xFFF7F4FF);
|
||||||
static const Color primaryDark = Color(0xFF5B3FE8);
|
static const Color primaryDark = Color(0xFF5B4BDB);
|
||||||
static const Color blueMeasure = Color(0xFF4F7CFF);
|
static const Color blueMeasure = Color(0xFF60A5FA);
|
||||||
static const Color doctorBlue = Color(0xFF3B82F6);
|
static const Color doctorBlue = Color(0xFF3B82F6);
|
||||||
static const Color auraLavender = Color(0xFFA78BFA);
|
static const Color auraLavender = Color(0xFFC4B5FD);
|
||||||
static const Color auraIndigo = Color(0xFF818CF8);
|
static const Color auraIndigo = Color(0xFF8B5CF6);
|
||||||
static const Color auraOrchid = Color(0xFFC084FC);
|
static const Color auraOrchid = Color(0xFFE879F9);
|
||||||
static const Color auraDeepIndigo = Color(0xFF6366F1);
|
static const Color auraDeepIndigo = Color(0xFF7C3AED);
|
||||||
static const Color mintAccent = Color(0xFF2DD4BF);
|
static const Color mintAccent = Color(0xFF67E8F9);
|
||||||
static const Color peachAccent = Color(0xFFFFA6A6);
|
static const Color peachAccent = Color(0xFFFBCFE8);
|
||||||
static const Color roseAccent = Color(0xFFF43F5E);
|
static const Color roseAccent = Color(0xFFFB7185);
|
||||||
static const Color meadowAccent = Color(0xFF72F874);
|
static const Color meadowAccent = Color(0xFFBAE6FD);
|
||||||
static const Color sageAccent = Color(0xFF9CD9C9);
|
static const Color sageAccent = Color(0xFFE9D5FF);
|
||||||
|
|
||||||
static const Color background = Color(0xFFFAF8FF);
|
static const Color health = Color(0xFF3B82F6);
|
||||||
static const Color backgroundSoft = Color(0xFFFFFCFF);
|
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 backgroundSoft = Color(0xFFFFFFFF);
|
||||||
static const Color cardBackground = Color(0xFFFFFFFF);
|
static const Color cardBackground = Color(0xFFFFFFFF);
|
||||||
static const Color cardInner = Color(0xFFF7F5FF);
|
static const Color cardInner = Color(0xFFF1F5F9);
|
||||||
static const Color pageGrey = background;
|
static const Color pageGrey = background;
|
||||||
static const Color iconBg = Color(0xFFF2EEFF);
|
static const Color iconBg = Color(0xFFEFF6FF);
|
||||||
static const Color avatarBg = Color(0xFFF4EFFF);
|
static const Color avatarBg = Color(0xFFF5F3FF);
|
||||||
|
|
||||||
static const Color textPrimary = Color(0xFF1F2937);
|
static const Color textPrimary = Color(0xFF1F2937);
|
||||||
static const Color textSecondary = Color(0xFF667085);
|
static const Color textSecondary = Color(0xFF475467);
|
||||||
static const Color textHint = Color(0xFF98A2B3);
|
static const Color textHint = Color(0xFF667085);
|
||||||
static const Color textOnGradient = Color(0xFFFFFFFF);
|
static const Color textOnGradient = Color(0xFFFFFFFF);
|
||||||
|
|
||||||
static const Color border = Color(0xFFE5E7EB);
|
static const Color border = Color(0xFFD5DCE5);
|
||||||
static const Color borderLight = Color(0xFFF1F3F8);
|
static const Color borderLight = Color(0xFFE2E8F0);
|
||||||
static const Color divider = Color(0xFFEFF2F7);
|
static const Color divider = Color(0xFFE5EAF1);
|
||||||
|
|
||||||
static const Color success = Color(0xFF16A34A);
|
static const Color success = Color(0xFF16A34A);
|
||||||
static const Color successLight = Color(0xFFEAF8EF);
|
static const Color successLight = Color(0xFFEAF8EF);
|
||||||
@@ -56,10 +87,10 @@ class AppColors {
|
|||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [
|
colors: [
|
||||||
Color(0xFFFFFCFF),
|
Color(0xFFFFFFFF),
|
||||||
Color(0xFFF9F5FF),
|
Color(0xFFEFF6FF),
|
||||||
Color(0xFFF5F1FF),
|
Color(0xFFF5F3FF),
|
||||||
Color(0xFFF2F6FF),
|
Color(0xFFFDF2F8),
|
||||||
],
|
],
|
||||||
stops: [0.0, 0.42, 0.74, 1.0],
|
stops: [0.0, 0.42, 0.74, 1.0],
|
||||||
);
|
);
|
||||||
@@ -67,63 +98,128 @@ class AppColors {
|
|||||||
static const LinearGradient drawerGradient = LinearGradient(
|
static const LinearGradient drawerGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFFFFFFFF), Color(0xFFFBF7FF), Color(0xFFF0F4FF)],
|
colors: [Color(0xFFFFFFFF), Color(0xFFEFF6FF), Color(0xFFFDF2F8)],
|
||||||
stops: [0.0, 0.52, 1.0],
|
stops: [0.0, 0.52, 1.0],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient surfaceGradient = LinearGradient(
|
static const LinearGradient surfaceGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFFFFFFFF), Color(0xFFFCFAFF)],
|
colors: [Color(0xFFFFFFFF), Color(0xFFF8FAFC)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient softGlassGradient = LinearGradient(
|
static const LinearGradient softGlassGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xF7FFFFFF), Color(0xECFFFFFF)],
|
colors: [Color(0xFAFFFFFF), Color(0xF0FFFFFF)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient roseVioletGradient = LinearGradient(
|
static const LinearGradient roseVioletGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFF8B5CF6), Color(0xFFF43F5E)],
|
colors: [Color(0xFF8B5CF6), Color(0xFFFB7185)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient sageVioletGradient = LinearGradient(
|
static const LinearGradient sageVioletGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFF9CD9C9), Color(0xFFAE2EF9)],
|
colors: [Color(0xFF60A5FA), Color(0xFFE879F9)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient meadowVioletGradient = LinearGradient(
|
static const LinearGradient meadowVioletGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFF72F874), Color(0xFF8C6BD9)],
|
colors: [Color(0xFFBAE6FD), Color(0xFFC4B5FD)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient calmHealthGradient = LinearGradient(
|
static const LinearGradient calmHealthGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFF9CD9C9), Color(0xFF818CF8)],
|
colors: [Color(0xFF60A5FA), Color(0xFFC4B5FD)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient warmCareGradient = LinearGradient(
|
static const LinearGradient warmCareGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFFFFA6A6), Color(0xFFC084FC)],
|
colors: [Color(0xFFFBCFE8), Color(0xFFC4B5FD)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient actionOutlineGradient = LinearGradient(
|
static const LinearGradient actionOutlineGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFFC084FC), Color(0xFF818CF8), Color(0xFFC084FC), Color(0xFF818CF8)],
|
colors: [
|
||||||
|
Color(0xFFC084FC),
|
||||||
|
Color(0xFF818CF8),
|
||||||
|
Color(0xFFC084FC),
|
||||||
|
Color(0xFF818CF8),
|
||||||
|
],
|
||||||
stops: [0.0, 0.3, 0.6, 1.0],
|
stops: [0.0, 0.3, 0.6, 1.0],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient doctorGradient = LinearGradient(
|
static const LinearGradient doctorGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFF4F7CFF), Color(0xFF7C5CFF)],
|
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 => [
|
||||||
|
|||||||