Files
lixi 209021e7d2 docs: 迁入第一迭代过程报告并建立进展看板
- 新增 development/iterations/iteration-1/:15 份角色报告 + 进展看板(已完成/未闭环/下一步),作为双人协作的进度事实来源
- 新增 ADR-006:测试与交付容器化策略(Testcontainers / 交付 Docker 包 / 本机库仅个人联调)
- Git 工作流规范补充:敏感信息只进忽略文件或 sample、测试数据不入库、测试代码限标准测试目录
- 门禁:mkdocs build --strict 通过(零警告)
2026-09-04 10:45:05 +08:00

348 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.752.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` 走主题 disabledink 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 primaryslogan 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:1primary 端)至 1.75:1accent 端),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 最短停留 500msrefresh 客户端超时 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