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

23 KiB
Raw Permalink Blame History

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 主题 tokenlib/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 错误态 符合 errorTextInputDecoration 原生渲染(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 文字直接用传入色:默认 primary2.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. 登录页全宽诉求本就该由 PrimaryButtonwidth: 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#E2E8F0Colors.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-1Colors.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 = nullPrimaryButton.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_formErrorString?)、_submittingbool)。

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:仅用户名 + 手机号 + 密码 + 确认密码;短信验证码行、第三方登录预留区一律不渲染。

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 路由。

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 语义保证成立;若嫌样板多,可给 AppTextFieldfocusNode 参数(建议级)。
  3. 三页均未用 Form/validator——AppTextField 是 errorText 受控模式,校验状态放页面 state,这与组件现状一致,勿混用两套校验。
  4. DEBT-1(TagPill)另开工单,与登录纵切解耦。

UI Designer · 2026-09-04