新增:互动埋点与乐观更新视觉基建——字典 v3 互动八事件封装 + ToggleSync 成功上报与触点归因 + LikeButton 三层闪烁抑制动画(T3-16)
- community_interaction_analytics:post_liked/unliked、post_favorited/unfavorited、 comment_create_succeeded/failed、user_followed/unfollowed 强类型封装(22 号 白名单键集逐一对齐;textLengthBucket 分桶、httpStatus 由五位码推导、 会话失效不上报);comment_create_started 锁死 unknown 不封装 - CommunityController:toggleLike/toggleBookmark 增 source 触点归因 (feed / post_detail),成功响应后经注入的 interactionAnalytics 上报 (乐观翻转与失败不报);adjustCommentCount 评论计数同源写入 (详情副本与 Feed 卡片一并更新) - LikeButton 升 Stateful:点按激活 240ms 弹性缩放 + 120ms 图标淡入、 取消仅颜色渐出;回滚/对账零动画直接跳变、计数与状态成对更新、 激活动画未播完等播完再跳(05 号 §3.5/§4);减弱动态设置降级瞬变 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
|
||||
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||
|
||||
/// 互动域埋点强类型封装(06 号规划 §1.4 字典 v3 互动八事件;后端白名单
|
||||
/// 已随 api dev@8089c06 就绪,22 号报告 §1)。沿 pet/feed 域惯例:枚举
|
||||
/// 编译期锁死,业务代码禁止手拼事件名与属性;只记行为不记内容
|
||||
/// (隐私红线:postId/commentId 等内容 ID 一律不进 props)。
|
||||
///
|
||||
/// 点赞/收藏/关注**不埋失败**(06 §1.4 取舍:幂等写入单点交互,失败率
|
||||
/// 靠服务端错误率观测);`comment_create_started` 被字典锁死 unknown,
|
||||
/// 不得上报(22 号 §1 末段)。
|
||||
|
||||
/// 互动触点来源(06 §1.4 source 枚举)。M3 接 feed / post_detail;
|
||||
/// user_profile / follow_list 随后续页面启用。
|
||||
enum InteractionSource {
|
||||
feed('feed'),
|
||||
postDetail('post_detail'),
|
||||
userProfile('user_profile'),
|
||||
followList('follow_list');
|
||||
|
||||
const InteractionSource(this.value);
|
||||
|
||||
final String value;
|
||||
}
|
||||
|
||||
/// 评论创建失败原因(06 §1.4 失败枚举基底)。网络归并口径同 pet/feed 域:
|
||||
/// 断网/超时/5xx 均并入 network_error,server_error 保留兜底。
|
||||
enum CommentCreateFailureReason {
|
||||
validationError('validation_error'),
|
||||
notFound('not_found'),
|
||||
rateLimited('rate_limited'),
|
||||
networkError('network_error'),
|
||||
serverError('server_error');
|
||||
|
||||
const CommentCreateFailureReason(this.value);
|
||||
|
||||
final String value;
|
||||
}
|
||||
|
||||
/// 类型化异常 → 评论失败原因;会话失效返回 null(应用即将回登录页,
|
||||
/// 不作为评论失败上报,feed 域同款口径)。
|
||||
CommentCreateFailureReason? commentCreateFailureReasonOf(ApiException error) =>
|
||||
switch (error) {
|
||||
ApiNetworkException _ => CommentCreateFailureReason.networkError,
|
||||
ApiRateLimitException _ => CommentCreateFailureReason.rateLimited,
|
||||
SessionExpiredException _ => null,
|
||||
ApiBusinessException(:final code) => switch (code) {
|
||||
ApiCodes.paramError => CommentCreateFailureReason.validationError,
|
||||
ApiCodes.postNotFound ||
|
||||
ApiCodes.commentNotFound ||
|
||||
ApiCodes.communityUserNotFound => CommentCreateFailureReason.notFound,
|
||||
_ => CommentCreateFailureReason.serverError,
|
||||
},
|
||||
};
|
||||
|
||||
/// 正文规模分桶(06 §1.3 隐私红线 1:不报精确字数)。
|
||||
/// empty / short(≤50) / medium(51–500) / long(>500)。
|
||||
String textLengthBucketOf(int length) {
|
||||
if (length <= 0) return 'empty';
|
||||
if (length <= 50) return 'short';
|
||||
if (length <= 500) return 'medium';
|
||||
return 'long';
|
||||
}
|
||||
|
||||
class CommunityInteractionAnalytics {
|
||||
CommunityInteractionAnalytics(this._track);
|
||||
|
||||
/// 生产传 `AnalyticsService.trackEvent`,测试传录制桩。
|
||||
final TrackEventFn _track;
|
||||
|
||||
/// 点赞成功响应后(乐观翻转本身不报;单飞合并链每个实际抵达服务端
|
||||
/// 并成功的状态变更各报一条,与「成功响应后」的字典口径一致)。
|
||||
void postLiked({required InteractionSource source}) {
|
||||
_track('post_liked', {'source': source.value});
|
||||
}
|
||||
|
||||
/// 取消点赞成功响应后。
|
||||
void postUnliked({required InteractionSource source}) {
|
||||
_track('post_unliked', {'source': source.value});
|
||||
}
|
||||
|
||||
/// 收藏成功响应后。
|
||||
void postFavorited({required InteractionSource source}) {
|
||||
_track('post_favorited', {'source': source.value});
|
||||
}
|
||||
|
||||
/// 取消收藏成功响应后。
|
||||
void postUnfavorited({required InteractionSource source}) {
|
||||
_track('post_unfavorited', {'source': source.value});
|
||||
}
|
||||
|
||||
/// 评论提交成功响应后。
|
||||
///
|
||||
/// [durationMs]:本次输入会话(首个字符输入)→ 成功响应的耗时
|
||||
/// (评论不设 started 事件,时长随成功事件带出);
|
||||
/// [textLength] 经 [textLengthBucketOf] 分桶后上报,精确字数不出端。
|
||||
void commentCreateSucceeded({
|
||||
required int durationMs,
|
||||
required bool isReply,
|
||||
required int textLength,
|
||||
}) {
|
||||
_track('comment_create_succeeded', {
|
||||
'durationMs': durationMs,
|
||||
'isReply': isReply,
|
||||
'textLengthBucket': textLengthBucketOf(textLength),
|
||||
});
|
||||
}
|
||||
|
||||
/// 评论提交失败。
|
||||
///
|
||||
/// [errorCode] 为业务错误码(网络错误时缺席);[httpStatus] 由五位
|
||||
/// 业务码推导(`code ~/ 100`,pet 域同款口径);[attemptSeq] 为本次
|
||||
/// 输入会话内第几次提交尝试(从 1 起,成功或清空输入后重置)。
|
||||
void commentCreateFailed({
|
||||
required CommentCreateFailureReason reason,
|
||||
required int attemptSeq,
|
||||
int? errorCode,
|
||||
}) {
|
||||
_track('comment_create_failed', {
|
||||
'failureReason': reason.value,
|
||||
'attemptSeq': attemptSeq,
|
||||
'errorCode': ?errorCode,
|
||||
if (errorCode != null && errorCode >= 10000)
|
||||
'httpStatus': errorCode ~/ 100,
|
||||
});
|
||||
}
|
||||
|
||||
/// 关注成功响应后。
|
||||
void userFollowed({required InteractionSource source}) {
|
||||
_track('user_followed', {'source': source.value});
|
||||
}
|
||||
|
||||
/// 取关成功响应后。
|
||||
void userUnfollowed({required InteractionSource source}) {
|
||||
_track('user_unfollowed', {'source': source.value});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user