# 25 M3 第三波:帖子详情页替换 + 互动接线(T3-15/T3-16) **执行日期**:2026-09-09 **工单**:T3-15 帖子详情页整页替换(demo 数据层退役)+ T3-16 互动接线(ToggleSync UI 层),同域合并交付 **依赖**:21 号(T3-12 数据层,ToggleSync/CommunityController 就位)、24 号(T3-14 组件与遗留交接)、05 号 UI 规范 §2.2/§3.4/§3.5/§4、22 号事件白名单 v3、17 号后端评论/互动语义 **提交**:patbond-flutter dev `92524da`(T3-16 基建)+ `f873acf`(T3-15/16 页面与接线),基线 `8aac8c5`,已推送 origin/dev --- ## 0. 概要 `post_detail_page.dart` 整页重写为真实数据:四态首屏、媒体全量渲染(真九宫格 + 全屏大图)、作者卡关注双态、评论区(游标列表 / 输入条创建 / 仅本人可删)全部 落地;点赞/收藏经共享 ToggleSync 接入 Feed 卡片与详情页(同一 controller 实例, 互动状态跨页一致),按 05 号 §4 三层视觉抑制实现;互动域 8 事件挂接完成。 Feed 整卡点按导航详情接通,T3-14 的占位 SnackBar 与禁用态移除。**未动 patbond-api**;create 页 demo 留给 T3-17。 **质量门禁**:`flutter test` 458/458 全绿(基线 421,+37;另 2 个 env 门控 compose 冒烟默认跳过)、`flutter analyze` 0 问题、`dart format --set-exit-if-changed` 无 diff、compose 六容器真链路实测通过(§5,含断网 点赞回滚)。 ## 1. 详情页四态与结构(T3-15) | 态 | 渲染 | widget 测试 | |----|------|------------| | loading(无内存副本) | 居中转圈;评论区独立骨架 2 个(32 圆 + 圆角 16 块高 72,05 §3.7) | ✓ | | 内存副本先渲染 | 进入即展示 controller 缓存内容,`getPost` 后台拉新静默替换(Feed 卡片互动字段一并回写) | ✓ | | error | `InlineErrorBanner`(pets 同款话术映射)+ 「重试」;有副本时后台刷新失败不打断阅读 | ✓ | | **40403 不存在态** | SnackBar「帖子不存在或已被删除」→ **返回 Feed 并触发整体刷新**(失效帖剔除);详情 / 评论 / 评论创建三条路径均可触发,单次守卫防重复 pop | ✓ | | ready | 媒体区 → 作者卡 → 正文卡 → 操作行 → 评论区,底部固定输入条 | ✓ | 结构落点(05 §2.2 对照): - **媒体区(本单裁定:真九宫格,D11 轮播方案弃用)**:单图原比例通栏、高度 钳制 [宽×0.75, 宽×1.33](widthPx/heightPx 缺失回落 4:3);多图走 `PostMediaGrid` 全量形态(24 号预留的列数规则 2/4→2 列、3/5–9→3 列与 超 9 折叠「+N」直接生效)。点格进全屏大图:黑底 + `InteractiveViewer` (03 号拍板 E 选①内置方案,零依赖)+ 横滑翻页 + 双击定点 2.5x 缩放 + 右上「n/N」ink 胶囊(13.50:1)与关闭钮。**偏差**:05 §2.2 的「下滑关闭」 与 InteractiveViewer 平移手势冲突,本版未做(关闭钮 + 返回手势可退出), 留待 photo_view 复评(03 号 E 的升级条件「体验不达标」)。 - **作者卡**:`PetAvatar` md44 + 名字/相对时间;关注双态钮见 §3。 - **正文卡**:标题 titleMedium + 求助帖 `TagPill(accent)` + 正文 14/1.6 全文 + 「发布于 …」12 `inkSoft`。契约 Post 无话题字段,TopicChip 不涉本单。 - **操作行**:与 Feed 卡片同一套组件卡外裸排(demo 的 FilledButton.tonalIcon 弃用);评论锚点钮点按聚焦底部输入框(唤起键盘直接开写)。 - **输入条**:surface 底 + 顶部 border 1px 分隔线(demo 缺失,已补)+ isDense 输入框 + filled 发送钮(空文本禁用;发送中 18 转圈锁尺寸)。 - **AppBar 分享**:占位 SnackBar「分享功能即将上线」(无契约端点)。 ## 2. 评论区(T3-15) - **游标列表**:`(created_at DESC, id DESC)` 服务端序原样渲染,触底(余量 400)携 nextCursor 补页,失败态只走显式重试(Feed 同款守卫);空态 「还没有评论,来抢沙发」(装饰图标 muted 合法、文案 inkSoft,DEBT-2 零新增)。 - **创建**:仓库层 Idempotency-Key 每次提交换新键(21 号已测线上语义);成功 插入列表头 + `adjustCommentCount(+1)` 同源写入(详情副本与 Feed 卡片 commentCount 一并更新)+ 清空输入收起键盘;失败保留输入 + 按类型话术 SnackBar(40000 →「评论内容不合规」等),撞 40403 走不存在态流程。 - **仅本人可删(UI 呈现)**:`currentUserId`(app.dart 注入 sessionManager.userId) 与评论 author.userId 相等才渲染「删除」入口——权限判定只做 UI 自见性, 服务端 40301/40404 仍是裁决者(17 号 §2.3)。删除经确认弹窗 → 软删成功 剔除 + 计数 -1;40404(已在别处删)本地同步剔除;40301 提示无权限。 - **CommentTile 升共享组件**(`lib/core/widgets/comment_tile.dart`,05 §3.4): PetAvatar sm32 + 气泡(surface/border 1px/圆角 16/padding 12);@ 回复以 「回复 @昵称:」前缀呈现(响应 replyToUser,含降级「宠友」占位);删除 in-flight 转圈锁定。**取舍**:评论点赞(§3.4 底行右端)无契约端点不渲染; @ 回复的**发起** UI 与长按操作 sheet(回复/复制/举报)留待后续工单 (数据层 replyToUserId 已支持,isReply 埋点属性预留)。 ## 3. 互动视觉实现(T3-16,05 §3.5/§4 三层抑制对照) | 层 | 规范 | 实现落点 | |----|------|---------| | 即时反馈 | 点按即刻翻转 + 激活动画 | ToggleSync 乐观写入同帧 notify;LikeButton 升 Stateful——点按驱动的激活播 240ms 弹性缩放(1→1.25→1)+ 120ms 图标淡入,取消仅 120ms 颜色渐出无缩放 | | 连点合并 | 只发最终态 | 由数据层单飞合并承担(在途链只并入 pendingTarget、完成后按最终意图至多补发一次,连点至多两在途);UI 不再叠加 600ms 计时防抖——ToggleSync 已保证「合并后只发最终态」的语义,双状态机会打架(D8 的跨角色确认以 21 号定稿为准) | | 回滚静默化 | 零动画 + 成对恢复 + SnackBar | 非点按驱动的状态变化(回滚/对账)直接跳变;**计数与展示态成对更新**(不出现「心已灭计数未减」中间帧);激活动画未播完等播完再跳(§4.3a);`toggleError` 一次性消费出 SnackBar「操作失败,请重试」(Feed 页与详情页共用消费口,先消费者清空,同帧恰一条) | | 对账不打扰 | 静默替换计数 | 服务端权威计数与乐观值不同(他人并发)时数字直接替换、无动画(LikeButton 对「状态不变的计数变化」不播任何过渡) | 关注钮同策略(§4.5):乐观翻转、失败直接跳回 + SnackBar;取关先确认 「不再关注 TA?」;本人帖不渲染(自关注 42204 不给触发面);关注状态经 `getFollowStats.followedByMe` 拉取,拉取失败不渲染钮(不阻塞阅读)。 系统「减弱动态效果」开启时全部动画降级瞬变(LikeButton 与 FeedSkeleton 同口径)。 **跨页一致**:Feed 卡片与详情页共享同一 CommunityController/ToggleSync 实例, 互动写入经 `_writeInteraction` 同帧更新详情副本与 Feed 卡片(widget 测试从 UI 侧断言「详情点赞、卡片同帧 +1」)。 ## 4. 埋点挂接清单(8 事件 + 口径) 新增 `community_interaction_analytics.dart`(22 号白名单键集逐一对齐, 编译期锁死): | # | 事件 | 触发点 | props | 挂接位置 | |---|------|--------|-------|---------| | 1 | `post_liked` | 点赞**成功响应后** | source(feed / post_detail) | CommunityController send 闭包(触点在 toggle 调用处归因) | | 2 | `post_unliked` | 取消点赞成功响应后 | source | 同上 | | 3 | `post_favorited` | 收藏成功响应后 | source | 同上 | | 4 | `post_unfavorited` | 取消收藏成功响应后 | source | 同上 | | 5 | `comment_create_succeeded` | 评论创建成功响应后 | durationMs、isReply、textLengthBucket | 详情页提交回调 | | 6 | `comment_create_failed` | 评论创建失败 | failureReason、errorCode、httpStatus、attemptSeq | 详情页提交回调 | | 7 | `user_followed` | 关注成功响应后 | source=post_detail | 详情页关注钮 | | 8 | `user_unfollowed` | 取关成功响应后 | source=post_detail | 详情页关注钮 | 口径说明(widget/单元测试逐字段断言): - **成功才报**:乐观翻转与失败回滚不报(06 §1.4「点赞/收藏/关注不埋失败」); 单飞合并链每个**实际抵达服务端并成功**的状态变更各报一条(快速连点合并后 至多两条、方向相反,与「成功响应后」字典口径一致)。 - **comment_create_started 不发**(22 号锁死 unknown);`durationMs` 取 「输入会话首字符 → 成功响应」(评论无 started 事件,时长随成功事件带出); `textLengthBucket` 分桶 empty/short(≤50)/medium(51–500)/long(>500),精确 字数不出端(红线 1);`attemptSeq` 输入会话内从 1 递增,成功或清空输入重置; `httpStatus = code ~/ 100`(pet 域同款);会话失效不上报;postId/commentId 等内容 ID 一律不进 props(红线 2)。 - **follow UI 判定**:详情页作者卡有关注钮(demo 形态保留升级双态),故 user_followed/unfollowed 本单接通;user_profile / follow_list 触点随 后续页面启用。 ## 5. compose 真链路实测 后端 patbond-api dev@`8089c06` 六容器 `docker compose up -d --build` 全部 Up、postgres/minio healthy;测毕 `docker compose down` 干净退出,patbond-api 零改动。 **(a)互动一轮(`test/smoke/detail_interactions_smoke_test.dart`,env 门控 `PATBOND_DETAIL_SMOKE=1`,生产 ApiClient/Repository/Controller 全真实现)**: 注册一次性账号 → 发帖(published)→ 点赞(`{liked:true, likeCount:1}`)→ **重复 PUT 幂等不重复计数** → 收藏/取消(计数 1→0)→ 评论创建 (Idempotency-Key,`commentCount` 0→1)→ 仅作者删除评论(`commentCount` 回 0、列表剔除)→ 权威计数逐步对账。**一次通过**。 **(b)断网点赞回滚(同测试内,生产 CommunityController + ToggleSync)**: Feed 刷新拿到该帖(likedByMe=true/count=1)→ community 端点整体切至不可达 端口模拟断网(连接拒绝走生产 ApiClient 的真实 ApiNetworkException 链路)→ `toggleLike`:乐观翻转**同帧可见**(false/0)→ 请求失败后**快照成对回滚** (true/1)、`toggleError` 为 ApiNetworkException(SnackBar 消费口就位)→ 恢复网络再 toggle → 服务端权威终态收敛(likedByMe=false/likeCount=0)。 **一次通过**(首轮实测暴露测试自身竞态:以乐观值判收敛会早退,已改为轮询 服务端权威终态)。 **(c)互动 8 事件白名单验真(curl,客户端真实 payload 形状)**: `POST /api/v1/events`(user :8082)一批 8 条(platform=android)→ **202 accepted 8 / rejected 0**,`platform.product_events` 落库 props 完整 (source / durationMs+isReply+textLengthBucket / failureReason+attemptSeq+ errorCode+httpStatus 逐键核对无剥离)。 真机专属项(乐观更新手感:连点合并请求数、回滚动画帧率、跨页一致、减弱 动态降级)已补全 device-verification.md「M3 预登记」第 2 项的步骤与通过标准。 ## 6. 测试数变化 | 项 | 基线 | 本单后 | |----|------|--------| | flutter test | 421(+1 门控冒烟跳过) | **458(+37,门控冒烟跳过 2)** | | flutter analyze | 0 | 0 | | dart format | 无 diff | 无 diff | 新增分布:post_detail_page widget 测试 17(四态 5 含 40403 弹回刷新与内存 副本先渲染、媒体九宫格与全屏大图 1、评论区 5——游标补页/失败重试/创建成败 与 attemptSeq/删除权限与 40301、互动 4——乐观翻转/失败回滚/收藏事件/跨页 一致、关注 5)、LikeButton 动画 6(点按激活缩放/取消无缩放/外部零动画跳变/ 动画中回滚等播完/对账静默/禁用态)、CommentTile 3、互动埋点单测 6(键集/ 分桶边界/失败原因映射)、home_page 更新 3(导航接通替换 T3-14 占位断言、 点赞接线成功事件、失败回滚 SnackBar)、helpers 扩展(评论/关注假仓钩子)。 另 compose 冒烟 1 条(env 门控,默认跳过不计入套件)。 ## 7. 遗留与交接 - **T3-17 发布页**:create 页 demo 数据层(AppState.publishPost/updatePost 与 `posts` 字段本体)随发布页真实化退役;demo 发布流现只回 Feed 不再导航 (demo 详情页已消亡);PostMediaGrid 编辑态 + UploadProgressOverlay 已在。 - **@ 回复发起 UI / 长按操作 sheet(举报)**:数据层与埋点属性(isReply) 已支持,交互留待范围拍板。 - **大图浏览下滑关闭**:与 InteractiveViewer 平移手势冲突未做,photo_view 复评条件不变(03 号 E)。 - **真机项**:device-verification.md M3 预登记第 2 项待真机执行(连点合并 请求数 ≤2 的抓包核对只能在真机/真网完成)。 --- **Frontend Developer(Flutter)** **日期**:2026-09-09