# 12 · UI 设计还原度 QA 与登录页组装稿 > 作者:UI Designer > 日期:2026-09-04 > 依据:04-ui-login-design-spec.md(下称「04 规范」)、08-flutter-theme-report.md、`AI宠物_iOS_UI设计稿.html`(品牌正典)、`patbond-flutter` 工作区实现代码(未提交 diff) > 性质:只读 QA + 组装级设计稿;需修正项由后续工单执行,本轮不改代码 --- ## 1. QA 总结论 实现质量高,还原度好于预期。逐项对照后: - **需修正:2 项**(1 项视觉回归 + 1 项主题遗漏),另有 1 项既有设计债列入规范修订。 - **可接受偏离:3 项**(含已声明的 `minimumSize`,裁决为采纳并正式写入规范)。 - **最严重项**:首页促销卡换用 `brandGradient` 后,白色文字对比度从旧靛蓝渐变的 ≥4.5:1 跌至 1.75–2.75:1,是本次换色**新引入**的可读性回归(见 §3 FIX-1)。 - 色值零误差:`app_theme.dart` 全部 token 与 04 规范 §1.1 及 HTML 正典逐字节一致(用正典 HTML 提取的 19 个 hex 值交叉验证)。 - 5 个登录组件的构造参数足以照 §5 组装稿直接拼装登录/注册/Splash,无需先改组件(一处失焦校验用 `Focus` 包裹解决,见 §5.4)。 --- ## 2. 设计 QA 偏差清单 ### 2.1 主题 token(`lib/core/theme/app_theme.dart`) | # | 检查项 | 规范值 | 实现值 | 判定 | | --- | --- | --- | --- | --- | | T1 | `primary` | `#FF6F4C` | `0xFFFF6F4C`,注释明确"不用于承载文字的实心填充" | 符合 | | T2 | `primaryStrong` / `primaryDark` | `#D6431A` / `#7A2E12` | 一致 | 符合 | | T3 | `accent` / `accentDark` | `#FFB648` / `#7A4B0A` | 一致 | 符合 | | T4 | `canvas` / `surface` / `surfaceTint` | `#FFF7ED` / `#FFFFFF` / `#FFE8D6` | 一致;`scaffoldBackgroundColor: canvas` | 符合 | | T5 | `ink` / `muted` / `border` | `#3E2A1F` / `#9C8977` / `#F0DCC8` | 一致 | 符合 | | T6 | 成功色族 | `success #7FA88A` / `successInk #3F5744` / `successSurface #E8F0E8` | 一致(`successInk`/`successSurface` 为合理的 token 化拆分) | 符合 | | T7 | `error` | `#D0342C` | 一致,且 `colorScheme.error`/`onError` 显式覆盖 | 符合 | | T8 | `brandGradient` | 135° `#FF6F4C → #FFB648` | `topLeft → bottomRight`(即 135°),色一致 | 符合 | | T9 | 圆角 token | 12/16/18/24/999 | `AppRadius.sm/md/lg/xl/pill` 一致 | 符合 | | T10 | 字号层级 | 22/w800、18/w800、15/w700、14/1.5、12/1.4 muted | `textTheme` 一致;输入框内文字走 M3 `bodyLarge` 默认 16,满足 §1.2"输入框内 16" | 符合 | | T11 | `filledButtonTheme` | 高 52、圆角 16、`primaryStrong` 填充、白字 15/w700、disabled = onSurface 12%/38% | 全部落实;disabled 用 `ink.withAlpha(31/97)`(=12.2%/38%,以 ink 代 onSurface 更贴暖色系) | 符合 | | T12 | `minimumSize` | 规范建议 `Size.fromHeight(52)` | `Size(64, 52)`(已声明偏离) | **可接受偏离,采纳为规范修订值**(裁决见 §2.4) | | T13 | `errorBorder` / `focusedErrorBorder` / `errorStyle` | error 1.5px / error 1.5px / 12px error 色 | 全部一致 | 符合 | | T14 | `helperStyle` | §3.1 注册页 helperText"12 muted" | **未定义**,将回落到 M3 默认(bodySmall + 种子生成的 onSurfaceVariant,非 `muted #9C8977`) | **需修正**(FIX-2,一行改动) | | T15 | `ColorScheme.fromSeed` 派生组件色 | 规范未逐一指定 | Chip/SegmentedButton 等用种子生成暖调色板 | 可接受偏离(登录纵切不涉及;哪个组件刺眼就补哪个 componentTheme,不整体重调) | | T16 | 品牌字体 Baloo 2 / 圆润中文标题体 | 规范允许后置 | 未引入,`BrandMark` 用系统字体 32/w700 | 可接受偏离(04 规范 §1.2 原文允许) | ### 2.2 五个组件(`lib/core/widgets/`) | # | 组件 · 检查项 | 判定 | 说明 | | --- | --- | --- | --- | | C1 | `AppTextField` 错误态 | 符合 | `errorText` 由 `InputDecoration` 原生渲染(TalkBack/VoiceOver 自动关联),描边走主题 errorBorder;有测试 | | C2 | `AppTextField` 禁用态 | 符合 | `enabled=false` 时整体 `Opacity 0.6`,对齐 §4.1"60% 不透明度" | | C3 | `AppTextField` 密码切换 | 符合 | `obscurable` 默认遮蔽;切换按钮 `constraints 44×44`、带中文 tooltip、禁用时同步禁用;有测试锁定切换行为 | | C4 | `PrimaryButton` loading | 符合 | 20×20 白圈 strokeWidth 2.5、外层 SizedBox 锁 52 高尺寸不变、`onPressed` 置 null 锁点击、`disabledBackgroundColor: primaryStrong` 保持珊瑚填充(§4.3"loading 不是置灰"),测试逐项锁定 | | C5 | `PrimaryButton` 禁用 | 符合 | `onPressed: null` 走主题 disabled(ink 12%/38%),符合 §4.4 | | C6 | `InlineErrorBanner` | 符合 | error 底 `withAlpha(20)`(=7.84%,即 8%×255=20.4 的正确取整)、`radiusSm`、padding 12、`error_outline` 18 + 13px error 字、全宽 | | C7 | `BrandMark` | 符合 | logo 72(可参数化)+ brandGradient `radiusXl` 圆底 + `Icons.pets` 占位(§2.1 允许 v1 替代);字标 32/w700 primary;slogan 14 muted 可传 null——正好满足 Splash/登录共用与 §5.2 过渡前提。字标 primary 于 canvas 上约 2.6:1,属品牌字标(logo 豁免),与规范原文一致 | | C8 | `AuthScaffold` | 符合,附组装约束 | SafeArea + 滚动 + 水平 padding 24 + Scaffold 默认键盘避让。注意:`ConstrainedBox` 只给 **minHeight**,子 Column 高度仍无上界,**页面内不能用 `Spacer`/`Expanded` 做弹性空间**(会布局异常),垂直居中必须用 `mainAxisAlignment.center` + 固定间距——§5 组装稿已按此约束编写,不算缺陷 | | C9 | 组件缺口:`AppTextField` 未暴露 `focusNode`/`validator` | 可接受 | 失焦校验用外层 `Focus(onFocusChange:)` 包裹即可(见 §5.4),v1 不需改组件;若后续表单变多,建议加 `focusNode` 参数(建议级,非工单) | ### 2.3 需修正项汇总(供开工单) | 编号 | 严重度 | 位置 | 问题 | 修正建议 | | --- | --- | --- | --- | --- | | **FIX-1** | 高(视觉回归) | `lib/features/home/home_page.dart` `_PromoCard`(约 619 行起) | 促销卡底从旧靛蓝深色渐变换成 `brandGradient`(亮橙→亮琥珀)后,卡上 17px/w800 白色标题与 12px 白色副文字对比度仅 2.75:1(primary 端)至 1.75:1(accent 端),17px 加粗未达 large-text 门槛(18.7px),全部不达 AA。这也违反 04 规范 §1.1 自己的约定"brandGradient 不承载正文文字"。换色前白字在靛蓝上是达标的,属**本次迁移新引入的回归** | 促销卡渐变改为 `LinearGradient(colors: [AppColors.primaryStrong, AppColors.primary])`(文字在左侧 primaryStrong 端,白字 4.5:1 达标;右侧 primary 端只放"去使用"白底按钮与装饰)。备选:整卡 `primaryStrong` 纯色 + accent 装饰爪印。`brandGradient` 本身不动,故事环等纯装饰用法不受影响 | | **FIX-2** | 低(一行) | `app_theme.dart` `inputDecorationTheme` | 缺 `helperStyle`,注册页密码 helperText 颜色将是种子派生灰而非 `muted` | 补 `helperStyle: TextStyle(color: AppColors.muted, fontSize: 12)` | | **DEBT-1** | 低(既有设计债,不阻塞纵切) | `lib/widgets/common.dart` `TagPill` | 11px/w700 文字直接用传入色:默认 `primary`(2.75:1)与服务页 `success` sage(约 2.4:1)都不达标。08 报告第 6 条已指出 sage 一例并正确地未擅改——责任在规范本身,本报告在此修订:**TagPill 应"底用 color 8%、文字用配套深变体"**(primary→`primaryDark`、success→`successInk`、accent→`accentDark`) | 给 `TagPill` 增加可选 `inkColor` 参数(默认按上述映射),另开工单,与登录纵切解耦 | ### 2.4 已声明偏离的设计裁决:`minimumSize: Size(64, 52)` **裁决:采纳,并以 `Size(64, 52)` 作为规范修订值**(04 规范 §6.1 建议的 `Size.fromHeight(52)` 作废)。理由: 1. `Size.fromHeight(52)` 展开为 `Size(double.infinity, 52)`,会给所有 `FilledButton` 无限最小宽度,Row 内既有按钮(首页"去使用"、服务页"立即预约"、对话框"确认恢复")必然布局异常——Frontend 的判断正确,`64` 恰是 Material 默认最小宽,语义上等于"只约束高度"。 2. 登录页全宽诉求本就该由 `PrimaryButton` 的 `width: double.infinity` 承担(组件职责),而非全局主题(全局职责)。实现的分工比规范原稿更对。 3. 副作用(既有小按钮 40→52 高)方向正确:52 高触控目标优于 40(§6.4 要求 ≥44)。若后续对话框内按钮显厚重,方案是补一个 compact 变体或对话框内改 `TextButton`,**不回退全局 52**。 --- ## 3. 五个 Tab 换色抽查 抽查方式:全量 grep 硬编码色值 + 逐页 diff 复核 + 未列入 diff 的三个文件(services / post_detail / main_shell)的 token 引用核对。 **残留硬编码:仅 2 处,均为已声明保留的功能色**——`home_page.dart:536` 雨 `#0284C7`、`:538` 雪 `#0891B2`。旧靛蓝/slate/indigo 系(`#4F46E5`、`#EEF2FF`、`#475569`、`#E2E8F0`、`Colors.indigo*` 等)全量清零。 逐页视觉协调性结论: | 页面 | 结论 | | --- | --- | | home | 问候卡 `primaryDark` 12px/w600 于 `surfaceTint→canvas` 渐变上约 7:1,达标;装饰爪印 `primary.withAlpha(34)` 语义对;天气"多云→muted、阴天→ink"暖灰化协调(阴天用深棕做图标色略重,可接受);**促销卡见 FIX-1**;促销卡反白按钮前景改 `primaryStrong` 是正确的 AA 修正 | | create | 图片压暗 `ink.withAlpha(204)` + scrim 上 `Colors.white70` 副文字,协调达标 | | pets | 健康提醒卡 `successSurface` 底 + `successInk` 文字 + `success.withAlpha(140)` 描边,是成功色族的标准用法;进度环轨道 `border` 对 | | profile | 深色头卡背景随 `ink` 自动变暖深棕,卡上白字 / `accent` amber 徽语 / `Colors.white70` 统计 label / `Colors.white24` 分隔线,全部协调达标(amber `#FFB648` 于 `#3E2A1F` 上约 7:1)——这是"深色底"抽查重点,无深字深底问题 | | services | ⭐ 评分徽章白底 + ink 字达标;"认证服务" `TagPill(success)` 归 DEBT-1;`Colors.white.withAlpha(235)` 徽章底为合理 scrim | | main_shell / post_detail | 仅引用 token 字段,随主题自动切换;导航栏 `white.withAlpha(245)` 与 `surface` 一致;点赞/收藏 `primary` 图标为装饰性着色,达标豁免 | --- ## 4. 组装总则(三页共用) - **垂直弹性**:`AuthScaffold` 内禁用 `Spacer`(§2.2 C8)。登录页居中 = `Column(mainAxisAlignment: MainAxisAlignment.center)` + 首尾 `SizedBox`;注册页顶部对齐 = 默认 `MainAxisAlignment.start`。 - **提交中整表单锁定**:`_submitting == true` 时所有 `AppTextField.enabled = false`、切换链接 `onPressed = null`、`PrimaryButton.isLoading = true`。 - **错误三层模型**(对齐 04 规范 §4.2,全 app 后续网络页面沿用): | 层 | 触发 | 呈现 | 组件 | | --- | --- | --- | --- | | 字段级 | 本地校验失败;服务端 **409/422** 可归属字段的冲突(用户名已存在 / 手机号已注册) | 对应 `AppTextField.errorText`,该字段 `onChanged` 即清除 | `AppTextField` | | 表单级 | **401**"用户名或密码错误"、**429**"尝试次数过多,请稍后再试"等无法归属字段的业务错误 | 主按钮上方横幅,出现时 `SemanticsService.announce(message, TextDirection.ltr)` 播报;任一字段 `onChanged` 即清除 | `InlineErrorBanner` | | 瞬态 | 超时、断网 | floating SnackBar"网络异常,请检查网络后重试"+ action"重试"(重放 `_submit`) | `ScaffoldMessenger` | - 任何服务端异常文本/错误码不直接透出,一律映射为上表文案。 - 提交成功后调 `TextInput.finishAutofillContext()`(触发系统保存密码),随认证状态机 300ms fade 进 `MainShellPage`。 ## 5. 登录页组装稿(`lib/features/auth/login_page.dart`) 页面状态:`_accountCtrl`、`_passwordCtrl`、`_accountError`、`_passwordError`、`_formError`(String?)、`_submitting`(bool)。 ```dart AuthScaffold( // 无 appBar child: AutofillGroup( child: Column( mainAxisAlignment: MainAxisAlignment.center, // 垂直居中,禁 Spacer children: [ const SizedBox(height: 48), // §2.1 顶部最小留白 const BrandMark(), // 默认 size 72 + 默认 slogan const SizedBox(height: 48), Focus( // 失焦校验,见 §5.4 onFocusChange: (has) { if (!has) _validateAccountOnBlur(); }, child: AppTextField( label: '用户名 / 手机号', controller: _accountCtrl, prefixIcon: Icons.person_outline_rounded, errorText: _accountError, enabled: !_submitting, keyboardType: TextInputType.text, textInputAction: TextInputAction.next, autofillHints: const [AutofillHints.username], onChanged: (_) => _clearErrors(field: Field.account), // 清本字段 + 表单级 ), ), const SizedBox(height: 16), Focus( onFocusChange: (has) { if (!has) _validatePasswordOnBlur(); }, child: AppTextField( label: '密码', controller: _passwordCtrl, prefixIcon: Icons.lock_outline_rounded, obscurable: true, errorText: _passwordError, enabled: !_submitting, textInputAction: TextInputAction.done, autofillHints: const [AutofillHints.password], onChanged: (_) => _clearErrors(field: Field.password), onSubmitted: (_) => _submit(), // 键盘 done 直接提交 ), ), if (_formError != null) ...[ const SizedBox(height: 16), InlineErrorBanner(message: _formError!), ], const SizedBox(height: 24), PrimaryButton(label: '登录', isLoading: _submitting, onPressed: _submit), const SizedBox(height: 16), Row(mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('还没有账号?', style: TextStyle(color: AppColors.muted, fontSize: 14)), TextButton( // 点击区 ≥44 高由 minimumSize 保证 style: TextButton.styleFrom( foregroundColor: AppColors.primaryStrong, minimumSize: const Size(44, 44), padding: const EdgeInsets.symmetric(horizontal: 8), textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700), ), onPressed: _submitting ? null : _goRegister, // push RegisterPage child: const Text('立即注册'), ), ]), const SizedBox(height: 24), // 底部留白 // 协议行:v1 协议页未就绪,整行不渲染(§2.2) // 预留区(短信验证码/第三方登录):不渲染任何占位(§2.4 / DoD) ], ), ), ) ``` 校验与提交:两字段仅做**非空**校验(去首尾空格),失焦(曾聚焦过才触发)+ 提交时各校验一次;不做格式强校验。`_submit()`:先总校验,有字段错即 return;置 `_submitting`,调 `POST /auth/login`;结果映射按 §4 表(登录页无 409 场景,401/429 → 横幅)。 ## 6. 注册页组装稿(`lib/features/auth/register_page.dart`) 字段按 **ADR-004:仅用户名 + 手机号 + 密码 + 确认密码**;短信验证码行、第三方登录预留区一律不渲染。 ```dart AuthScaffold( appBar: AppBar( // 透明返回栏(§3.1) backgroundColor: Colors.transparent, elevation: 0, scrolledUnderElevation: 0, foregroundColor: AppColors.ink, // 返回箭头用 ink ), child: AutofillGroup( child: Column( crossAxisAlignment: CrossAxisAlignment.start, // 顶部左对齐 children: [ const SizedBox(height: 8), Text('创建账号', style: Theme.of(context).textTheme.headlineSmall), const SizedBox(height: 8), Text('加入 Patbond,记录毛孩子的每一天', style: Theme.of(context).textTheme.bodySmall), const SizedBox(height: 32), // 四个字段统一模式:Focus 包裹做失焦校验,间距 16 Focus(onFocusChange: (has) { if (!has) _validateUsername(); }, child: AppTextField( label: '用户名', controller: _usernameCtrl, prefixIcon: Icons.person_outline_rounded, errorText: _usernameError, enabled: !_submitting, textInputAction: TextInputAction.next, autofillHints: const [AutofillHints.newUsername], onChanged: (_) => _clearError(Field.username), )), const SizedBox(height: 16), Focus(onFocusChange: (has) { if (!has) _validatePhone(); }, child: AppTextField( label: '手机号', controller: _phoneCtrl, prefixIcon: Icons.phone_iphone_rounded, errorText: _phoneError, enabled: !_submitting, keyboardType: TextInputType.phone, textInputAction: TextInputAction.next, autofillHints: const [AutofillHints.telephoneNumber], onChanged: (_) => _clearError(Field.phone), )), const SizedBox(height: 16), Focus(onFocusChange: (has) { if (!has) _validatePassword(); }, child: AppTextField( label: '密码', controller: _passwordCtrl, prefixIcon: Icons.lock_outline_rounded, obscurable: true, errorText: _passwordError, helperText: '密码 8–32 位,需包含字母和数字', // 出错时被 errorText 替换 enabled: !_submitting, textInputAction: TextInputAction.next, autofillHints: const [AutofillHints.newPassword], onChanged: (_) => _clearError(Field.password), )), const SizedBox(height: 16), Focus(onFocusChange: (has) { if (!has) _validateConfirm(); }, child: AppTextField( label: '确认密码', controller: _confirmCtrl, prefixIcon: Icons.lock_outline_rounded, obscurable: true, errorText: _confirmError, enabled: !_submitting, textInputAction: TextInputAction.done, autofillHints: const [AutofillHints.newPassword], onChanged: (_) => _clearError(Field.confirm), onSubmitted: (_) => _submit(), )), if (_formError != null) ...[ const SizedBox(height: 16), InlineErrorBanner(message: _formError!), ], const SizedBox(height: 32), PrimaryButton(label: '注册', isLoading: _submitting, onPressed: _submit), const SizedBox(height: 16), Center(child: Row(mainAxisSize: MainAxisSize.min, children: [ const Text('已有账号?', style: TextStyle(color: AppColors.muted, fontSize: 14)), TextButton(/* 同登录页样式 */, onPressed: _submitting ? null : () => Navigator.of(context).pop(), child: const Text('直接登录')), ])), const SizedBox(height: 24), ], ), ), ) ``` 校验规则(失焦即校验、提交再总校验,文案照 04 规范 §3.2):用户名非空 + 3–20 位字母开头字母/数字/下划线;手机号 `^1\d{10}$`;密码 8–32 位含字母和数字;确认密码与密码一致(密码字段变更时若确认已有值也重校验一致性)。 **409 映射(字段级)**:用户名冲突 → `_usernameError = '该用户名已被使用'`;手机号已注册 → `_phoneError = '该手机号已注册,可直接登录'`(v1 文案自带出路,"去登录"链接可后置)。401 在注册页理论上不出现,其余不可归属错误 → 横幅;网络 → SnackBar。注册成功即建立会话,`finishAutofillContext()` 后直接 fade 进首页,不回登录页。 ## 7. Splash 组装稿(`lib/features/auth/splash_page.dart`) 状态机:`checking`(默认)→ `failed`(refresh 网络错误/超时且本地有 token)。成功/无 token 不换态,直接 300ms fade 路由。 ```dart Scaffold( backgroundColor: AppColors.canvas, body: Center( child: state == SplashState.checking ? Column(mainAxisSize: MainAxisSize.min, children: [ const BrandMark(), // 与登录页同一构造,保证 §5.2 过渡对位 const SizedBox(height: 32), // 仅当等待 >300ms 才显示(进入页面时启动 300ms 定时器置 _showSpinner) SizedBox.square(dimension: 20, child: _showSpinner ? const CircularProgressIndicator( strokeWidth: 2.5, color: AppColors.primary) : null), // 占位保高度,避免出现时跳动 ]) : Column(mainAxisSize: MainAxisSize.min, children: [ // 错误态(§5.3) const BrandMark(), const SizedBox(height: 24), const Text('网络连接失败,无法恢复登录', style: TextStyle(color: AppColors.ink, fontSize: 14)), const SizedBox(height: 16), OutlinedButton( // 次级按钮,高 44 style: OutlinedButton.styleFrom( minimumSize: const Size(120, 44), foregroundColor: AppColors.primaryStrong, side: const BorderSide(color: AppColors.border), ), onPressed: _retryRefresh, child: const Text('重试')), const SizedBox(height: 8), TextButton( // 逃生通道:清除凭证进登录页 style: TextButton.styleFrom( foregroundColor: AppColors.primaryStrong, minimumSize: const Size(44, 44)), onPressed: _clearCredentialsAndGoLogin, child: const Text('改用账号登录')), ]), ), ) ``` 流程约束(照 04 规范 §5.1,实现方注意):Splash 最短停留 500ms;refresh 客户端超时 5s;**网络失败不得清除本地 refresh token**(只有服务端明确 401 才清);所有页面切换用 `PageRouteBuilder` + `FadeTransition` 300ms。 ## 8. 遗留给实现方的备忘 1. FIX-1、FIX-2 见 §2.3,建议与登录页开发同一工单批执行(FIX-2 直接影响注册页 helperText 观感)。 2. `Focus(onFocusChange:)` 包裹方案依赖祖先 Focus 节点聚合子孙焦点状态,Flutter 语义保证成立;若嫌样板多,可给 `AppTextField` 加 `focusNode` 参数(建议级)。 3. 三页均未用 `Form`/`validator`——`AppTextField` 是 errorText 受控模式,校验状态放页面 state,这与组件现状一致,勿混用两套校验。 4. DEBT-1(TagPill)另开工单,与登录纵切解耦。 --- **UI Designer** · 2026-09-04