# T2-12 宠物列表、详情与编辑页接入真实数据(交付报告) **执行日期**:2026-09-08 **角色**:Frontend Developer(Flutter) **工单**:T2-12(L,关键路径)+ DEBT-1 偿还 + T2-17 前端半边(pet 域三事件) **依据**:01 号拆解 T2-12 节、22 号数据层交付(T2-11)、05 号 UI 设计规范、06 号埋点规划 **提交**:patbond-flutter dev@`97a1f46`(基线 7fb9031,已 push origin dev),拆 3 个提交: | 提交 | 内容 | |------|------| | `3179528` | 共享组件三件(PetAvatar / RecordTypeDot / EmptyStateIllustration)+ TagPill 深变体映射(DEBT-1) | | `c0a8a56` | pet 域埋点强类型封装(pet_analytics.dart 三事件) | | `97a1f46` | 列表/详情/表单页接入真实数据 + app 装配 + demo 清理 + 全部页面测试 | --- ## 0. 结论摘要 - 档案 Tab 替换为真实宠物列表;列表 / 详情 / 建档 / 编辑全链路走 T2-11 数据层(PetsController → PetsRepository → ApiClient),页面零直连 ApiClient、零 AppState demo 依赖; - **四态硬要求达成**:列表、详情、表单内品种目录三处网络面均有 loading / empty / error / retry 且有 widget 测试锁定; - 40902 版本冲突有「明确提示 + 自动取新 version 重提」路径(测试锁定 version 3→4 重提序列);40903 芯片号冲突字段级报错(测试锁定); - DEBT-1 随本单偿还:TagPill 深变体映射落地,全部组合 ≥5.78:1(AA),既有调用零参数回归; - 埋点:pet 域三事件 + page_viewed 的 pet_form / pet_detail / pet_list 接线完成(观察者路由名采集有测试证据); - 测试 **126 → 177 全绿(+51)**,`flutter analyze` 0 问题,`dart format` 无 diff; - compose 真实后端实测:注册 → 空态 → 品种目录 → 建档 → 列表 → 详情 → 差量编辑 → 40902 → 40903 → 自定义品种建档,全部符合契约预期(§6)。 ## 1. 页面与四态覆盖表 | 页面 / 网络面 | loading | empty | error | retry | 测试文件 | |---|---|---|---|---|---| | P1 宠物列表(档案 Tab,`pets_page.dart`) | 居中转圈 ✅ | `EmptyStateIllustration`「还没有宠物档案」+ 建档 CTA ✅ | `InlineErrorBanner`(按错误类型分文案)✅ | 重试按钮 + 下拉刷新 ✅ | `pets_page_test.dart`(6) | | P2 宠物详情(`pet_detail_page.dart`) | 无内存副本时转圈 ✅(有副本即时渲染、后台刷新失败降级 SnackBar,有测试) | 「不存在」态:40401 → 提示 + 返回列表并刷新 ✅(详情页的 empty 语义即目标缺席) | 横幅 ✅ | 重试按钮 ✅ | `pet_detail_page_test.dart`(8) | | 表单页品种目录(`pet_form_page.dart` 内) | 内联转圈 ✅ | 目录空 → 仅「自定义品种…」可选(结构兜底) | 「目录加载失败」提示 + 回落自定义输入 ✅ | 内联重试按钮 ✅ | `pet_form_page_test.dart`(11) | 页面结构与导航: ```text 档案 Tab(IndexedStack,页名 pet_list) └─ P1 宠物列表:宠物卡(PetAvatar lg + 名字 + 品种·性别·年龄 + 状态 TagPill) ├─ 「添加」/ 空态 CTA / 虚线卡 → PetFormPage.create(fadePageRoute,路由名 pet_form) └─ 点卡 → PetDetailPage(路由名 pet_detail) └─ owner 编辑徽标 / 编辑按钮 → PetFormPage.edit(无路由名,见 §4 决策 3) ``` ## 2. 表单与冲突处理(对齐冻结契约) - **字段**:昵称\*、物种\*(SegmentedButton 犬/猫/其他,编辑锁定静态显示——species 不可改)、性别\*(male/female/unknown,契约必填,未选提交拦截)、品种(目录下拉 + 「自定义品种…」互斥,二选一必填;编辑时目录缺席的既有品种保底成项防下拉失配)、生日(DatePicker + 「估算」勾选)、芯片号(可选)、性格(可选)。头像按 ADR-010 本地占位形态(`PetAvatar` url 缺省),不做上传。 - **校验**:失焦 + 提交双校验,`errorText` 受控、`onChanged` 即清(登录纵切模式,昵称 Focus 失焦有测试)。 - **部分更新**:编辑只发送改动字段 + version(测试锁定 `{version:3, name:…}` 精确形状);品种对整体替换;无变更不发 PATCH 直接返回(有测试)。 - **错误分层**(对齐 22 号报告 §3 处理语义,各有测试或复用既有锁定): | 错误 | 呈现 | |---|---| | 40903 芯片号冲突 | 芯片号字段级 errorText「该芯片号已被登记,请核对后重试」 | | 40902 版本冲突 | 横幅「资料已在其他设备被修改,已获取最新版本,请核对后重新保存」+ 自动 `getPet` 更新基线 version(保留用户输入),重提即用新 version——测试锁定提交序列 [3, 4] | | 40401 不存在 | SnackBar + 返回列表并刷新 | | 40300 无权限 | 横幅;且详情页对非 owner 隐藏全部编辑入口(viewer 用例有测试) | | 40000 / 其他业务码 | 横幅通用文案(原始 message 不上屏) | | 429 | 横幅「操作过于频繁」 | | 网络/超时/5xx | SnackBar + 重试动作 | | 会话失效 | 静默(认证状态机自动回登录页;登出同时 `PetsController.reset()` 防跨账号泄漏,有测试) | ## 3. DEBT-1 偿还证据(TagPill 深变体) 方案照 05 号规范 §5.3 落地:`TagPill` 增可选 `inkColor`,缺省按 `color` 查内置映射;底色维持 `withAlpha(20)` 不变;字号 11/w700 不变。 | 组合(文字色 / 8% 淡底) | 修复前对比度 | 修复后对比度 | 判定 | |---|---|---|---| | primary → **primaryDark** | 2.55 | **8.74:1** | AA ✅ | | success → **successInk** | 2.50 | **7.39:1** | AA ✅ | | accent → **accentDark** | 1.67 | **7.07:1** | AA ✅ | | error → **errorDark**(新 token `#B02C25`) | — | **5.78:1** | AA ✅ | | 未命中映射 → **ink** 兜底 | — | ≥12:1 | AA ✅ | - 新 token 落位 `AppColors`:`errorDark #B02C25`、`inkSoft #6B5A4A`(05 D8;本单页面族次级信息文字一律 `inkSoft`,`muted` 只作占位/禁用/装饰——DEBT-2 局部规避执行)。 - 回归:既有零参数调用(post_detail 话题标签、services「认证服务」、services 商家标签)**零参数变更**,全量 177 测试回归通过;映射行为由 `test/widgets/tag_pill_test.dart` 5 个用例锁定(含显式 `inkColor` 覆盖与兜底)。 - 同工单落位(05 §5.3 第 4 点建议):`RecordTypeDot` 五类型三色映射唯一出口(`lib/core/widgets/record_type_dot.dart`,含 §2 表全量映射常量与测试),供 T2-13/14 时间线直接取用;`PetAvatar` 四尺寸档收敛重复头像实现,编辑徽标底修订为 `primaryStrong`(白图标 4.49:1 达非文字 3:1,修复原 `primary` 底 2.75:1 不达标)。 ## 4. 埋点挂接清单(T2-17 前端半边) 强类型封装 `lib/features/pets/pet_analytics.dart`(13 号规范 §3.1 惯例,枚举编译期锁死),注入链 app.dart → MainShellPage → PetsPage → 表单页: | # | 事件 / 页名 | 触发点 | 属性 | 测试 | |---|---|---|---|---| | 1 | `pet_create_started` | 建宠表单**首次输入**(任一字段/选择器,每次进入一次) | `entryPoint`(`profile_empty_state` / `pet_list`;`post_register_guide` 预留) | 首次输入仅一次 ✅ | | 2 | `pet_create_succeeded` | 建宠接口 code=0 | `durationMs`(表单打开→成功)、`species`、`petIndex` | 三属性齐备、petIndex=1 ✅ | | 3 | `pet_create_failed` | 失败响应 / 超时 / 本地校验拦截 | `failureReason`、`errorCode`(可空)、`httpStatus`(由业务码 `~/100` 推导,可空)、`attemptSeq` | 校验拦截 / 40903(409) / 网络三路径 ✅ | | 4 | `page_viewed(pet_form)` | 建宠表单页 push(`RouteSettings(name: 'pet_form')`,fadePageRoute 为 PageRoute,被既有 AnalyticsRouteObserver 采集)| 既有 pageName/referrer | push 路由名断言 ✅(06 §1.6 三段漏斗到达段接通) | | 5 | `page_viewed(pet_detail)` | 详情页 push 路由名 `pet_detail` | 同上 | push 路由名断言 ✅ | | 6 | `page_viewed(pet_list)` | 档案 Tab 页名由 `pet_archive` 改报 `pet_list`(Tab 曝光补点机制不变) | 同上 | 既有 Tab 补点测试覆盖机制 | 映射决策(报数据侧知悉): 1. `failureReason` 枚举照 06 §4 四值(`pet_limit_reached` 因产品未设上限未纳入);客户端网络层不区分 5xx 与断网/超时(同为 `ApiNetworkException`),两者并入 `network_error`,`server_error` 留作兜底;40903 等业务拒绝归 `validation_error` 并以 `errorCode` 细分。 2. `durationMs` 口径 = 表单打开(页面 initState)→ 成功响应(06 未定义精确口径,此口径对「动笔→成功」段更有解释力)。 3. **编辑表单不带路由名**:`pet_form` 是建宠漏斗到达段专属页名(06 §1.6),编辑曝光计入会使「到达→动笔」分母系统性虚高;编辑本身不设事件(06 §1.4 既定取舍)。 4. 后端白名单:patbond-api dev@64c9b72 已含 pet 域 10 事件(T2-17 后端半边先行完成),三事件可直接落库。 ## 5. 测试数变化 | 时点 | 测试数 | 说明 | |------|--------|------| | 基线(dev@7fb9031) | 126 | T2-11 数据层交付 | | 本单(dev@97a1f46) | **177(+51,全绿)** | 见下分布 | | 文件 | 数量 | 覆盖 | |------|------|------| | `test/widgets/tag_pill_test.dart` | 5 | DEBT-1 映射四组 + 兜底 + inkColor 覆盖 + 底色不变 | | `test/core/widgets/pet_avatar_test.dart` | 5 | 四尺寸档、占位形态、徽标底色修订、sm/md 无徽标、点击/禁用 | | `test/core/widgets/record_type_dot_test.dart` | 3 | 五类映射齐备、渲染规格(50% 图标/8% 底)、三尺寸档 | | `test/core/widgets/empty_state_illustration_test.dart` | 2 | 全要素渲染 + CTA 回调、无 CTA/说明不渲染 | | `test/features/pets/pet_analytics_test.dart` | 4 | 三事件属性形状、可空属性缺席语义、httpStatus 推导 | | `test/features/pets/pets_page_test.dart` | 6 | 列表四态、pet_form/pet_detail 路由名、状态标签 | | `test/features/pets/pet_detail_page_test.dart` | 8 | 详情四态(含 40401 返回刷新)、副本即时渲染 + 降级 SnackBar、viewer 隐藏入口、编辑跳转预填、估算标记/未填写兜底 | | `test/features/pets/pet_form_page_test.dart` | 11 | 校验拦截、started 去重、目录/自定义互斥请求形状、40903 字段级、网络 SnackBar、目录失败回落+重试、失焦校验、编辑差量、40902 冲突重提序列、无变更不发 PATCH | | `test/features/pets/pet_display_test.dart` | 4 | 年龄边界(岁/月/未满月/未知)、元信息行、错误文案分档、标签 | | `pets_controller_test.dart` 增量 | 3 | loadBreeds 物种缓存、失败重试、reset 登出清空 | 质量门禁:`flutter test` 177/177 全绿;`flutter analyze` No issues found;`dart format --set-exit-if-changed` 无 diff(三个提交逐个通过)。 ## 6. compose 真实后端实测记录(验收链路) 环境:patbond-api dev@64c9b72,`./mvnw -DskipTests package` + `docker compose up -d --build`(auth :8081 / pet :8083,均本机默认端口,客户端无需 --dart-define)。curl 按页面实际发出的请求逐步复演(token 已脱敏,测试账号随机生成、用后随 compose down 丢弃): | 步骤 | 请求 | 结果 | |------|------|------| | 1 | POST /api/v1/auth/register(新用户) | code=0,取得 accessToken | | 2 | GET /api/v1/pets | `{"code":0,"data":[]}` —— **新用户空态** ✅ | | 3 | GET /api/v1/breeds?species=dog | 目录返回(中华田园犬/金毛/拉布拉多…),表单下拉数据源 ✅ | | 4 | POST /api/v1/pets(表单同构体:name/species/sex/breedId/birthDate/birthDateEstimated/microchipNo/personality) | 201 语义 code=0,返回完整 Pet(version=0,myRole=owner)—— **建档** ✅ | | 5 | GET /api/v1/pets | 列表含新宠物 —— **列表** ✅ | | 6 | GET /api/v1/pets/{id} | 详情字段逐一回读 —— **详情** ✅ | | 7 | PATCH /api/v1/pets/{id}(`{"version":0,"name":"豆豆二世"}` 差量) | code=0,name 更新 —— **编辑** ✅ | | 8 | PATCH 携带旧 version=0 | `{"code":40902,"message":"数据已被修改,请刷新后重试"}` —— 冲突路径与页面处理对齐 ✅ | | 9 | POST 同芯片号再建档 | `{"code":40903,"message":"芯片号已被其他宠物登记"}` —— 字段级报错路径对齐 ✅ | | 10 | POST 自定义品种(customBreedName,无 breedId) | code=0,`breedId=null, customBreedName="狸花"` —— 互斥另一半 ✅ | 结论:**空态 → 建档 → 列表/详情全链路 + 两类冲突码在真实后端全部符合冻结契约与页面实现预期**;未发现契约偏差。UI 侧同构行为由 §5 的 widget 测试(注入假仓库)锁定。实测后 `docker compose down`,patbond-api 仓库零改动。 ## 7. AppState demo 清理 - 删除:`AppState.vaccines` / `updateVaccines` / `updatePet` 及其持久化键、`initialVaccines`、models 中 `VaccineRecord` / `VaccineItem` / `VaccineStatus`(消费方仅原 pets_page,随页面替换全部失效);原 `EditPetSheet` / `VaccineSheet` demo 随页面重写移除。 - 保留(未越界):`AppState.pet` demo 仍被首页问候卡、创作页上传占位、主壳头部头像消费——属其他 Tab 的 demo 家具,留待相应工单收敛(AppState 内已注释标记)。 ## 8. 决策与遗留 | # | 事项 | 说明 | |---|------|------| | 1 | 05 D1「单宠物跳过列表直进 P2」未采纳 | 该项待拍板;本单始终显示列表(P2 头部宠物切换器同属 D1,未做)。拍板后为小改动 | | 2 | P2 的 stat 卡行 / AI 提醒 / 健康时间线未渲染 | T2-13/14 接摘要与记录接口时加回;不渲染 demo 占位(ADR-004),`RecordTypeDot` / `HealthTimelineTile` 所需映射已备好(前者已交付) | | 3 | 档案 Tab 页名 `pet_archive` → `pet_list` | 字典 v2 初始集合本含 pet_list;数据侧看板注意 2026-09-08 起的页名断点 | | 4 | `sterilizedOn` 详情展示、表单暂不可编辑 | 工单字段清单(品种/性别/生日/芯片号)之外,避免表单过长;记小遗留 | | 5 | 归档入口(D2-7「首版仅归档」)未做 | 依赖 listPets 对 archived 的过滤语义确认(契约未明示列表是否含 archived),建议随 T2-13 或收口单补一个详情页归档动作 | | 6 | HealthTimelineTile(05 §3.3)未随本单交付 | 其唯一消费方是 T2-14 时间线,留给 T2-14 与真实数据一并落地 | ## 9. 交接 T2-13/14 - 页面骨架:`PetDetailPage._content` 的「基本资料」卡之上/之下即 stat 行与时间线的落位点;`RecordTypeDot`、`EmptyStateIllustration`、TagPill 深变体、`recordTypeStyles` 映射可直接取用。 - 数据获取范式:页内四态 + `petLoadErrorMessage` 文案分档 + 内存副本先渲染的模式可复制;分页用 `CursorPage`(22 号报告 §2)。 - 埋点:`health_record_*` 事件按 `pet_analytics.dart` 同款强类型封装新建 `health_record_analytics.dart`;`record_form` / `record_detail` 页名枚举已就位待接线。 --- **Frontend Developer** · 2026-09-08 · patbond-flutter dev@97a1f46