feat: 健康仪表盘配色调整 + 登录页品牌升级 + iOS 配置 + 隐私文案修订 + 文档清理
## UI 配色 - 健康仪表盘: 背景从蓝紫粉三色渐变改为 #4FACFE 纯色蓝, 白字清晰不抢眼 - app_colors / app_design_tokens / app_theme: 配色体系微调 - 多个 widget 和页面跟随配色更新(health_drawer/admin_drawer/doctor_drawer/enterprise_widgets 等) ## 登录页品牌升级 - 新增品牌素材: drawer_background_v2 / login_background_v2 / health_login_character_transparent - login_page 重构, 接入新品牌视觉 - app.dart 启动流程调整 ## iOS / Android 配置 - Info.plist: 权限描述调整 - AppIcon / LaunchImage: 资源更新 - AndroidManifest: 权限微调 ## 隐私文案修订 - privacy.html: 蓝牙设备描述调整为"标准蓝牙血压服务", 移除未上线设备类型表述 - terms.html: 移除"在线医生咨询"相关条款, 服务范围收窄 ## 通知中心 - notification_center_page: 重构通知项布局 ## 其他 - 删除已完成的计划/spec 文档(ui-system-first-pass / apple-sign-in / secondary-page-color-refresh / notification-preferences-design / ui-design-system) - 新增 native_navigation_test - 新增 HANDOFF 交接文档 - home_message_order_test 更新 - .gitignore 调整
This commit is contained in:
@@ -1,96 +0,0 @@
|
||||
# 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.
|
||||
|
||||
@@ -1,688 +0,0 @@
|
||||
# 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;
|
||||
```
|
||||
@@ -1,47 +0,0 @@
|
||||
# Secondary Page Color Refresh Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Execute this plan inline in the current session. Do not create a branch or worktree.
|
||||
|
||||
**Goal:** Produce a restrained first-pass visual refresh for secondary pages while leaving the home page and all business behavior unchanged.
|
||||
|
||||
**Architecture:** Centralize a light neutral secondary-page canvas with a white AppBar in `GradientScaffold`, keep the existing home implementation untouched, and introduce one reusable outlined create FAB for the pages that currently expose add/upload actions. Fix swipe-delete rows at the shared widget layer so medication and exercise rows always paint an opaque white content surface over the red delete action.
|
||||
|
||||
**Tech Stack:** Flutter, Material 3, flutter_test.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Work on the existing `main` branch and preserve all unrelated dirty-worktree changes.
|
||||
- Do not change backend APIs, providers, routing, or page business logic.
|
||||
- Keep the current home page visual design unchanged.
|
||||
- Secondary pages use a `#F7F8FA` canvas with white content surfaces; module colors remain content accents only.
|
||||
- Reuse the existing `AppRadius` and `AppColors.actionOutlineGradient` tokens.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Lock the shared secondary-page visual behavior
|
||||
|
||||
**Files:**
|
||||
- Modify: `health_app/test/swipe_delete_tile_test.dart`
|
||||
- Create: `health_app/test/secondary_page_visuals_test.dart`
|
||||
- Modify: `health_app/lib/core/app_theme.dart`
|
||||
- Modify: `health_app/lib/widgets/common_widgets.dart`
|
||||
|
||||
- [ ] Add failing widget tests for an opaque swipe-row surface, a white `GradientScaffold`, and the shared outlined create FAB.
|
||||
- [ ] Run the targeted tests and confirm they fail for the missing visual behavior.
|
||||
- [ ] Make `GradientScaffold` and the global AppBar surface white without changing the home page.
|
||||
- [ ] Add the reusable white, purple-gradient-outline create FAB.
|
||||
- [ ] Paint swipe-row content white so red appears only in the revealed action area.
|
||||
- [ ] Run the targeted tests and confirm they pass.
|
||||
|
||||
### Task 2: Adopt the common create action on active user pages
|
||||
|
||||
**Files:**
|
||||
- Modify: `health_app/lib/pages/chart/trend_page.dart`
|
||||
- Modify: `health_app/lib/pages/medication/medication_list_page.dart`
|
||||
- Modify: `health_app/lib/pages/exercise/exercise_plan_page.dart`
|
||||
- Modify: `health_app/lib/pages/report/report_pages.dart`
|
||||
|
||||
- [ ] Replace page-specific solid-color FAB styling with the shared create FAB.
|
||||
- [ ] Preserve every existing callback, tooltip, route, and upload sheet.
|
||||
- [ ] Format only the touched Dart files.
|
||||
- [ ] Run the focused widget tests, the full Flutter test suite, and `flutter analyze`.
|
||||
@@ -1,47 +0,0 @@
|
||||
# 通知设置改造设计
|
||||
|
||||
## 目标
|
||||
|
||||
让通知设置展示真实的服务端状态,保存失败时明确提示并恢复原值;将五个健康指标开关收纳到底部选择弹窗中,同时修复通知总开关未真正参与后端投递判断的问题。
|
||||
|
||||
## 页面结构
|
||||
|
||||
主页面保持四个区域:通知总开关、通知类型、健康录入提醒、免打扰时段。
|
||||
|
||||
健康录入提醒区域只展示:
|
||||
|
||||
- “每日健康录入提醒”总开关。
|
||||
- “提醒指标”入口,副标题展示当前已选指标摘要。
|
||||
|
||||
点击“提醒指标”后打开底部弹窗。弹窗使用复选框选择血压、心率、血糖、血氧和体重,至少保留一项,点击“保存”后一次性提交全部指标设置。
|
||||
|
||||
## 状态与保存
|
||||
|
||||
前端使用明确的通知偏好状态对象,不再直接使用无类型的 Map。状态区分首次加载、单项保存、指标批量保存和错误信息。
|
||||
|
||||
- 首次加载成功后才展示设置内容。
|
||||
- 首次加载失败展示错误页和重试按钮,不使用默认值冒充服务端数据。
|
||||
- 普通开关修改时只锁定当前操作,后端成功后采用响应中的完整偏好更新页面。
|
||||
- 保存失败恢复原值并显示错误提示。
|
||||
- 指标弹窗先维护本地草稿,点击保存时一次提交五个字段,避免连续请求和部分成功。
|
||||
- 免打扰时间采用相同的真实保存与失败恢复规则。
|
||||
|
||||
## 后端行为
|
||||
|
||||
保留现有 `/api/notification-prefs` GET 和 PUT 接口。PUT 继续支持部分字段更新并返回保存后的完整偏好。
|
||||
|
||||
通知发送管线必须优先检查 `PushEnabled`。总开关关闭时,不生成或投递任何用户通知;健康录入、用药、随访、医生回复和异常提醒继续读取各自开关,免打扰规则继续生效。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- 网络或服务错误显示后端可读信息,无法解析时使用统一提示。
|
||||
- 保存过程中禁止同一设置重复提交。
|
||||
- 指标未选择任何一项时,弹窗内提示至少选择一项,不发送请求。
|
||||
- 后端返回失败时不显示成功状态。
|
||||
|
||||
## 测试
|
||||
|
||||
- Flutter 单元测试覆盖偏好解析、指标摘要、至少选择一项和失败回滚。
|
||||
- Flutter 组件测试覆盖加载失败、指标弹窗批量选择和保存中禁用。
|
||||
- 后端测试覆盖 `PushEnabled=false` 时所有通知类型均不投递,以及各分类开关和免打扰继续生效。
|
||||
- 运行 `flutter analyze`、Flutter 全量测试和相关 .NET 测试。
|
||||
Reference in New Issue
Block a user