iOS 提交
This commit is contained in:
265
CHANGELOG.md
265
CHANGELOG.md
@@ -6,6 +6,271 @@
|
||||
|
||||
***
|
||||
|
||||
## [v6.144.0] - 2026-06-27
|
||||
|
||||
### 🐛 修复句子广场已收藏句子重复出现且未显示状态 + 按钮轮廓不清
|
||||
|
||||
#### 问题现象
|
||||
1. 句子广场中已收藏的句子在循环加载时重复出现,且未显示"已收藏"状态
|
||||
2. 未点赞/未收藏时按钮轮廓过淡(边框 0.5px + 25% white),用户无法辨识可点击区域
|
||||
|
||||
#### 根因分析
|
||||
1. **服务端根因**:`/api/feed/list` 接口 `_batchAttachInteractionCounts` 只回填聚合计数(like_count 等),不返回当前用户的 `is_liked`/`is_favorited` 状态。客户端 `HomeSentence.fromFeedItem` 解析时降级为 false,导致已收藏句子重复出现时显示"未收藏"。
|
||||
2. **客户端根因**:`fetchRefreshSentences`/`fetchNewSentences` 直接使用服务端返回的 `isLiked`/`isFavorited`,未合并本地 DB 的互动状态。未登录用户服务端始终返回 false,本地 DB 是唯一状态来源但未被读取。
|
||||
3. **循环加载根因**:`cycleRound >= 3` 时 `allSeenIds.clear()` 清空所有已见 ID,已收藏句子的去重记录也一并清除,导致循环加载时已收藏内容重复出现。
|
||||
4. **按钮样式根因**:`LiquidGlassActionButton` 未激活态边框仅 0.5px + 25% white 透明度,视觉上几乎不可见。
|
||||
|
||||
#### 服务端修复
|
||||
**`docs/toolsapi/application/api/controller/Feed.php`**
|
||||
- `_batchAttachInteractionCounts(&$items, $userId = 0)`:新增 `$userId` 参数,当用户已登录时批量查询 `feed_interaction` 表中该用户的 like/favorite 记录,回填 `is_liked`/`is_favorited` 字段。
|
||||
- `list()` 方法:调用处从 `$this->_batchAttachInteractionCounts($items, $userId)` 改为 `$this->_batchAttachInteractionCounts($items, $this->_getUserId())`(修复未定义变量 `$userId`)。
|
||||
- `_batchLoadFeedItems()` 方法:同样传入 `$this->_getUserId()`。
|
||||
- **缓存隔离**:缓存命中后重新调用 `_batchAttachInteractionCounts` 按当前用户重查 per-user 字段;缓存写入前剥离 `is_liked`/`is_favorited` 为 false,避免不同用户串状态。
|
||||
|
||||
#### 客户端修复
|
||||
**`lib/core/storage/database/app_database.dart`**
|
||||
- 新增 `getSentencesByIds(List<String> ids)` 批量查询方法,返回 `Map<String, Sentence>`,用于合并本地 DB 互动状态。
|
||||
|
||||
**`lib/features/home/providers/home_feed_mixin.dart`**
|
||||
- 新增 `_mergeLocalInteractionState(sentences)` 私有方法:批量查询本地 DB,采用 OR 合并策略(`local true wins`)——服务端 true 或本地 true 均为 true。覆盖未登录场景(服务端始终 false,本地 DB 是唯一来源)和登录场景(本地兜底未同步的离线操作)。
|
||||
- `fetchRefreshSentences`:`result.list.map(HomeSentence.fromFeedItem)` 后调用 `_mergeLocalInteractionState` 合并本地状态。
|
||||
- `fetchNewSentences`:同上,生成 `mergedSentences` 供后续去重/循环加载使用。
|
||||
- `cycleRound >= 3` 循环加载:清空 `allSeenIds`/`allSeenTexts` 前保留已收藏句子的 ID 和文本,恢复到去重集合中;`recentIds`/`recentTexts` 合并已收藏内容,确保循环加载时跳过已收藏句子。
|
||||
|
||||
**`lib/features/home/presentation/home_sentence_card.dart`**
|
||||
- `LiquidGlassActionButton` 未激活态:边框宽度 0.5px → 1.2px;背景透明度加深(深色 8%→15% white,浅色 18%→28% white);边框透明度加深(深色 12%→30% white,浅色 25%→50% white)。
|
||||
|
||||
#### 验证
|
||||
- `flutter analyze` 三个修改文件 0 issues
|
||||
- curl 验证服务端:`limit=8` 非缓存路径返回 `"is_liked":false,"is_favorited":false`(未登录正确);缓存命中路径 `"成功(cache)"` 同样返回字段(重新附加 per-user 状态)
|
||||
- 缓存隔离验证:缓存写入前剥离 per-user 字段,命中后按当前用户重查,杜绝跨用户串状态
|
||||
|
||||
#### 不足与建议
|
||||
- **缓存命中额外查询**:每次缓存命中调用 `_batchAttachInteractionCounts` 会重新查询聚合计数(冗余),可后续拆分为仅查询 per-user 状态的轻量方法。
|
||||
- **跨设备同步延迟**:未登录用户依赖本地 DB,登录用户首次加载时服务端返回正确状态,但缓存命中时 per-user 状态依赖 `_batchAttachInteractionCounts` 重查,若 DB 负载高可能增加延迟。
|
||||
- **循环加载已收藏保留策略**:当前保留全部已收藏 ID 在 `allSeenIds` 中,若用户收藏量极大(>300),`_buildSeenIdList` 会截断为最近 300 条,早期收藏可能再次出现。可考虑按收藏时间分页保留。
|
||||
|
||||
***
|
||||
|
||||
## [v6.143.0] - 2026-06-27
|
||||
|
||||
### 🐛 修复 lite 模式点赞/取消点赞后 like_count 不更新的问题
|
||||
|
||||
#### 问题现象
|
||||
- 主页句子广场(lite 模式,limit=5)点赞或取消点赞后,like_count 不变化
|
||||
- curl 实测:unlike 后 lite list 返回 `msg=成功(cache)`,like_count 仍为旧值
|
||||
- 非 lite 模式(limit=10/20)正常,仅 lite 模式 + limit=5 异常
|
||||
|
||||
#### 根因分析(三层 bug)
|
||||
1. **真正根因(核心)**:`_clearFeedCache` 的 `$limits = [10, 20, 30, 50]` **不包含 5**,而客户端 lite 模式请求用 limit=5,导致 `feed_list_hitokoto_newest_1_5_lite` 这个 cacheKey 从未被清理。互动操作后旧缓存仍然命中,返回过期数据。
|
||||
2. **潜在 bug(已兜底)**:`Cache::rm` 在某些环境(opcache 缓存旧代码 / ThinkPHP complex 驱动异常)下不删除文件。诊断脚本中 `Cache::rm` 返回 true 但文件仍在。
|
||||
3. **历史遗漏**:`_clearFeedCache` 之前未覆盖 `_lite` 后缀的 cacheKey(v6.142.0 已加,但 limit=5 才是关键阻塞点)。
|
||||
|
||||
#### 服务端修复
|
||||
**`docs/toolsapi/application/api/controller/Feed.php`**
|
||||
- `_clearFeedCache`:`$limits` 从 `[10, 20, 30, 50]` 扩展为 `[5, 10, 15, 20, 25, 30, 40, 50]`,覆盖客户端所有可能的 limit 值(含 5)。同步修复 `_clearWeightCache` 的同名数组。
|
||||
- 新增 `_unlinkCacheFile($cacheKey)` 私有方法:在 `Cache::rm` 之后兜底,按 ThinkPHP 5 File 驱动路径规则(`CACHE_PATH/前2位md5/剩余30位md5.php`)直接 `unlink` 缓存文件,绕过 opcache 缓存旧代码 / complex 驱动异常。
|
||||
- `_clearFeedCache` 所有 `Cache::rm` 调用后均追加 `$this->_unlinkCacheFile()` 兜底,覆盖 singleKeys / list 列表 / trending / 平台后缀 全部缓存 key。
|
||||
- 移除 v6.142.0 排查期间遗留的 7 处临时调试代码(`file_put_contents` 写入 unlike_debug/clearcache_debug/feed_cache_debug 日志)。
|
||||
|
||||
#### 验证
|
||||
自动化测试脚本 `/tmp/test_lite_cache.py` 全流程验证通过:
|
||||
```
|
||||
[stepA] 预热: msg='成功' feed_id=8812 like_count=3
|
||||
[stepB] like: msg='操作成功'
|
||||
[stepC] like后: msg='成功' like_count=4 期望=4 cache命中=False [OK]
|
||||
[stepD] unlike: msg='操作成功'
|
||||
[stepE] unlike后: msg='成功' like_count=3 期望=3 cache命中=False [OK]
|
||||
=== 全部通过: lite 模式缓存清理修复成功 ===
|
||||
```
|
||||
关键验证点:
|
||||
- stepC/stepE 的 `msg` 不再包含 `cache`,证明缓存已被清理、强制重新查库
|
||||
- like_count 正确递增(3→4)和递减(4→3)
|
||||
|
||||
#### 排查过程经验
|
||||
- 最初误判"unlike 不递减",实际是缓存竞态。加 sleep 2 秒重测发现 unlike 本身工作正常。
|
||||
- 调试日志(file_put_contents)全部为空,误导排查方向。根因是 opcache 缓存了旧 Feed.php(不含调试日志的版本),PHP-FPM 重启后 opcache 未彻底清理。通过 `opcache_invalidate(Feed.php, true)` + `opcache_reset()` 解决。
|
||||
- 最终通过在 `_unlinkCacheFile` 中加 trace 日志,对比 `newest_1_5_lite` 和 `newest_1_10_lite` 的记录,发现 limit=5 的 key 从未被处理,定位到 `$limits` 数组遗漏。
|
||||
|
||||
#### 文档同步
|
||||
- `API_FEED_DOC.md` 头部版本号 v2.4 → v2.5
|
||||
- 修正 v6.142.0 中关于 unlike 的错误结论(标注为已修复,指向本版本)
|
||||
|
||||
#### 不足与建议
|
||||
- **$limits 仍为枚举**:若未来客户端新增 limit=35 等值,需同步更新数组。建议后续改为 `range(5, 50, 5)` 或用 glob 扫描缓存文件前缀的方式彻底规避。
|
||||
- **opcache 自动清理依赖**:当前依赖 `validate_timestamps=1` + `revalidate_freq=3`,文件更新后最多 3 秒延迟。若部署后 3 秒内有请求命中旧 opcache,仍可能用旧代码。建议部署脚本中调用 `opcache_reset` 或重启 PHP-FPM。
|
||||
- **Cache::rm 与 unlink 双写**:当前每次互动操作执行约 320 次 `Cache::rm + unlink`,性能可接受但略显冗余。可考虑用 `Cache::clear()` 清空整个缓存目录,但会影响其他模块缓存命中率。
|
||||
|
||||
***
|
||||
|
||||
## [v6.142.0] - 2026-06-27
|
||||
|
||||
### 🐛 修复主页句子广场点赞按钮显示 0 的问题
|
||||
|
||||
#### 根因分析(双重 bug)
|
||||
1. **服务端根因**:`/api/feed/list` 接口 `_formatItem` 默认不返回 `like_count/favorite_count/comment_count/share_count` 字段,只有 `/api/feed/detail` 才附加。客户端 `FeedItem.fromJson` 解析 `null` → 默认 0 → 按钮显示 0。
|
||||
2. **客户端根因**:`toggleLike`/`toggleFavorite` 乐观更新时只翻转 `isLiked/isFavorited`,未递增/递减 `likeCount/favoriteCount`,导致即使点击点赞数字也不变。
|
||||
|
||||
#### 服务端修复
|
||||
**`docs/toolsapi/application/api/controller/Feed.php`**
|
||||
- 新增 `_batchAttachInteractionCounts(&$items)` 私有方法:
|
||||
- 收集所有 `(feed_type, feed_id)` 对
|
||||
- 按 feed_type 分组批量查询 `feed_interaction` 表,`GROUP BY feed_id, action` 一次性获取 like/favorite/comment/share 计数
|
||||
- 回填到 items 的 `like_count/favorite_count/comment_count/share_count` 字段
|
||||
- 避免 N+1 查询,列表接口性能影响极小
|
||||
- `list()` 方法末尾调用,`_batchLoadFeedItems()` 末尾也调用(覆盖 favorites/likes/history/readlater 列表)
|
||||
- 查询失败时降级填充 0,保证字段始终存在(客户端不再解析 null)
|
||||
|
||||
#### 客户端修复
|
||||
**`lib/features/home/providers/home_interaction_mixin.dart`**
|
||||
- `toggleLike` 乐观更新:除翻转 `isLiked` 外,同步递增/递减 `likeCount`(点赞 +1,取消点赞 -1,下限 0)
|
||||
- `toggleFavorite` 乐观更新:除翻转 `isFavorited` 外,同步递增/递减 `favoriteCount`(举一反三,同类问题)
|
||||
- `_persistLikeLocally` 兜底插入分支:调用新增的 `setLikesCount` 同步写入本地 DB 的 `likes` 字段
|
||||
|
||||
**`lib/core/storage/database/app_database.dart`**
|
||||
- 新增 `setLikesCount(id, count)` 方法:
|
||||
- 由于 `.g.dart` 未重新生成(`build_runner` 受 freezed/analyzer 版本冲突阻塞),`SentencesCompanion` 缺少 `likes` 命名参数
|
||||
- 改用 `customStatement` 直接执行 SQL:`UPDATE sentences SET likes = ?, updated_at = ? WHERE id = ?`
|
||||
- 自动 clamp 负数为 0
|
||||
|
||||
**`lib/features/home/providers/home_sentence_model.dart`**
|
||||
- `HomeSentence.fromDb` 添加注释说明 `likes` 字段未读取原因(Sentence 类缺 `likes` getter,.g.dart 未重新生成)
|
||||
- 兜底场景保持 likeCount=0,主场景由服务端返回真实数据
|
||||
|
||||
#### 验证
|
||||
- curl 实测 `/api/feed/list` 修复前:`like_count= MISSING favorite_count= MISSING`
|
||||
- curl 实测 `/api/feed/list` 修复后:`like_count= 4 favorite_count= 3 comment_count= 0`(真实数据)
|
||||
- 端到端验证:登录 → 查询 detail(like_count=4, is_liked=True)→ list 接口返回一致 like_count=4
|
||||
- `flutter analyze` 三个修改文件零警告
|
||||
|
||||
#### 文档同步
|
||||
- `API_FEED_DOC.md` 头部版本号 v2.3 → v2.4
|
||||
- 3.4 节响应字段说明新增 v2.4 更新提示
|
||||
|
||||
#### 不足与建议
|
||||
- ~~**服务端 unlike 潜在 bug**:curl 实测 unlike 后 list 接口的 like_count 仍为 4,没有递减。可能 feed_interaction 表的 unlike 操作未真正删除记录(待排查 action 方法逻辑)。~~ **[v6.143.0 已修复]** 实际根因是 `_clearFeedCache` 的 `$limits` 数组不含 5,导致 lite 模式 limit=5 的缓存未被清理,详见 v6.143.0。
|
||||
- **`build_runner` 阻塞**:导致 `SentencesCompanion`/`Sentence` 类缺少 `likes` 字段,本地兜底场景 likeCount 显示 0。建议解决 freezed 3.2.5/analyzer 12.1.0 版本冲突后重新生成。
|
||||
- **可扩展**:`_batchAttachInteractionCounts` 可进一步附加 `is_liked`/`is_favorited` 字段(需登录用户ID),让 list 接口也能返回用户个人互动状态,避免客户端依赖 detail 接口。
|
||||
|
||||
***
|
||||
|
||||
## [v6.141.0] - 2026-06-27
|
||||
|
||||
### ✨ LiquidGlassActionButton 深度升级 + 触觉音效 + 长按交互
|
||||
|
||||
#### 背景
|
||||
v6.140.0 完成句子卡片按钮重设计与收藏 Bug 修复后,识别出 5 项不足。本版本逐一解决。
|
||||
|
||||
#### 改进清单
|
||||
|
||||
##### 1. 真正的 BackdropFilter 液态玻璃
|
||||
**`lib/features/home/presentation/home_sentence_card.dart`**
|
||||
- `LiquidGlassActionButton` 未激活态改用真正的 `BackdropFilter` + `ImageFilter.blur`
|
||||
- sigma 计算:`GlassTokens.baseBlur * ext.glassBlurMultiplier`(按钮较小用 base 层 10.0)
|
||||
- 性能降级链路:
|
||||
- 鸿蒙端 `OhosDeviceCapabilities.supportsBackdropFilter` 判断
|
||||
- `PerformanceOptimizer.instance.shouldEnableBackdropFilter` 省电模式降级
|
||||
- `PerformanceOptimizer.instance.suggestedBlurSigma()` 低端设备 sigma 缩放
|
||||
- `RepaintBoundary` 隔离重绘,避免列表滚动重复模糊
|
||||
- 激活态仍用渐变填充(无需模糊)
|
||||
|
||||
##### 2. 统一动画驱动
|
||||
- 移除 `AnimatedScale` + `ScaleTransition` 双重叠加
|
||||
- 改为单一 `AnimatedBuilder` + `Transform.scale` 统一缩放整个按钮(icon + 文字)
|
||||
- 动画曲线 `Curves.easeInOutBack`,时长 400ms
|
||||
|
||||
##### 3. 跨模块文案依赖修复
|
||||
- `t.discover.base.favorite/favorited` → `t.home.sentenceDetail.favorite/favorited`
|
||||
- home 模块不再依赖 discover 模块翻译,符合 AGENTS.md 架构约束
|
||||
- `THomeBase` 无 favorite 字段,但 `TSentenceDetail`(home.sentenceDetail)已有完整字段
|
||||
|
||||
##### 4. 稍后读同类 Bug 排查(无需修复)
|
||||
- 调查发现 `_persistReadLaterLocally` **不写 sentences 表**
|
||||
- 稍后读状态通过 `ChatMessageService.sendReadLaterSentence` 写入 ChatMessage 表(会话 ID `'readlater'`)
|
||||
- 取消时调 `db.softDeleteChatMsgRecord` 软删除
|
||||
- 已有 try-catch + `inserted = true` 兜底,逻辑鲁棒
|
||||
- **结论**:稍后读不存在 UPDATE 静默失败问题,无需修复
|
||||
|
||||
##### 5. HapticService + SfxService 触觉音效接入
|
||||
**`lib/features/home/presentation/home_sentence_card.dart`**
|
||||
- 新增 `HapticType` 枚举(like / favorite)
|
||||
- `LiquidGlassActionButton` 新增 `hapticType` 参数
|
||||
- 激活态:`HapticService.medium()` + `SfxService.instance.play(SfxType.like/favorite)`
|
||||
- 取消态:`HapticService.light()` + `SfxService.instance.play(SfxType.unlike/unfavorite)`
|
||||
- 反馈在 `didUpdateWidget` 中根据状态变化触发,与动画同步
|
||||
|
||||
##### 6. 长按卡片触发重冲击
|
||||
**`lib/core/utils/ui/interaction_animations.dart`**
|
||||
- `PressableCard` 新增 `onLongPress` 参数
|
||||
- 长按触发时自动回弹(避免停留在按压态)
|
||||
**`lib/features/home/presentation/home_sentence_card.dart`**
|
||||
- `SentenceCard` 接入长按:`HapticService.heavy()` + 触发详情面板
|
||||
- 与点击效果一致,但提供差异化触觉反馈(重冲击 vs 无冲击)
|
||||
|
||||
##### 7. 服务端 /api/feed/favorites 验证
|
||||
- 服务端代码审查:`Feed.php::_formatItem` 第 2186 行明确返回 `'feed_type' => $type`
|
||||
- 客户端解析:`FeedItem.fromJson` 第 295 行 `feedType: json['feed_type'] as String? ?? ''`
|
||||
- 接口在线验证:curl 返回 401(未登录,非 404),接口正常
|
||||
- **结论**:feed_type 字段链路完整,无需修复
|
||||
|
||||
#### 验证
|
||||
- `flutter analyze` 通过,0 error / 0 warning
|
||||
- 2 个修改文件的 IDE 诊断全部通过
|
||||
|
||||
#### 改动文件
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| [home_sentence_card.dart](file:///Users/wushu/Documents/trae_projects/project/xianyan/lib/features/home/presentation/home_sentence_card.dart) | LiquidGlassActionButton 重构 + HapticType + 长按接入 + 文案改用 home.sentenceDetail |
|
||||
| [interaction_animations.dart](file:///Users/wushu/Documents/trae_projects/project/xianyan/lib/core/utils/ui/interaction_animations.dart) | PressableCard 新增 onLongPress 支持 |
|
||||
|
||||
---
|
||||
|
||||
## [v6.140.0] - 2026-06-27
|
||||
|
||||
### 🐛 修复句子广场收藏在「我的收藏」页面不显示的 Bug + 卡片按钮重设计
|
||||
|
||||
#### 背景
|
||||
用户反馈主页「句子广场」的句子卡片点击收藏后,在「我的收藏」页面没显示收藏的句子。同时要求重新设计点赞/收藏按钮为长方形 icon+文字 样式,去掉分享 icon 按钮。
|
||||
|
||||
#### Bug 根因(多重故障叠加)
|
||||
1. **本地数据库 UPDATE 静默失败**:`home_interaction_mixin._persistFavoriteLocally` 直接调用 `interactionDb.toggleFavorite(id)` UPDATE,若 sentences 表中不存在该 id 的记录(Feed 列表未缓存/缓存被清),UPDATE 静默失败,本地 isFavorite 字段从未被设置。
|
||||
2. **Legacy API 硬编码 `targetType: 'article'`**:`favorite_repository._loadLegacyFavorites` 中硬编码 `targetType: 'article'`,但句子广场的 feedType 是 `hitokoto`/`chengyu`/`hanzi`/`poetry` 等,降级 Legacy API 永远查不到句子收藏。
|
||||
3. **`mergeWithLocalDb` 去重 key 不鲁棒**:仅用 `content+author` 作为去重 key,空 content 时退化为 `|title`,多条空 content 条目互相误去重;且未利用 id 信息,服务端和本地同一句子(id 相同但 content 微小差异)会被当作两条。
|
||||
4. **降级条件过激**:`_loadFeedFavorites` 中 `result.list.isEmpty && result.total == 0 && page == 1` 触发立即降级 Legacy API,在服务端 bug / 缓存延迟 / 新用户无收藏等场景下误降级。
|
||||
|
||||
#### 修复
|
||||
| 文件 | 修复 |
|
||||
|------|------|
|
||||
| `lib/features/home/providers/home_interaction_mixin.dart` | `_persistFavoriteLocally` 改为先查再决定 `setFavoriteFlag` 或 `insertOrUpdateSentence` upsert 兜底;举一反三同步修复 `_persistLikeLocally` 相同问题 |
|
||||
| `lib/core/storage/database/app_database.dart` | 新增 `setLikeFlag(id, value)` 方法(与 `setFavoriteFlag` 对称),避免 `toggleLike` 在记录不存在时静默失败 |
|
||||
| `lib/features/home/favorite_repository.dart` | `_loadLegacyFavorites` 移除硬编码 `targetType: 'article'`,让服务端返回所有类型收藏 |
|
||||
| `lib/features/home/favorite_repository.dart` | `mergeWithLocalDb` 改为双重去重策略:主 key 用 `targetType\|targetId`,副 key 用 `content\|title`(空 content 不参与去重) |
|
||||
| `lib/features/home/favorite_repository.dart` | `_loadFeedFavorites` 空结果时先检查本地数据库,本地有则兜底返回,避免误降级 Legacy API |
|
||||
|
||||
#### 卡片按钮重设计(方案 C · 液态玻璃)
|
||||
**`lib/features/home/presentation/home_sentence_card.dart`**:
|
||||
- 移除分享 icon 按钮(分享功能保留在左滑手势和详情面板入口)
|
||||
- 移除 `_shareAsImage` / `_shareAsText` / `_isSharing` / `_repaintKey` 及相关 import(dart:io / dart:ui / path_provider / share_plus / kIsWeb)
|
||||
- 新增 `LiquidGlassActionButton` 组件(iOS 26 Liquid Glass 风格):
|
||||
- 长方形 icon + 文字 按钮
|
||||
- 未激活:半透明玻璃材质 + 微高光 + 中性色
|
||||
- 已激活:渐变填充 + 内嵌高光 + 投射阴影 + 白色 icon/文字
|
||||
- 点击触发弹跳动画(AnimationController + easeInOutBack 曲线)
|
||||
- 自动跟随 `AppThemeExtension` 切换明/暗/AMOLED/主色
|
||||
- 点赞按钮:❤️ + 数字(用 `NumberFormatter.formatCount`),激活态 iOS systemRed 渐变
|
||||
- 收藏按钮:★ + 文字"收藏"/"已藏"(用 `t.discover.base.favorite/favorited`),激活态 iOS systemOrange 渐变
|
||||
- 新增 `_ToolIconButton` 抽出拼音/翻译/TTS 工具图标共用组件,添加无障碍语义标签
|
||||
- HTML 设计原型:`docs/prototypes/sentence_card_redesign.html`(4 种方案 + 动态主题切换预览)
|
||||
|
||||
#### 验证
|
||||
- `flutter analyze` 通过,0 error / 0 warning
|
||||
- 4 个修改文件的 IDE 诊断全部通过
|
||||
|
||||
#### 不足与扩展建议
|
||||
详见本次会话末尾的「审计验收」总结。
|
||||
|
||||
---
|
||||
|
||||
## [v6.139.0] - 2026-06-26
|
||||
|
||||
### 🧹 macOS 编译验证 + 静态分析清理
|
||||
|
||||
@@ -5,7 +5,7 @@ import os
|
||||
import glob
|
||||
|
||||
HOST = '123.207.67.197'
|
||||
PORT = 22
|
||||
PORT = 2026
|
||||
USER = 'root'
|
||||
PASS = '520Kiss123'
|
||||
REMOTE_DIR = '/www/wwwroot/tools.wktyl.com/public/agreements/'
|
||||
|
||||
@@ -1,5 +1,208 @@
|
||||
# CHANGELOG
|
||||
|
||||
## v10.3.2 (2026-06-27)
|
||||
|
||||
### 🔐 隐私权管理增强 - 多方式登录/忘记密码注销/SMTP检测
|
||||
|
||||
**重要更新**:完善隐私权管理页面登录与注销流程,新增SMTP配置检测、4种登录方式、忘记密码注销回执码流程,前端UI全面重构。
|
||||
|
||||
#### 新增功能
|
||||
|
||||
| 功能 | 说明 | 优先级 |
|
||||
|------|------|--------|
|
||||
| 🔍 SMTP配置检测 | 公开API检测SMTP主机/端口/加密/密码是否配置完整+TCP端口可达性 | ⭐⭐⭐ |
|
||||
| 🔑 4种登录方式 | 密码/密保问题/邮箱验证码/回执号登录,iOS分段控制器切换 | ⭐⭐⭐⭐⭐ |
|
||||
| 📧 忘记密码注销 | 凭邮箱或密保答案验证身份→发送回执码→凭回执码提交注销(无需登录) | ⭐⭐⭐⭐⭐ |
|
||||
| 👤 状态查询显示昵称 | accountLookup返回nickname字段(脱敏处理,非账号) | ⭐⭐⭐⭐ |
|
||||
| 🌐 URL语言后缀 | 切换语言时URL同步更新`?lang=zh`/`?lang=en` | ⭐⭐⭐ |
|
||||
| 📋 注销事件查询 | 原"设备Tab"改为"注销事件查询",展示注销申请记录+删除进度 | ⭐⭐⭐ |
|
||||
| ✅ 注销协议勾选框 | 注销确认输入框下方增加协议勾选(可超链接),未勾选禁止提交 | ⭐⭐⭐⭐ |
|
||||
|
||||
#### 新增API接口
|
||||
|
||||
| 接口 | 方法 | 路径 | 说明 |
|
||||
|------|------|------|------|
|
||||
| SMTP状态检测 | GET | `/api/user_security/checkSmtpStatus` | 返回配置完整度+端口可达性 |
|
||||
| 密保问题登录 | POST | `/api/user_security/secQuestionLogin` | account+sec_answer |
|
||||
| 邮箱验证码登录 | POST | `/api/user_security/emailCodeLogin` | email+captcha(未注册自动创建) |
|
||||
| 发送注销回执码 | POST | `/api/user_security/sendDeletionCode` | 验证身份后发送6位码到邮箱 |
|
||||
| 凭回执码注销 | POST | `/api/user_security/requestDeletionByReceipt` | 无需登录,凭回执码提交注销 |
|
||||
|
||||
#### 前端变更
|
||||
|
||||
| 区域 | 变更 |
|
||||
|------|------|
|
||||
| Tab结构 | 移除"其他协议"Tab;"设备"Tab改为"📋 注销事件查询" |
|
||||
| 登录表单 | 重构为4种方式(iOS SegmentedControl):密码/密保/邮箱验证码/回执号 |
|
||||
| 忘记密码注销 | 新增可折叠区域:邮箱/密保两种验证→发送回执码→提交注销 |
|
||||
| 注销确认 | 增加协议勾选框(超链接《账号使用协议》和《隐私政策》) |
|
||||
| 状态查询 | 显示"👤 用户昵称: xxx" |
|
||||
| URL语言 | `?lang=zh`/`?lang=en` 后缀同步(history.replaceState) |
|
||||
| 多语言 | zh/en各新增约50个I18N key |
|
||||
| 文件行数 | 1942行 → 2687行 |
|
||||
|
||||
#### 测试结果
|
||||
|
||||
```
|
||||
✅ SMTP配置检测 配置完整+端口可达(host=free.mboxhosting.com:465/SSL)
|
||||
✅ accountLookup昵称 返回nickname字段
|
||||
✅ 密保问题登录 API正常(测试账号未设密保→正确返回错误)
|
||||
✅ 发送注销回执码 回执码已发送到 pt*****@test.local
|
||||
✅ 错误回执码拦截 错误回执码被正确拦截
|
||||
✅ 前端5个URL HTTP 200 (中文/英文/注销Tab/事件Tab/偏好Tab)
|
||||
通过率: 10/10 (100%)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v10.3.1 (2026-06-27)
|
||||
|
||||
### 🛡️ 隐私权管理增强 - GDPR/PIPL/COPPA合规升级
|
||||
|
||||
**重要更新**:完善隐私权管理功能,新增6大能力(隐私偏好/设备管理/注销进度/未成年保护/邮件通知/数据最小化),修复跨域BUG,达到GDPR/PIPL/COPPA合规要求。
|
||||
|
||||
#### 新增功能
|
||||
|
||||
| 功能 | 说明 | 合规依据 |
|
||||
|------|------|----------|
|
||||
| 🔧 隐私偏好管理 | 4类数据处理同意开关(analytics/marketing/personalization/third_party_share) | GDPR Art.7 |
|
||||
| 📱 登录设备管理 | 设备列表/位置/最后活跃时间,支持远程登出 | 用户体验+安全 |
|
||||
| 📊 数据删除进度可视化 | 注销后展示8类数据删除进度条(账号/Token/设备/收藏/笔记/签到/积分/文章) | 透明度要求 |
|
||||
| 👨👩👧 未成年保护 | 注册时校验出生日期,14岁以下禁止注册,14-17岁开启家长控制(每日120分钟/内容过滤/禁消费) | COPPA/GDPR-K |
|
||||
| 📧 注销邮件通知 | 用户提交注销后异步发送确认邮件到注册邮箱,防冒用 | 安全要求 |
|
||||
| 🗑️ 数据最小化保留 | 注销记录保留期从2年→6个月,username做SHA256哈希存储 | PIPL最小化原则 |
|
||||
|
||||
#### BUG修复
|
||||
|
||||
| BUG | 原因 | 修复 |
|
||||
|-----|------|------|
|
||||
| ❌ 注销提交后后台查不到 | `API_BASE='https://tools.wktyl.com'`从s2ss.com发起跨域请求,`__token__`响应头不可读导致authToken=null | API_BASE改为相对路径`''`,优先从JSON body读取token |
|
||||
| ❌ 管理后台注销列表空 | admin/controller/Userdeletion.php中`$list`查询未带filter | 使用单一`$listQuery`统一过滤 |
|
||||
| ❌ 英文模式下输入框仍显示中文 | `switchLang()`未更新placeholder | 新增`phLookupAccount`/`phLoginAccount`/`phLoginPassword`翻译 |
|
||||
| ❌ SQL迁移ADD COLUMN IF NOT EXISTS失败 | 服务器MySQL版本不支持该语法 | 改用存储过程+INFORMATION_SCHEMA检查 |
|
||||
|
||||
#### 新增API接口
|
||||
|
||||
| 接口 | 方法 | 路径 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 隐私偏好查询 | GET | `/api/user_security/getConsents` | 返回4类同意状态 |
|
||||
| 隐私偏好更新 | POST | `/api/user_security/updateConsents` | 批量更新同意开关 |
|
||||
| 登录设备列表 | GET | `/api/user_security/listDevices` | 返回设备列表含位置/最后活跃 |
|
||||
| 远程登出设备 | POST | `/api/user_security/revokeDevice` | 通过id/device_id登出指定设备 |
|
||||
| 注销进度查询 | GET | `/api/user_security/deletionProgress` | 返回8类数据删除状态+百分比 |
|
||||
| 家长控制状态 | GET | `/api/user_security/parentalControlStatus` | 返回未成年状态+家长控制配置 |
|
||||
| 更新出生日期 | POST | `/api/user_security/updateBirthday` | 补充/修改出生日期,自动更新未成年状态 |
|
||||
| 清理旧注销记录 | GET | `/api/user_security/cleanupOldDeletionRecords` | 清理6个月以上已完成注销记录 |
|
||||
|
||||
#### 数据库变更
|
||||
|
||||
| 表 | 变更 | 说明 |
|
||||
|----|------|------|
|
||||
| `tool_user_consents` | 新建表 | 用户数据处理同意记录(user_id+consent_type唯一) |
|
||||
| `tool_user_deletion` | 新增 `deletion_progress` text | 注销进度JSON |
|
||||
| `tool_user_deletion` | 新增 `deletion_summary` varchar(500) | 注销数据摘要 |
|
||||
| `tool_user` | 新增 `birthday` date | 出生日期 |
|
||||
| `tool_user` | 新增 `is_minor` tinyint(1) | 是否未成年(0/1) |
|
||||
|
||||
#### 前端变更
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `privacy-rights.html` | 4→6 Tab(新增📱设备/⚙️偏好),注销进度条,浏览器语言自动检测,登录态联动 |
|
||||
| `account-agreement.html` | V6.6→V6.7:保留期2年→6个月+SHA256哈希 |
|
||||
|
||||
#### 测试结果
|
||||
|
||||
```
|
||||
✅ register(带birthday) code=1 注册成功,age=31 is_minor=0
|
||||
✅ parentalControlStatus code=1 返回家长控制配置
|
||||
✅ updateBirthday(未成年) code=1 改为2010年生,is_minor=1
|
||||
✅ getConsents code=1 返回4项同意配置
|
||||
✅ updateConsents code=1 成功更新4项
|
||||
✅ listDevices code=1 返回设备列表
|
||||
✅ deletionProgress code=1 返回进度
|
||||
✅ privacy-rights.html HTTP 200 (devices/preferences tab正常)
|
||||
通过率: 8/8 (100%)
|
||||
```
|
||||
|
||||
#### 部署清单
|
||||
|
||||
- 已上传7个文件到 `123.207.67.197:/www/wwwroot/tools.wktyl.com/`
|
||||
- 备份目录: `backup_privacy_rights_20260627_021954`
|
||||
- SQL迁移已执行(存储过程兼容MySQL 5.7)
|
||||
|
||||
---
|
||||
|
||||
## v10.3.0 (2026-06-27)
|
||||
|
||||
### 🛡️ 隐私权管理中心 + Google Play应用外账户管理
|
||||
|
||||
**重大更新**:新增独立的隐私权管理网页,满足Google Play数据安全合规要求,支持应用外账号状态查询与注销。
|
||||
|
||||
#### 新增功能
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 🛡️ 隐私权管理页面 | 4-Tab界面:状态查询/注销账号/隐私权利/其他协议,Apple风格设计,中英双语 |
|
||||
| 🔍 公开账号状态查询 | `accountLookup` API,无需登录即可查询5种状态(正常/封锁/注销中/已注销/无记录) |
|
||||
| 🔐 HMAC-SHA256防枚举 | 查询接口需回执签名,防止账号枚举攻击 |
|
||||
| 📋 账号协议V6.6 | 新增长期未登录账号回收政策(12/18/24个月三级)、服务器数据删除时限、Google Play合规说明 |
|
||||
| 🧪 全流程验证脚本 | `test_privacy_rights_flow.py`,覆盖注册→登录→查询→注销→查询状态→撤销6个接口 |
|
||||
|
||||
#### 新增文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `public/agreements/privacy-rights.html` | 隐私权管理中心网页(1488行,含Web Crypto API回执生成) |
|
||||
| `Scripts/upload_privacy_rights.py` | 服务器文件上传脚本(含备份、SSH端口2026) |
|
||||
| `Scripts/test_privacy_rights_flow.py` | 全流程接口验证脚本(6/6通过) |
|
||||
|
||||
#### 修改文件
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|----------|
|
||||
| `application/api/controller/UserSecurity.php` | 新增 `accountLookup()` 方法(1152行),加入 `$noNeedLogin` 和 `$rateLimits` |
|
||||
| `application/route.php` | 新增 `api/user_security/accountLookup` 路由(第73行) |
|
||||
| `public/agreements/account-agreement.html` | 升级V6.6:新增3.4长期未登录账号回收政策、4.5服务器数据删除时限、4.6应用外账户管理支持 |
|
||||
| `public/agreements/index.html` | 隐私与安全分区新增"隐私权管理"入口(中英文) |
|
||||
| `Scripts/upload_agreements.py` | 修复SSH端口从22改为2026 |
|
||||
|
||||
#### API接口
|
||||
|
||||
| 接口 | 方法 | 路径 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 账号状态查询 | POST | `/api/user_security/accountLookup` | 公开接口,需回执验证,限流20次/小时/IP |
|
||||
|
||||
#### 状态查询返回值
|
||||
|
||||
| status | status_text | 说明 |
|
||||
|--------|-------------|------|
|
||||
| `normal` | 正常 | 账号正常使用 |
|
||||
| `blocked` | 封锁 | 账号被封禁 |
|
||||
| `deleting` | 注销中 | 注销申请审核中 |
|
||||
| `deleted` | 已注销 | 账号已注销 |
|
||||
| `no_record` | 无记录 | 查无此账号 |
|
||||
|
||||
#### 测试结果
|
||||
|
||||
```
|
||||
✅ register code=1 注册成功
|
||||
✅ login code=1 登录成功
|
||||
✅ accountLookup code=1 状态查询成功
|
||||
✅ requestDeletion code=1 注销申请提交成功
|
||||
✅ deletionStatus code=1 注销状态查询成功
|
||||
✅ cancelDeletion code=1 撤销注销成功
|
||||
通过率: 6/6 (100%)
|
||||
```
|
||||
|
||||
#### 部署URL
|
||||
|
||||
- 隐私权管理页:https://tools.wktyl.com/agreements/privacy-rights.html
|
||||
- 协议列表页:https://tools.wktyl.com/agreements/index.html
|
||||
- 账号协议页:https://tools.wktyl.com/agreements/account-agreement.html
|
||||
|
||||
---
|
||||
|
||||
## v10.2.1 (2026-06-25)
|
||||
|
||||
### 🛡️ 限流排队系统优化
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
-- ============================================================
|
||||
-- 迁移脚本: v10.3.1 隐私权管理增强
|
||||
-- 创建时间: 2026-06-27
|
||||
-- 描述: 新增用户同意管理表、注销进度字段、用户出生日期字段
|
||||
-- 兼容: MySQL 5.7+ (不使用 ADD COLUMN IF NOT EXISTS 语法)
|
||||
-- ============================================================
|
||||
|
||||
-- 1. 用户数据处理同意表 (GDPR/PIPL合规)
|
||||
CREATE TABLE IF NOT EXISTS `tool_user_consents` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(11) NOT NULL DEFAULT '0' COMMENT '用户ID',
|
||||
`consent_type` varchar(50) NOT NULL DEFAULT '' COMMENT '同意类型: analytics/marketing/personalization/third_party_share',
|
||||
`consent_value` tinyint(1) NOT NULL DEFAULT '1' COMMENT '0=拒绝 1=同意',
|
||||
`consent_source` varchar(20) NOT NULL DEFAULT 'web' COMMENT '来源: web/app/system',
|
||||
`ip` varchar(50) DEFAULT '' COMMENT 'IP地址',
|
||||
`createtime` int(11) DEFAULT NULL COMMENT '创建时间',
|
||||
`updatetime` int(11) DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_user_type` (`user_id`,`consent_type`),
|
||||
KEY `idx_user_id` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户数据处理同意记录';
|
||||
|
||||
-- 2. user_deletion表新增注销进度字段 (使用存储过程兼容MySQL 5.7)
|
||||
DROP PROCEDURE IF EXISTS `migrate_v10_3_add_columns`;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE `migrate_v10_3_add_columns`()
|
||||
BEGIN
|
||||
-- tool_user_deletion: deletion_progress
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'tool_user_deletion'
|
||||
AND COLUMN_NAME = 'deletion_progress') THEN
|
||||
ALTER TABLE `tool_user_deletion` ADD COLUMN `deletion_progress` text COMMENT '注销进度JSON' AFTER `admin_remark`;
|
||||
END IF;
|
||||
|
||||
-- tool_user_deletion: deletion_summary
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'tool_user_deletion'
|
||||
AND COLUMN_NAME = 'deletion_summary') THEN
|
||||
ALTER TABLE `tool_user_deletion` ADD COLUMN `deletion_summary` varchar(500) DEFAULT '' COMMENT '注销数据摘要' AFTER `deletion_progress`;
|
||||
END IF;
|
||||
|
||||
-- tool_user: birthday
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'tool_user'
|
||||
AND COLUMN_NAME = 'birthday') THEN
|
||||
ALTER TABLE `tool_user` ADD COLUMN `birthday` date DEFAULT NULL COMMENT '出生日期' AFTER `email`;
|
||||
END IF;
|
||||
|
||||
-- tool_user: is_minor
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'tool_user'
|
||||
AND COLUMN_NAME = 'is_minor') THEN
|
||||
ALTER TABLE `tool_user` ADD COLUMN `is_minor` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否未成年: 0=成年 1=未成年(需家长控制)' AFTER `birthday`;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
CALL `migrate_v10_3_add_columns`();
|
||||
DROP PROCEDURE IF EXISTS `migrate_v10_3_add_columns`;
|
||||
@@ -34,6 +34,7 @@ class Userdeletion extends Backend
|
||||
$filterJson = $this->request->get('filter', '{}');
|
||||
|
||||
$query = Db::name('user_deletion');
|
||||
$listQuery = Db::name('user_deletion');
|
||||
|
||||
try {
|
||||
$filter = json_decode($filterJson, true);
|
||||
@@ -41,29 +42,18 @@ class Userdeletion extends Backend
|
||||
foreach ($filter as $key => $val) {
|
||||
if ($val !== '' && $val !== null) {
|
||||
$query->where($key, '=', $val);
|
||||
$listQuery->where($key, '=', $val);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {}
|
||||
|
||||
$total = $query->count();
|
||||
$list = Db::name('user_deletion')
|
||||
$list = $listQuery
|
||||
->order($sort ?: 'createtime', $order ?: 'desc')
|
||||
->limit($offset, $limit)
|
||||
->select();
|
||||
|
||||
if (!empty($filter) && is_array($filter)) {
|
||||
$listQuery = Db::name('user_deletion');
|
||||
foreach ($filter as $key => $val) {
|
||||
if ($val !== '' && $val !== null) {
|
||||
$listQuery->where($key, '=', $val);
|
||||
}
|
||||
}
|
||||
$list = $listQuery->order($sort ?: 'createtime', $order ?: 'desc')
|
||||
->limit($offset, $limit)
|
||||
->select();
|
||||
}
|
||||
|
||||
foreach ($list as &$item) {
|
||||
$statusMap = [0 => '待审核', 1 => '已通过', 2 => '已拒绝', 3 => '已自动注销'];
|
||||
$item['status_text'] = $statusMap[$item['status']] ?? '未知';
|
||||
@@ -212,12 +202,17 @@ class Userdeletion extends Backend
|
||||
|
||||
Db::name('user')->where('id', $userId)->delete();
|
||||
|
||||
// 注销完成后,对username做SHA256哈希(PIPL最小化保留原则)
|
||||
// 保留6个月后由cleanupOldDeletionRecords接口自动清理
|
||||
$hashedUsername = hash('sha256', $record['username'] ?? '');
|
||||
|
||||
Db::name('user_deletion')->where('id', $record['id'])->update([
|
||||
'status' => $status,
|
||||
'admin_id' => $adminId,
|
||||
'admin_remark' => $remark,
|
||||
'updatetime' => time(),
|
||||
'deletetime' => time(),
|
||||
'username' => $hashedUsername,
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
|
||||
@@ -276,6 +276,11 @@ class Feed extends Api
|
||||
}
|
||||
$cached = Cache::get($cacheKey);
|
||||
if ($cached && !$hasSeenIds) {
|
||||
// v2.6: 缓存命中后重新附加当前用户的 is_liked/is_favorited 状态
|
||||
// 缓存中的 per-user 字段可能来自其他用户,不可信,必须按当前用户重查
|
||||
if (isset($cached['list']) && !empty($cached['list'])) {
|
||||
$this->_batchAttachInteractionCounts($cached['list'], $this->_getUserId());
|
||||
}
|
||||
$this->success('成功(cache)', $cached);
|
||||
return;
|
||||
}
|
||||
@@ -421,6 +426,11 @@ class Feed extends Api
|
||||
unset($item);
|
||||
}
|
||||
|
||||
// 批量附加互动计数(like_count/favorite_count/comment_count/share_count)
|
||||
// 修复: 原仅 /api/feed/detail 返回这些字段,列表接口缺失导致客户端按钮显示 0
|
||||
// v2.6: 同时回填当前用户的 is_liked/is_favorited 状态,解决已收藏句子重复出现时状态丢失
|
||||
$this->_batchAttachInteractionCounts($items, $this->_getUserId());
|
||||
|
||||
$result = [
|
||||
'list' => $items,
|
||||
'total' => $total,
|
||||
@@ -432,7 +442,18 @@ class Feed extends Api
|
||||
];
|
||||
|
||||
if (!$hasSeenIds) {
|
||||
Cache::set($cacheKey, $result, 60);
|
||||
// v2.6: 缓存前剥离 per-user 字段(is_liked/is_favorited)
|
||||
// 这些字段是用户私有的,缓存后其他用户命中会串状态
|
||||
// 缓存命中时由 _batchAttachInteractionCounts 重新按当前用户查询
|
||||
$cacheResult = $result;
|
||||
if (isset($cacheResult['list'])) {
|
||||
foreach ($cacheResult['list'] as &$cacheItem) {
|
||||
$cacheItem['is_liked'] = false;
|
||||
$cacheItem['is_favorited'] = false;
|
||||
}
|
||||
unset($cacheItem);
|
||||
}
|
||||
Cache::set($cacheKey, $cacheResult, 60);
|
||||
}
|
||||
|
||||
$this->success('成功', $result);
|
||||
@@ -2682,41 +2703,102 @@ class Feed extends Api
|
||||
/**
|
||||
* @name 清除Feed相关缓存
|
||||
* @desc 在互动操作写入后主动清除相关缓存,确保数据一致性
|
||||
* @lastUpdate v2.5 修复 _lite 后缀缓存未清理 + Cache::rm 不生效的 bug:
|
||||
* 1) 增加 _lite 后缀变体循环
|
||||
* 2) 在 Cache::rm 后调用 _unlinkCacheFile 兜底,直接删除缓存文件
|
||||
* 绕过 opcache 缓存旧代码 / ThinkPHP complex 驱动异常
|
||||
*/
|
||||
private function _clearFeedCache($feedType, $feedId)
|
||||
{
|
||||
try {
|
||||
Cache::rm('feed_stats');
|
||||
Cache::rm('feed_channels');
|
||||
Cache::rm('feed_weight_config');
|
||||
Cache::rm('feed_weight_config_api');
|
||||
Cache::rm("feed_count_{$feedType}");
|
||||
Cache::rm("feed_recommend_guest_20");
|
||||
// 单键缓存
|
||||
$singleKeys = [
|
||||
'feed_stats',
|
||||
'feed_channels',
|
||||
'feed_weight_config',
|
||||
'feed_weight_config_api',
|
||||
"feed_count_{$feedType}",
|
||||
'feed_recommend_guest_20',
|
||||
];
|
||||
foreach ($singleKeys as $sk) {
|
||||
Cache::rm($sk);
|
||||
$this->_unlinkCacheFile($sk);
|
||||
}
|
||||
|
||||
// 列表缓存:覆盖 newest/hottest × 5 页 × 4 种 limit × 普通与 _lite × 平台后缀
|
||||
$sorts = ['newest', 'hottest'];
|
||||
$limits = [10, 20, 30, 50];
|
||||
// 修复 v2.5: 必须覆盖客户端所有可能的 limit 值(含 5),否则 limit=5 的缓存不会被清理
|
||||
$limits = [5, 10, 15, 20, 25, 30, 40, 50];
|
||||
$liteVariants = ['', '_lite']; // lite 模式变体
|
||||
for ($p = 1; $p <= 5; $p++) {
|
||||
foreach ($sorts as $sort) {
|
||||
foreach ($limits as $lim) {
|
||||
Cache::rm("feed_list_{$feedType}_{$sort}_{$p}_{$lim}");
|
||||
Cache::rm("feed_list_all_{$sort}_{$p}_{$lim}");
|
||||
// 带平台后缀
|
||||
foreach (self::$platforms as $pKey => $pName) {
|
||||
Cache::rm("feed_list_{$feedType}_{$sort}_{$p}_{$lim}_plat_{$pKey}");
|
||||
Cache::rm("feed_list_all_{$sort}_{$p}_{$lim}_plat_{$pKey}");
|
||||
foreach ($liteVariants as $liteSuffix) {
|
||||
$k1 = "feed_list_{$feedType}_{$sort}_{$p}_{$lim}{$liteSuffix}";
|
||||
$k2 = "feed_list_all_{$sort}_{$p}_{$lim}{$liteSuffix}";
|
||||
Cache::rm($k1);
|
||||
Cache::rm($k2);
|
||||
$this->_unlinkCacheFile($k1);
|
||||
$this->_unlinkCacheFile($k2);
|
||||
// 带平台后缀
|
||||
foreach (self::$platforms as $pKey => $pName) {
|
||||
$pk1 = "feed_list_{$feedType}_{$sort}_{$p}_{$lim}{$liteSuffix}_plat_{$pKey}";
|
||||
$pk2 = "feed_list_all_{$sort}_{$p}_{$lim}{$liteSuffix}_plat_{$pKey}";
|
||||
Cache::rm($pk1);
|
||||
Cache::rm($pk2);
|
||||
$this->_unlinkCacheFile($pk1);
|
||||
$this->_unlinkCacheFile($pk2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Cache::rm("feed_trending_{$feedType}_20");
|
||||
Cache::rm("feed_trending_all_20");
|
||||
|
||||
// trending 缓存
|
||||
$trendingKeys = [
|
||||
"feed_trending_{$feedType}_20",
|
||||
"feed_trending_all_20",
|
||||
];
|
||||
foreach ($trendingKeys as $tk) {
|
||||
Cache::rm($tk);
|
||||
$this->_unlinkCacheFile($tk);
|
||||
}
|
||||
foreach (self::$platforms as $pKey => $pName) {
|
||||
Cache::rm("feed_trending_{$feedType}_20_{$pKey}");
|
||||
Cache::rm("feed_trending_all_20_{$pKey}");
|
||||
Cache::rm("feed_recommend_guest_20_plat_{$pKey}");
|
||||
$platKeys = [
|
||||
"feed_trending_{$feedType}_20_{$pKey}",
|
||||
"feed_trending_all_20_{$pKey}",
|
||||
"feed_recommend_guest_20_plat_{$pKey}",
|
||||
];
|
||||
foreach ($platKeys as $pk) {
|
||||
Cache::rm($pk);
|
||||
$this->_unlinkCacheFile($pk);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* @name 兜底删除缓存文件
|
||||
* @desc Cache::rm 在某些环境(opcache 缓存旧代码 / complex 驱动异常)下不删除文件,
|
||||
* 本方法直接按 ThinkPHP 5 File 驱动的路径规则计算缓存文件路径并 unlink
|
||||
* @param string $cacheKey 缓存 key(与 Cache::rm 入参一致)
|
||||
* @return bool 文件已删除返回 true,文件不存在或删除失败返回 false
|
||||
*/
|
||||
private function _unlinkCacheFile($cacheKey)
|
||||
{
|
||||
if (!defined('CACHE_PATH') || empty($cacheKey)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
$hash = md5($cacheKey);
|
||||
$file = CACHE_PATH . substr($hash, 0, 2) . '/' . substr($hash, 2) . '.php';
|
||||
if (is_file($file) && @unlink($file)) {
|
||||
return true;
|
||||
}
|
||||
} catch (\Exception $e) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name 清除权重配置缓存
|
||||
* @desc 在install/sync操作后清除权重相关缓存
|
||||
@@ -2730,7 +2812,8 @@ class Feed extends Api
|
||||
Cache::rm('feed_stats');
|
||||
Cache::rm('feed_channels');
|
||||
$sorts = ['newest', 'hottest'];
|
||||
$limits = [10, 20, 30, 50];
|
||||
// 修复 v2.5: 必须覆盖客户端所有可能的 limit 值(含 5),否则 limit=5 的缓存不会被清理
|
||||
$limits = [5, 10, 15, 20, 25, 30, 40, 50];
|
||||
for ($p = 1; $p <= 5; $p++) {
|
||||
foreach ($sorts as $sort) {
|
||||
foreach ($limits as $lim) {
|
||||
@@ -2946,9 +3029,136 @@ class Feed extends Api
|
||||
}
|
||||
}
|
||||
|
||||
// 批量附加互动计数(like_count/favorite_count/comment_count/share_count)
|
||||
// 修复: favorites/likes/history/readlater 列表也需返回真实计数
|
||||
// v2.6: 同时回填当前用户的 is_liked/is_favorited 状态
|
||||
$this->_batchAttachInteractionCounts($items, $this->_getUserId());
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name 批量附加互动计数
|
||||
* @desc 一次性查询 feed_interaction 表,按 (feed_type, feed_id, action) 分组 COUNT,
|
||||
* 回填到 items 的 like_count/favorite_count/comment_count/share_count 字段。
|
||||
* 避免 N+1 查询,列表接口(list/favorites/likes/history/readlater)统一调用。
|
||||
* @param array &$items 待附加计数的 items 数组(引用传递)
|
||||
* @lastUpdate v2.4 新增; 修复客户端点赞按钮显示 0 的问题
|
||||
*/
|
||||
private function _batchAttachInteractionCounts(&$items, $userId = 0)
|
||||
{
|
||||
if (empty($items) || !is_array($items)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 收集所有 (feed_type, feed_id) 对
|
||||
$pairs = [];
|
||||
foreach ($items as $item) {
|
||||
$feedType = $item['feed_type'] ?? '';
|
||||
$feedId = intval($item['id'] ?? 0);
|
||||
if (empty($feedType) || $feedId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$pairs[$feedType][] = $feedId;
|
||||
}
|
||||
if (empty($pairs)) {
|
||||
// 无有效对,仍填充 0 防止客户端解析 null
|
||||
foreach ($items as &$item) {
|
||||
if (!isset($item['like_count'])) $item['like_count'] = 0;
|
||||
if (!isset($item['favorite_count'])) $item['favorite_count'] = 0;
|
||||
if (!isset($item['comment_count'])) $item['comment_count'] = 0;
|
||||
if (!isset($item['share_count'])) $item['share_count'] = 0;
|
||||
// v2.6: 回填当前用户互动状态(未登录默认 false)
|
||||
if (!isset($item['is_liked'])) $item['is_liked'] = false;
|
||||
if (!isset($item['is_favorited'])) $item['is_favorited'] = false;
|
||||
}
|
||||
unset($item);
|
||||
return;
|
||||
}
|
||||
|
||||
// 构造 OR 查询条件: ((feed_type='poetry' AND feed_id IN (...)) OR (feed_type='wisdom' AND feed_id IN (...)) ...)
|
||||
// 仅查询 like/favorite/comment/share 四种 action
|
||||
$countMap = []; // key: "feed_type_feed_id" => ["like"=>n, "favorite"=>n, ...]
|
||||
try {
|
||||
foreach ($pairs as $feedType => $ids) {
|
||||
$ids = array_unique($ids);
|
||||
$rows = Db::name('feed_interaction')
|
||||
->field('feed_id, action, COUNT(*) AS cnt')
|
||||
->where('feed_type', $feedType)
|
||||
->where('feed_id', 'in', $ids)
|
||||
->where('action', 'in', ['like', 'favorite', 'comment', 'share'])
|
||||
->group('feed_id, action')
|
||||
->select();
|
||||
foreach ($rows as $row) {
|
||||
$fid = intval($row['feed_id']);
|
||||
$action = $row['action'];
|
||||
$cnt = intval($row['cnt']);
|
||||
$key = $feedType . '_' . $fid;
|
||||
if (!isset($countMap[$key])) {
|
||||
$countMap[$key] = [
|
||||
'like' => 0,
|
||||
'favorite' => 0,
|
||||
'comment' => 0,
|
||||
'share' => 0,
|
||||
];
|
||||
}
|
||||
$countMap[$key][$action] = $cnt;
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 查询失败时降级为 0
|
||||
}
|
||||
|
||||
// v2.6 新增:批量查询当前用户的互动状态,回填 is_liked/is_favorited
|
||||
// 解决:list 接口不返回用户个人状态,导致已收藏句子重复出现时显示"未收藏"
|
||||
$userActionMap = []; // key: "feed_type_feed_id" => ["like"=>bool, "favorite"=>bool]
|
||||
if (!empty($userId)) {
|
||||
try {
|
||||
foreach ($pairs as $feedType => $ids) {
|
||||
$ids = array_unique($ids);
|
||||
$userRows = Db::name('feed_interaction')
|
||||
->field('feed_id, action')
|
||||
->where('user_id', $userId)
|
||||
->where('feed_type', $feedType)
|
||||
->where('feed_id', 'in', $ids)
|
||||
->where('action', 'in', ['like', 'favorite'])
|
||||
->select();
|
||||
foreach ($userRows as $row) {
|
||||
$fid = intval($row['feed_id']);
|
||||
$action = $row['action'];
|
||||
$key = $feedType . '_' . $fid;
|
||||
if (!isset($userActionMap[$key])) {
|
||||
$userActionMap[$key] = [
|
||||
'like' => false,
|
||||
'favorite' => false,
|
||||
];
|
||||
}
|
||||
$userActionMap[$key][$action] = true;
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 查询失败时降级为 false
|
||||
}
|
||||
}
|
||||
|
||||
// 回填到 items
|
||||
foreach ($items as &$item) {
|
||||
$feedType = $item['feed_type'] ?? '';
|
||||
$feedId = intval($item['id'] ?? 0);
|
||||
$key = $feedType . '_' . $feedId;
|
||||
$counts = $countMap[$key] ?? null;
|
||||
$item['like_count'] = $counts ? $counts['like'] : 0;
|
||||
$item['favorite_count'] = $counts ? $counts['favorite'] : 0;
|
||||
$item['comment_count'] = $counts ? $counts['comment'] : 0;
|
||||
$item['share_count'] = $counts ? $counts['share'] : 0;
|
||||
// v2.6: 回填当前用户互动状态
|
||||
$userActions = $userActionMap[$key] ?? null;
|
||||
$item['is_liked'] = $userActions ? $userActions['like'] : false;
|
||||
$item['is_favorited'] = $userActions ? $userActions['favorite'] : false;
|
||||
}
|
||||
unset($item);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name 获取权重配置
|
||||
* @desc 从数据库读取管理员设置的推荐权重配置,带缓存
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -70,6 +70,21 @@ Route::rule([
|
||||
'api/user_security/requestDeletion' => 'api/UserSecurity/requestDeletion',
|
||||
'api/user_security/deletionStatus' => 'api/UserSecurity/deletionStatus',
|
||||
'api/user_security/cancelDeletion' => 'api/UserSecurity/cancelDeletion',
|
||||
'api/user_security/accountLookup' => 'api/UserSecurity/accountLookup',
|
||||
'api/user_security/cleanupOldDeletionRecords' => 'api/UserSecurity/cleanupOldDeletionRecords',
|
||||
'api/user_security/getConsents' => 'api/UserSecurity/getConsents',
|
||||
'api/user_security/updateConsents' => 'api/UserSecurity/updateConsents',
|
||||
'api/user_security/listDevices' => 'api/UserSecurity/listDevices',
|
||||
'api/user_security/revokeDevice' => 'api/UserSecurity/revokeDevice',
|
||||
'api/user_security/deletionProgress'=> 'api/UserSecurity/deletionProgress',
|
||||
'api/user_security/parentalControlStatus' => 'api/UserSecurity/parentalControlStatus',
|
||||
'api/user_security/updateBirthday' => 'api/UserSecurity/updateBirthday',
|
||||
// v10.3.2 新增
|
||||
'api/user_security/checkSmtpStatus' => 'api/UserSecurity/checkSmtpStatus',
|
||||
'api/user_security/secQuestionLogin' => 'api/UserSecurity/secQuestionLogin',
|
||||
'api/user_security/emailCodeLogin' => 'api/UserSecurity/emailCodeLogin',
|
||||
'api/user_security/sendDeletionCode' => 'api/UserSecurity/sendDeletionCode',
|
||||
'api/user_security/requestDeletionByReceipt'=> 'api/UserSecurity/requestDeletionByReceipt',
|
||||
]);
|
||||
|
||||
// OAuth社交登录
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> @File: API_FEED_DOC.md
|
||||
> @Time: 2026-05-13
|
||||
> @Description: 信息流Feed系统API文档,聚合44种内容表为统一信息流
|
||||
> @LastUpdate: v2.3 新增刷新获取新内容接口(/api/feed/refresh_content); 新增用户偏好设置接口(/api/feed/preferences)
|
||||
> @LastUpdate: v2.6 list 接口返回 is_liked/is_favorited 当前用户互动状态;缓存命中后按当前用户重查 per-user 字段,缓存写入前剥离 per-user 字段避免跨用户串状态
|
||||
|
||||
---
|
||||
|
||||
@@ -158,6 +158,12 @@ curl -s "https://tools.wktyl.com/api/feed/channels" | python -m json.tool
|
||||
| sort | string | 当前排序 |
|
||||
| lite | bool | 是否轻量模式 |
|
||||
|
||||
> **v2.4 更新**: list 接口的每个 item 现在都包含 `like_count`、`favorite_count`、`comment_count`、`share_count` 字段(批量查询 feed_interaction 表回填),无需再单独调用 detail 接口获取计数。
|
||||
|
||||
> **v2.5 更新**: 修复 lite 模式(limit=5)点赞/取消点赞后 like_count 不更新的问题。根因是 `_clearFeedCache` 的 `$limits` 数组不含 5,导致 `feed_list_*_newest_1_5_lite` 缓存从未被清理。修复后 `$limits = [5, 10, 15, 20, 25, 30, 40, 50]`,并在 `Cache::rm` 后追加 `_unlinkCacheFile` 兜底直接删除缓存文件。互动操作后 lite 模式 list 接口立即返回最新计数。
|
||||
|
||||
> **v2.6 更新**: list 接口的每个 item 现在包含 `is_liked`、`is_favorited` 字段,表示**当前登录用户**是否已点赞/已收藏。未登录时始终返回 `false`。`_batchAttachInteractionCounts` 新增 `$userId` 参数,批量查询 `feed_interaction` 表中当前用户的 like/favorite 记录。**缓存隔离**:缓存命中后按当前用户重新查询 per-user 字段(缓存中的 is_liked/is_favorited 不可信),缓存写入前剥离为 false 避免跨用户串状态。客户端额外合并本地 DB 状态(OR 策略:服务端 true 或本地 true 均为 true),覆盖未登录场景和离线操作。
|
||||
|
||||
### 3.5 curl测试
|
||||
|
||||
```bash
|
||||
|
||||
@@ -261,9 +261,9 @@
|
||||
<div class="content-card">
|
||||
<div id="content-zh" class="lang-content">
|
||||
<p><strong>闲言APP</strong> 账号使用协议</p>
|
||||
<p>版本号:V6.5</p>
|
||||
<p>更新日期:2026年5月20日</p>
|
||||
<p>生效日期:2026年5月21日</p>
|
||||
<p>版本号:V6.6</p>
|
||||
<p>更新日期:2026年6月17日</p>
|
||||
<p>生效日期:2026年6月17日</p>
|
||||
<p>本协议是您与<strong>弥勒市朋普镇微风暴网络科技工作室</strong>关于<strong>闲言APP</strong>账号注册、使用、安全及注销等事项的约定。请您仔细阅读并充分理解本协议。</p>
|
||||
<h2>一、账号注册</h2>
|
||||
<h3>1.1 注册资格</h3>
|
||||
@@ -317,6 +317,20 @@
|
||||
<li>涉嫌违法违规活动</li>
|
||||
<li>长期未登录(超过<span class="highlight">2年</span>)</li>
|
||||
</ul>
|
||||
<h3>3.4 长期未登录账号回收政策</h3>
|
||||
<p>为合理利用服务器资源并保护账号安全,我们制定了以下长期未登录账号回收规则:</p>
|
||||
<ul>
|
||||
<li><span class="highlight">连续12个月未登录</span>:账号将进入"休眠状态",我们将通过App内消息、邮件或短信方式向您发送提醒通知</li>
|
||||
<li><span class="highlight">连续18个月未登录</span>:账号下的虚拟财产(积分、金币等)将停止产生,但不被清零;账号仍可正常登录恢复使用</li>
|
||||
<li><span class="highlight">连续24个月(2年)未登录</span>:我们将对账号进行<span class="highlight">回收处理</span>,具体处理方式如下:</li>
|
||||
<li>— 账号将被永久冻结,无法登录</li>
|
||||
<li>— 个人资料、收藏、笔记、签到记录等数据将被<span class="highlight">永久删除且不可恢复</span></li>
|
||||
<li>— 账号关联的邮箱、手机号将被解绑,可重新注册新账号</li>
|
||||
<li>— 在回收处理前30天,我们将通过注册邮箱/手机号发送最终通知</li>
|
||||
<li>回收处理后,您将无法找回该账号及其数据</li>
|
||||
<li>为避免账号被回收,请至少每<span class="highlight">12个月</span>登录一次</li>
|
||||
</ul>
|
||||
<p class="note">💡 提示:账号回收处理与主动注销的后果一致,均会导致数据永久删除。建议您定期登录以保留账号和数据。</p>
|
||||
<h2>四、账号注销</h2>
|
||||
<h3>4.1 注销条件</h3>
|
||||
<p>您有权随时申请注销账号,注销前请确保:</p>
|
||||
@@ -351,6 +365,30 @@
|
||||
<li>会员权益、积分、金币等虚拟财产将清零且<span class="highlight">不可恢复</span></li>
|
||||
<li>注销后<span class="highlight">无法恢复</span>,请谨慎操作</li>
|
||||
</ul>
|
||||
<h3>4.5 服务器数据删除时限</h3>
|
||||
<p>关于注销后服务器数据的删除时间,我们承诺以下规则:</p>
|
||||
<ul>
|
||||
<li><span class="highlight">管理员审核通过后立即删除</span>:管理员通过注销申请后,服务器将在<span class="highlight">5分钟内</span>完成以下数据的硬删除:</li>
|
||||
<li>— 用户主表(tool_user)记录</li>
|
||||
<li>— 用户Token、登录设备、在线状态等会话数据</li>
|
||||
<li>— 用户收藏、笔记、点赞等互动数据</li>
|
||||
<li>— 用户签到记录、积分流水、金币流水</li>
|
||||
<li>— 用户文章、评论及关联内容(作者字段匿名化)</li>
|
||||
<li><span class="highlight">审核期超时自动注销</span>:提交申请后<span class="highlight">3天</span>内若管理员未审核,系统将自动执行注销,删除范围同上</li>
|
||||
<li><span class="highlight">注销记录保留</span>:为满足法律法规和审计要求,注销操作记录将保留<span class="highlight">6个月</span>,且用户名在注销完成后立即进行SHA256哈希处理,仅保留不可逆的哈希值用于审计</li>
|
||||
<li>数据库备份遵循<span class="highlight">30天滚动覆盖</span>策略,即最新备份仅保留30天,超期自动清除</li>
|
||||
</ul>
|
||||
<p class="note">💡 重要说明:注销完成后,您将无法通过任何方式(App内、网页、客服查询)查找到该账号的任何信息,状态查询将返回"已注销"或"无记录"。</p>
|
||||
<h3>4.6 应用外账户管理支持(Google Play合规)</h3>
|
||||
<p>为满足Google Play数据安全合规要求,我们提供独立的<span class="highlight">应用外账户管理网页</span>,无需安装App即可进行账号管理操作:</p>
|
||||
<ul>
|
||||
<li><span class="highlight">账号状态查询</span>:访问 <a href="https://tools.wktyl.com/agreements/privacy-rights.html" style="color:var(--primary);">隐私权管理页面</a>,输入账号即可查询状态(正常/封锁/注销中/已注销/无记录)</li>
|
||||
<li><span class="highlight">应用外注销</span>:在隐私权管理页面登录后即可发起注销申请,流程与App内一致</li>
|
||||
<li><span class="highlight">注销状态查询</span>:随时查询注销申请的处理进度</li>
|
||||
<li><span class="highlight">撤销注销</span>:审核期内可随时撤销注销申请</li>
|
||||
<li>该页面同时提供其他软件协议的查看入口,方便您了解权利与义务</li>
|
||||
</ul>
|
||||
<p class="note">🔐 安全提示:应用外账户管理页面采用HMAC-SHA256回执验证机制,防止账号枚举攻击。查询账号状态需提供有效的回执签名。</p>
|
||||
<h2>五、账号冻结与解冻</h2>
|
||||
<h3>5.1 冻结情形</h3>
|
||||
<p>以下情形我们有权冻结您的账号:</p>
|
||||
@@ -387,38 +425,109 @@
|
||||
</div>
|
||||
<div id="content-en" class="lang-content" style="display:none;">
|
||||
<p><strong>Xianyan APP</strong> Account Usage Agreement</p>
|
||||
<p>Version: V6.5</p>
|
||||
<p>Updated: May 20, 2026</p>
|
||||
<p>Effective: May 21, 2026</p>
|
||||
<p>Version: V6.6</p>
|
||||
<p>Updated: June 17, 2026</p>
|
||||
<p>Effective: June 17, 2026</p>
|
||||
<p>This agreement is between you and <strong>Mile City Pengpu Town Weifengbao Network Technology Studio</strong> regarding the registration, use, security, and deletion of your Xianyan APP account. Please read and fully understand this agreement.</p>
|
||||
<h2>I. Account Registration</h2>
|
||||
<h3>1.1 You must be at least 14 years old to register an account</h3>
|
||||
<h3>1.2 You need to provide a valid phone number or email for registration</h3>
|
||||
<h3>1.3 The information you provide must be true, accurate, and complete</h3>
|
||||
<h3>1.4 Each person may only register one account</h3>
|
||||
<h3>1.1 Registration Eligibility</h3>
|
||||
<ul>
|
||||
<li>You must meet the minimum age requirement of your region (14 in China, 16 in EU, 13 in US)</li>
|
||||
<li>Minors under 14 are not allowed to register</li>
|
||||
<li>Each user may only register one account; accounts cannot be bought, transferred, or lent</li>
|
||||
</ul>
|
||||
<h3>1.2 Registration Methods</h3>
|
||||
<ul>
|
||||
<li>Phone number registration via SMS verification</li>
|
||||
<li>Email registration via verification code</li>
|
||||
<li>Third-party login via authorized accounts</li>
|
||||
</ul>
|
||||
<h2>II. Account Security</h2>
|
||||
<h3>2.1 You are responsible for keeping your account password secure</h3>
|
||||
<h3>2.2 Do not use easily guessable passwords</h3>
|
||||
<h3>2.3 If you discover your account has been used without authorization, please contact us immediately</h3>
|
||||
<h3>2.4 We are not responsible for losses caused by your failure to keep your account secure</h3>
|
||||
<h3>2.1 Password Security</h3>
|
||||
<ul>
|
||||
<li>Set a strong password and change it regularly</li>
|
||||
<li>Never share your password with others</li>
|
||||
<li>Notify us immediately if you suspect unauthorized access</li>
|
||||
</ul>
|
||||
<h2>III. Account Usage</h2>
|
||||
<h3>3.1 You may not lend, transfer, or sell your account</h3>
|
||||
<h3>3.2 You may not use your account for illegal activities</h3>
|
||||
<h3>3.3 You may not use technical means to obtain other users' account information</h3>
|
||||
<h3>3.1 Account Restrictions</h3>
|
||||
<p>We may restrict your account in the following cases:</p>
|
||||
<ul>
|
||||
<li>Security risk detected</li>
|
||||
<li>Violation of User Service Agreement</li>
|
||||
<li>Suspected illegal activities</li>
|
||||
<li>Inactive for over 2 years</li>
|
||||
</ul>
|
||||
<h3>3.2 Long-term Inactive Account Recycling Policy</h3>
|
||||
<p>To optimize server resources and protect account security, we apply the following recycling rules:</p>
|
||||
<ul>
|
||||
<li><span class="highlight">12 months inactive</span>: Account enters "dormant state"; we will notify you via app, email, or SMS</li>
|
||||
<li><span class="highlight">18 months inactive</span>: Virtual assets stop accumulating but are not cleared; account can still be reactivated</li>
|
||||
<li><span class="highlight">24 months (2 years) inactive</span>: Account will be <span class="highlight">recycled</span>:</li>
|
||||
<li>— Account permanently frozen, login disabled</li>
|
||||
<li>— Personal data, favorites, notes, check-ins <span class="highlight">permanently deleted</span></li>
|
||||
<li>— Linked email/phone unbound, available for new registrations</li>
|
||||
<li>— Final notification sent 30 days before recycling</li>
|
||||
<li>To avoid recycling, please log in at least once every <span class="highlight">12 months</span></li>
|
||||
</ul>
|
||||
<h2>IV. Account Deletion</h2>
|
||||
<h3>4.1 You can apply for account deletion at: My → Settings → Account Settings → Delete Account</h3>
|
||||
<h3>4.2 Deletion process:</h3>
|
||||
<h3>4.1 Deletion Conditions</h3>
|
||||
<p>You may apply for account deletion at any time. Before deletion, please ensure:</p>
|
||||
<ul>
|
||||
<li>Submit a deletion request and undergo security verification</li>
|
||||
<li>3-day review period (countdown displayed)</li>
|
||||
<li>During the review period, you can cancel the deletion request</li>
|
||||
<li>After the review is complete, the account and related data will be permanently deleted</li>
|
||||
<li>No pending disputes or complaints</li>
|
||||
<li>No incomplete transactions or services</li>
|
||||
<li>You have backed up data you wish to keep</li>
|
||||
</ul>
|
||||
<h3>4.3 After account deletion:</h3>
|
||||
<h3>4.2 Deletion Process</h3>
|
||||
<ul>
|
||||
<li>All personal information will be deleted or anonymized</li>
|
||||
<li>Published content will be handled according to your choice</li>
|
||||
<li>Account cannot be recovered after deletion</li>
|
||||
<li>Apply via "Settings → Account Settings → Delete Account"</li>
|
||||
<li>Step 1: Read deletion warning and confirm understanding</li>
|
||||
<li>Step 2: Security verification - type "DELETE" to confirm; reason is optional</li>
|
||||
<li>Step 3: Enter a <span class="highlight">3-day review period</span></li>
|
||||
<li>Step 4: After admin approval or review timeout, account is <span class="highlight">permanently deleted</span></li>
|
||||
<li>You may cancel the deletion request anytime during the review period</li>
|
||||
</ul>
|
||||
<h3>4.3 Deletion Status Query</h3>
|
||||
<ul>
|
||||
<li>View current status (Pending/Approved/Rejected/Cancelled) on the deletion page</li>
|
||||
<li>Application time, reason, and estimated auto-deletion time are displayed</li>
|
||||
<li>Countdown timer is shown during the review period</li>
|
||||
</ul>
|
||||
<h3>4.4 Deletion Consequences</h3>
|
||||
<ul>
|
||||
<li>Account cannot be logged in or used after deletion</li>
|
||||
<li>The following data will be <span class="highlight">permanently deleted and unrecoverable</span>:</li>
|
||||
<li>All favorites and notes</li>
|
||||
<li>Check-in records and points</li>
|
||||
<li>Personal profile and settings</li>
|
||||
<li>Articles and interaction data</li>
|
||||
<li>Membership benefits, points, and coins cleared</li>
|
||||
<li>Deletion is <span class="highlight">irreversible</span> - proceed with caution</li>
|
||||
</ul>
|
||||
<h3>4.5 Server Data Deletion Timeline</h3>
|
||||
<p>We commit to the following data deletion rules:</p>
|
||||
<ul>
|
||||
<li><span class="highlight">Immediate deletion upon admin approval</span>: Within <span class="highlight">5 minutes</span> of approval, the following data is hard-deleted:</li>
|
||||
<li>— User main table (tool_user) record</li>
|
||||
<li>— User tokens, login devices, online status</li>
|
||||
<li>— User favorites, notes, likes</li>
|
||||
<li>— Check-in records, points/coins transactions</li>
|
||||
<li>— User articles, comments (author field anonymized)</li>
|
||||
<li><span class="highlight">Auto-deletion on timeout</span>: If admin does not review within <span class="highlight">3 days</span>, the system auto-executes deletion</li>
|
||||
<li><span class="highlight">Deletion record retention</span>: Deletion operation records are retained for <span class="highlight">6 months</span>, with username immediately SHA256-hashed upon deletion completion. Only irreversible hash values are retained for audit purposes</li>
|
||||
<li>Database backups follow a <span class="highlight">30-day rolling overwrite</span> policy</li>
|
||||
</ul>
|
||||
<p class="note">💡 Important: After deletion is complete, the account cannot be queried via any channel (app, web, customer service). Status query will return "Deleted" or "No Record".</p>
|
||||
<h3>4.6 Out-of-App Account Management (Google Play Compliance)</h3>
|
||||
<p>To comply with Google Play Data Safety requirements, we provide an independent <span class="highlight">out-of-app account management webpage</span> for managing accounts without installing the app:</p>
|
||||
<ul>
|
||||
<li><span class="highlight">Account Status Query</span>: Visit <a href="https://tools.wktyl.com/agreements/privacy-rights.html" style="color:var(--primary);">Privacy Rights Management Page</a> to query status (Normal/Blocked/Deleting/Deleted/No Record)</li>
|
||||
<li><span class="highlight">Out-of-app deletion</span>: Log in and submit deletion requests via the privacy rights page</li>
|
||||
<li><span class="highlight">Deletion status query</span>: Track deletion progress anytime</li>
|
||||
<li><span class="highlight">Cancel deletion</span>: Cancel deletion requests during the review period</li>
|
||||
<li>The page also provides access to other software agreements</li>
|
||||
</ul>
|
||||
<p class="note">🔐 Security: The out-of-app management page uses HMAC-SHA256 receipt verification to prevent account enumeration attacks.</p>
|
||||
<h2>V. Account Freeze</h2>
|
||||
<h3>5.1 We have the right to freeze accounts in the following circumstances:</h3>
|
||||
<ul>
|
||||
|
||||
@@ -194,6 +194,14 @@
|
||||
</div>
|
||||
<span class="item-arrow">›</span>
|
||||
</a>
|
||||
<a href="privacy-rights.html" class="agreement-item">
|
||||
<span class="item-icon">🛡️</span>
|
||||
<div class="item-text">
|
||||
<div class="item-title">隐私权管理</div>
|
||||
<div class="item-subtitle">账号状态查询、注销申请、隐私权利选择(Google Play合规)</div>
|
||||
</div>
|
||||
<span class="item-arrow">›</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="section-title">📋 服务条款</div>
|
||||
<div class="agreement-list">
|
||||
@@ -293,6 +301,14 @@
|
||||
</div>
|
||||
<span class="item-arrow">›</span>
|
||||
</a>
|
||||
<a href="privacy-rights.html?lang=en" class="agreement-item">
|
||||
<span class="item-icon">🛡️</span>
|
||||
<div class="item-text">
|
||||
<div class="item-title">Privacy Rights Management</div>
|
||||
<div class="item-subtitle">Account status query, deletion request, privacy choices (Google Play compliance)</div>
|
||||
</div>
|
||||
<span class="item-arrow">›</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="section-title">📋 Service Terms</div>
|
||||
<div class="agreement-list">
|
||||
|
||||
2687
docs/toolsapi/public/agreements/privacy-rights.html
Normal file
2687
docs/toolsapi/public/agreements/privacy-rights.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -52,7 +52,7 @@ PODS:
|
||||
- Flutter
|
||||
- flutter_webrtc (1.4.0):
|
||||
- Flutter
|
||||
- WebRTC-SDK (= 144.7559.01)
|
||||
- WebRTC-SDK (= 144.7559.09)
|
||||
- fluttertoast (0.0.2):
|
||||
- Flutter
|
||||
- gal (1.0.0):
|
||||
@@ -97,7 +97,6 @@ PODS:
|
||||
- Flutter
|
||||
- pro_image_editor (12.0.8):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- quick_actions_ios (0.0.1):
|
||||
- Flutter
|
||||
- quill_native_bridge_ios (0.0.1):
|
||||
@@ -137,7 +136,7 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- wakelock_plus (0.0.1):
|
||||
- Flutter
|
||||
- WebRTC-SDK (144.7559.01)
|
||||
- WebRTC-SDK (144.7559.09)
|
||||
- wifi_iot (0.0.1):
|
||||
- Flutter
|
||||
- workmanager_apple (0.0.1):
|
||||
@@ -175,7 +174,7 @@ DEPENDENCIES:
|
||||
- network_info_plus (from `.symlinks/plugins/network_info_plus/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- pro_image_editor (from `.symlinks/plugins/pro_image_editor/darwin`)
|
||||
- pro_image_editor (from `.symlinks/plugins/pro_image_editor/ios`)
|
||||
- quick_actions_ios (from `.symlinks/plugins/quick_actions_ios/ios`)
|
||||
- quill_native_bridge_ios (from `.symlinks/plugins/quill_native_bridge_ios/ios`)
|
||||
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
|
||||
@@ -268,7 +267,7 @@ EXTERNAL SOURCES:
|
||||
permission_handler_apple:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
pro_image_editor:
|
||||
:path: ".symlinks/plugins/pro_image_editor/darwin"
|
||||
:path: ".symlinks/plugins/pro_image_editor/ios"
|
||||
quick_actions_ios:
|
||||
:path: ".symlinks/plugins/quick_actions_ios/ios"
|
||||
quill_native_bridge_ios:
|
||||
@@ -324,7 +323,7 @@ SPEC CHECKSUMS:
|
||||
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
|
||||
flutter_tts: 35ac3c7d42412733e795ea96ad2d7e05d0a75113
|
||||
flutter_vibrate: 207bbbeb62dd5638b479846c8e46168d7229f14a
|
||||
flutter_webrtc: ec91d94b484ad49cf191ef93413f64a40ffd3b4c
|
||||
flutter_webrtc: 3ee86aa10e0313c38c93fdb215dd86e77014f96a
|
||||
fluttertoast: fe6790210fdba20801685be946e3a2124b72eef5
|
||||
gal: baecd024ebfd13c441269ca7404792a7152fde89
|
||||
home_widget: 54b4f6b36ed8d64cfee594a476225c35c3e45091
|
||||
@@ -339,7 +338,7 @@ SPEC CHECKSUMS:
|
||||
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e
|
||||
pro_image_editor: e57a76b305fd8c2e06d57f92aec82b9c6a6e8d9f
|
||||
pro_image_editor: 3dedac450f82a389877286fa9eb08852cefb04ea
|
||||
quick_actions_ios: 500fcc11711d9f646739093395c4ae8eec25f779
|
||||
quill_native_bridge_ios: f47af4b14e7757968486641656c5d23250cee521
|
||||
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
|
||||
@@ -356,7 +355,7 @@ SPEC CHECKSUMS:
|
||||
video_compress: f2133a07762889d67f0711ac831faa26f956980e
|
||||
video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52
|
||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||
WebRTC-SDK: ab9b5319e458c2bfebdc92b3600740da35d5630d
|
||||
WebRTC-SDK: e6006119cd730d6315d875e4a421b6cc8bb88833
|
||||
wifi_iot: f645260a2be8608517b2a9bf4c39b98e97003acc
|
||||
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
|
||||
|
||||
|
||||
@@ -1499,6 +1499,40 @@ class AppDatabase extends _$AppDatabase {
|
||||
}, 'setFavoriteFlag');
|
||||
}
|
||||
|
||||
/// 设置指定记录的点赞状态(不依赖当前值,直接设定)
|
||||
///
|
||||
/// 举一反三:与 setFavoriteFlag 同类需求。
|
||||
/// 原 toggleLike 在记录不存在时静默失败,导致本地点赞状态无法持久化。
|
||||
Future<void> setLikeFlag(String id, bool value) async {
|
||||
await _safeDbVoid(() async {
|
||||
await (update(sentences)..where((t) => t.id.equals(id))).write(
|
||||
SentencesCompanion(
|
||||
isLiked: Value(value),
|
||||
updatedAt: Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
}, 'setLikeFlag');
|
||||
}
|
||||
|
||||
/// 设置指定记录的点赞数(likes 字段)
|
||||
///
|
||||
/// 修复 v6.142.0: 点赞按钮显示 0 问题。
|
||||
/// 客户端 toggleLike 乐观更新 likeCount 后,同步写入本地 DB 的 likes 字段,
|
||||
/// 保证 fromDb 读取时也能拿到正确的 likeCount。
|
||||
///
|
||||
/// 注: 由于 .g.dart 未重新生成(build_runner 阻塞), SentencesCompanion 缺少 likes 字段,
|
||||
/// 改用 customStatement 直接执行 SQL。
|
||||
Future<void> setLikesCount(String id, int count) async {
|
||||
await _safeDbVoid(() async {
|
||||
final safeCount = count < 0 ? 0 : count;
|
||||
final ts = DateTime.now().millisecondsSinceEpoch;
|
||||
await customStatement(
|
||||
'UPDATE sentences SET likes = ?, updated_at = ? WHERE id = ?',
|
||||
[safeCount, ts, id],
|
||||
);
|
||||
}, 'setLikesCount');
|
||||
}
|
||||
|
||||
/// 设置收藏状态(按targetType+targetId匹配,兼容多种ID格式)
|
||||
///
|
||||
/// sentences表中ID可能是以下格式:
|
||||
@@ -1907,6 +1941,19 @@ class AppDatabase extends _$AppDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
/// 批量查询句子记录(仅命中本地已缓存的 id)
|
||||
///
|
||||
/// 用途:句子广场刷新时合并本地 DB 的 isFavorite/isLiked 状态,
|
||||
/// 解决"已收藏句子重复出现但未显示收藏状态"的问题。
|
||||
/// 返回 Map: id -> Sentence,未命中的 id 不会出现在 Map 中。
|
||||
Future<Map<String, Sentence>> getSentencesByIds(List<String> ids) {
|
||||
if (ids.isEmpty) return Future.value(<String, Sentence>{});
|
||||
return _safeDbList(
|
||||
() => (select(sentences)..where((t) => t.id.isIn(ids))).get(),
|
||||
'getSentencesByIds',
|
||||
).then((rows) => {for (final r in rows) r.id: r});
|
||||
}
|
||||
|
||||
Future<void> insertOrUpdateSentence(SentencesCompanion entry) {
|
||||
return _safeDbVoid(
|
||||
() => into(sentences).insert(entry, mode: InsertMode.insertOrReplace),
|
||||
|
||||
@@ -88,11 +88,13 @@ class _BounceButtonState extends State<BounceButton>
|
||||
/// 可按压卡片
|
||||
///
|
||||
/// 按压时缩小 + 降低亮度,松开弹回,增强触觉反馈。
|
||||
/// 2026-06-27: 新增 onLongPress 长按回调支持。
|
||||
class PressableCard extends StatefulWidget {
|
||||
const PressableCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.scaleDown = 0.97,
|
||||
this.borderRadius,
|
||||
this.padding,
|
||||
@@ -102,6 +104,9 @@ class PressableCard extends StatefulWidget {
|
||||
|
||||
final Widget child;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// 长按回调(触发后自动回弹,不进入按压态)
|
||||
final VoidCallback? onLongPress;
|
||||
final double scaleDown;
|
||||
final BorderRadius? borderRadius;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
@@ -140,6 +145,12 @@ class _PressableCardState extends State<PressableCard>
|
||||
void _onTapUp(TapUpDetails _) => _controller.reverse();
|
||||
void _onTapCancel() => _controller.reverse();
|
||||
|
||||
void _onLongPress() {
|
||||
// 长按触发时回弹,避免卡片停留在按压态
|
||||
_controller.reverse();
|
||||
widget.onLongPress?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ext = AppTheme.ext(context);
|
||||
@@ -148,6 +159,7 @@ class _PressableCardState extends State<PressableCard>
|
||||
onTapUp: _onTapUp,
|
||||
onTapCancel: _onTapCancel,
|
||||
onTap: widget.onTap,
|
||||
onLongPress: widget.onLongPress == null ? null : _onLongPress,
|
||||
child: ScaleTransition(
|
||||
scale: _scaleAnimation,
|
||||
child: Container(
|
||||
|
||||
@@ -154,6 +154,15 @@ class FavoriteRepository {
|
||||
}
|
||||
|
||||
/// Feed API 加载收藏
|
||||
///
|
||||
/// 修复:原降级条件 `result.list.isEmpty && result.total == 0 && page == 1`
|
||||
/// 在服务端 bug / 缓存延迟 / 新用户无收藏等场景下会误降级到 Legacy API,
|
||||
/// 而 Legacy API 又硬编码 targetType='article'(已在 _loadLegacyFavorites 修复),
|
||||
/// 双重故障导致句子收藏完全查不到。
|
||||
///
|
||||
/// 现在:Feed API 返回空时,**先检查本地数据库是否有收藏**,
|
||||
/// - 本地有:直接走本地兜底(loadLocalDbFavorites),不降级 Legacy
|
||||
/// - 本地无:再降级 Legacy API(已修复 targetType)
|
||||
Future<FavoriteLoadResult> _loadFeedFavorites({
|
||||
int page = 1,
|
||||
bool refresh = false,
|
||||
@@ -163,9 +172,22 @@ class FavoriteRepository {
|
||||
try {
|
||||
final result = await FeedService.fetchFavorites(page: page);
|
||||
|
||||
// 空结果降级旧接口
|
||||
// 空结果处理:先本地兜底,再考虑降级 Legacy
|
||||
if (result.list.isEmpty && result.total == 0 && page == 1) {
|
||||
Log.w('Feed收藏API返回空结果(total=0),降级旧接口');
|
||||
// 先看本地数据库是否有收藏记录(如刚收藏但服务端尚未同步/缓存延迟)
|
||||
final localDbItems = await loadLocalDbFavorites();
|
||||
if (localDbItems.isNotEmpty) {
|
||||
Log.i('Feed收藏API空结果,本地数据库兜底: ${localDbItems.length}条');
|
||||
return FavoriteLoadResult(
|
||||
items: localDbItems,
|
||||
feedItems: const [],
|
||||
total: localDbItems.length,
|
||||
nextPage: 2,
|
||||
useFeedApi: true,
|
||||
);
|
||||
}
|
||||
// 本地也无,降级 Legacy API(已修复 targetType 硬编码问题)
|
||||
Log.w('Feed收藏API空结果且本地无记录,降级旧接口');
|
||||
return _loadLegacyFavorites(
|
||||
page: page,
|
||||
refresh: refresh,
|
||||
@@ -196,6 +218,10 @@ class FavoriteRepository {
|
||||
}
|
||||
|
||||
/// Legacy API 加载收藏
|
||||
///
|
||||
/// 修复:原实现硬编码 `targetType: 'article'`,但句子广场的 feedType 是
|
||||
/// 'hitokoto'/'chengyu'/'hanzi'/'poetry' 等,永远查不到。
|
||||
/// 现在:不传 targetType,让服务端返回所有类型收藏(与 Feed API 行为一致)。
|
||||
Future<FavoriteLoadResult> _loadLegacyFavorites({
|
||||
int page = 1,
|
||||
bool refresh = false,
|
||||
@@ -204,7 +230,8 @@ class FavoriteRepository {
|
||||
try {
|
||||
final result = await UserCenterService.favorite(
|
||||
action: FavoriteAction.list,
|
||||
targetType: 'article',
|
||||
// 故意不传 targetType:让服务端返回所有类型收藏,
|
||||
// 避免硬编码 'article' 导致句子类收藏被漏掉
|
||||
page: page,
|
||||
);
|
||||
final total = SafeJson.parseInt(result['total']);
|
||||
@@ -385,7 +412,16 @@ class FavoriteRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// 合并服务端和本地数据库收藏,去重(以 content+author 为联合key)
|
||||
/// 合并服务端和本地数据库收藏,去重
|
||||
///
|
||||
/// 修复:原实现仅用 `content+author` 作为去重 key,当 content 为空时
|
||||
/// 退化为 `|title`,多条空 content 条目互相误去重;且未利用 id 信息,
|
||||
/// 服务端和本地同一句子(id 相同但 content 微小差异)会被当作两条。
|
||||
///
|
||||
/// 现在:双重去重策略
|
||||
/// - 主 key:`targetType|targetId`(精确,服务端与本地通过 _resolveServerId 可对齐)
|
||||
/// - 副 key:`content|title`(兜底,处理 id 缺失或异常的情况)
|
||||
/// 任一 key 命中即视为已存在。
|
||||
///
|
||||
/// 本地条目追加到尾部(而非插入头部),避免取消收藏后本地缓存条目
|
||||
/// 仍然显示在列表顶部导致"取消收藏后数据仍在"的问题。
|
||||
@@ -393,15 +429,32 @@ class FavoriteRepository {
|
||||
List<FavoriteItem> serverItems,
|
||||
List<FavoriteItem> localItems,
|
||||
) {
|
||||
// 使用 content + author 联合去重,避免不同作者的同内容句子被误去重
|
||||
final mergeKey = (FavoriteItem item) =>
|
||||
'${item.content.trim().toLowerCase()}|${item.title.trim().toLowerCase()}';
|
||||
final existingKeys = serverItems.map(mergeKey).toSet();
|
||||
String idKey(FavoriteItem item) =>
|
||||
'${item.targetType}|${item.targetId}';
|
||||
String contentKey(FavoriteItem item) {
|
||||
final c = item.content.trim().toLowerCase();
|
||||
final t = item.title.trim().toLowerCase();
|
||||
// 空 content 不参与去重,避免误合并不同条目
|
||||
if (c.isEmpty) return '';
|
||||
return '$c|$t';
|
||||
}
|
||||
|
||||
final existingIdKeys = serverItems.map(idKey).toSet();
|
||||
final existingContentKeys = serverItems
|
||||
.map(contentKey)
|
||||
.where((k) => k.isNotEmpty)
|
||||
.toSet();
|
||||
|
||||
final merged = List<FavoriteItem>.from(serverItems);
|
||||
for (final local in localItems) {
|
||||
if (!existingKeys.contains(mergeKey(local))) {
|
||||
merged.add(local);
|
||||
}
|
||||
final idK = idKey(local);
|
||||
final cK = contentKey(local);
|
||||
final dupById = existingIdKeys.contains(idK);
|
||||
final dupByContent = cK.isNotEmpty && existingContentKeys.contains(cK);
|
||||
if (dupById || dupByContent) continue;
|
||||
merged.add(local);
|
||||
existingIdKeys.add(idK);
|
||||
if (cK.isNotEmpty) existingContentKeys.add(cK);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
// ============================================================
|
||||
// 闲言APP — 首页句子卡片
|
||||
// 创建时间: 2026-04-27
|
||||
// 更新时间: 2026-06-12
|
||||
// 更新时间: 2026-06-27
|
||||
// 作用: 句子广场列表中的句子卡片,Feed数据适配 + 互动操作
|
||||
// 上次更新: AppIcon添加语义标签(点赞/收藏/评论/浏览量),增强无障碍
|
||||
// 上次更新: v6.144.0 — 未激活态按钮边框加粗 (0.5→1.2px) + 背景加深,
|
||||
// 让用户清晰辨识点赞/收藏按钮的可点击区域
|
||||
// ============================================================
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/material.dart' show Colors;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import 'package:xianyan/core/theme/app_theme.dart';
|
||||
import 'package:xianyan/l10n/translations.dart';
|
||||
import 'package:xianyan/core/theme/app_spacing.dart';
|
||||
import 'package:xianyan/core/theme/app_typography.dart';
|
||||
import 'package:xianyan/core/theme/app_radius.dart';
|
||||
import 'package:xianyan/core/utils/logger.dart';
|
||||
import 'package:xianyan/core/theme/glass_tokens.dart';
|
||||
import 'package:xianyan/core/utils/platform/platform_utils.dart'
|
||||
show isOhos, OhosDeviceCapabilities;
|
||||
import 'package:xianyan/core/utils/performance_optimizer.dart';
|
||||
import 'package:xianyan/core/utils/ui/interaction_animations.dart';
|
||||
import 'package:xianyan/core/utils/data/number_formatter.dart';
|
||||
import 'package:xianyan/core/services/device/haptic_service.dart';
|
||||
import 'package:xianyan/core/services/audio/sfx_service.dart';
|
||||
import 'package:xianyan/features/home/feed_model.dart';
|
||||
import 'package:xianyan/features/home/providers/home_provider.dart';
|
||||
import 'package:xianyan/features/home/presentation/providers/sentence_detail_sheet.dart';
|
||||
@@ -32,7 +34,6 @@ import 'package:xianyan/features/settings/providers/plugin_provider.dart';
|
||||
import 'package:xianyan/features/settings/presentation/plugin_widgets/translate_sheet.dart';
|
||||
import 'package:xianyan/features/settings/presentation/plugin_widgets/tts_player_sheet.dart';
|
||||
import 'package:xianyan/shared/widgets/plugin/pinyin_annotation_text.dart';
|
||||
import 'package:xianyan/shared/widgets/feedback/app_toast.dart';
|
||||
import 'package:xianyan/shared/widgets/display/app_icon.dart';
|
||||
|
||||
/// 句子卡片 — 列表项
|
||||
@@ -70,8 +71,6 @@ class _SentenceCardState extends ConsumerState<SentenceCard> {
|
||||
AppThemeExtension get ext => widget.ext;
|
||||
|
||||
bool _showPinyin = false;
|
||||
bool _isSharing = false;
|
||||
final GlobalKey _repaintKey = GlobalKey();
|
||||
|
||||
/// 根据 feedType 生成主题色
|
||||
Color _generateAccentColor() {
|
||||
@@ -94,12 +93,17 @@ class _SentenceCardState extends ConsumerState<SentenceCard> {
|
||||
widget.onTap?.call();
|
||||
_showDetail(context);
|
||||
},
|
||||
// 长按卡片:重冲击触觉 + 触发详情面板(与点击效果一致,但提供差异化触觉反馈)
|
||||
onLongPress: () {
|
||||
HapticService.heavy();
|
||||
widget.onTap?.call();
|
||||
_showDetail(context);
|
||||
},
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.sm,
|
||||
vertical: AppSpacing.xs,
|
||||
),
|
||||
child: RepaintBoundary(
|
||||
key: _repaintKey,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: _buildGradient(shaderEnabled, accentColor),
|
||||
@@ -387,7 +391,13 @@ class _SentenceCardState extends ConsumerState<SentenceCard> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 操作行 (作者 + 收藏 + 点赞)
|
||||
/// 操作行 (作者 + 工具图标 + 液态玻璃点赞/收藏按钮)
|
||||
///
|
||||
/// 重设计 2026-06-27:
|
||||
/// - 移除分享 icon 按钮(分享改为左滑手势 + 详情面板入口)
|
||||
/// - 拼音/翻译/TTS 保留为小图标,置于按钮左侧
|
||||
/// - 点赞/收藏改为液态玻璃长方形 icon+文字 按钮,显示激活态
|
||||
/// - 支持动态主题(明/暗/AMOLED + 主色切换 + 圆角风格 + 字号缩放)
|
||||
Widget _buildActionRow() {
|
||||
final translateEnabled = ref.watch(
|
||||
pluginProvider.select((s) => s.translateEnabled),
|
||||
@@ -396,77 +406,89 @@ class _SentenceCardState extends ConsumerState<SentenceCard> {
|
||||
final pinyinEnabled = ref.watch(
|
||||
pluginProvider.select((s) => s.pinyinEnabled),
|
||||
);
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
// 左侧:作者名(占满剩余空间)
|
||||
if (sentence.author != null)
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${ref.watch(translationsProvider).home.base.authorPrefix}${sentence.author!}',
|
||||
'${t.home.base.authorPrefix}${sentence.author!}',
|
||||
style: AppTypography.caption1.copyWith(color: ext.textSecondary),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
)
|
||||
else
|
||||
const Spacer(),
|
||||
|
||||
// 中间:工具图标组(拼音/翻译/TTS)保留为小图标
|
||||
if (pinyinEnabled)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: AppSpacing.sm),
|
||||
child: GestureDetector(
|
||||
onTap: _handlePinyinToggle,
|
||||
child: Icon(
|
||||
_showPinyin
|
||||
? CupertinoIcons.textformat_alt
|
||||
: CupertinoIcons.textformat,
|
||||
size: 18,
|
||||
color: _showPinyin ? const Color(0xFF34C759) : ext.textHint,
|
||||
),
|
||||
),
|
||||
_ToolIconButton(
|
||||
icon: _showPinyin
|
||||
? CupertinoIcons.textformat_alt
|
||||
: CupertinoIcons.textformat,
|
||||
color: _showPinyin ? const Color(0xFF34C759) : ext.textHint,
|
||||
semanticLabel: '拼音',
|
||||
onTap: _handlePinyinToggle,
|
||||
),
|
||||
if (translateEnabled)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: AppSpacing.sm),
|
||||
child: GestureDetector(
|
||||
onTap: () => _handleTranslate(),
|
||||
child: Icon(CupertinoIcons.globe, size: 18, color: ext.accent),
|
||||
),
|
||||
_ToolIconButton(
|
||||
icon: CupertinoIcons.globe,
|
||||
color: ext.accent,
|
||||
semanticLabel: '翻译',
|
||||
onTap: _handleTranslate,
|
||||
),
|
||||
if (ttsEnabled)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: AppSpacing.sm),
|
||||
child: GestureDetector(
|
||||
onTap: () => _handleTts(),
|
||||
child: const Icon(
|
||||
CupertinoIcons.speaker_2_fill,
|
||||
size: 18,
|
||||
color: Color(0xFFAF52DE),
|
||||
),
|
||||
),
|
||||
_ToolIconButton(
|
||||
icon: CupertinoIcons.speaker_2_fill,
|
||||
color: const Color(0xFFAF52DE),
|
||||
semanticLabel: '朗读',
|
||||
onTap: _handleTts,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: AppSpacing.sm),
|
||||
child: GestureDetector(
|
||||
onTap: _isSharing ? null : _shareAsImage,
|
||||
child: Icon(
|
||||
CupertinoIcons.share,
|
||||
size: 18,
|
||||
color: _isSharing ? ext.textHint : ext.accent,
|
||||
),
|
||||
),
|
||||
),
|
||||
FavoriteBounceAnimation(
|
||||
isFavorited: sentence.isFavorited,
|
||||
onToggle: () {
|
||||
if (mounted) widget.onFavorite?.call();
|
||||
},
|
||||
size: 18,
|
||||
),
|
||||
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
LikeAnimation(
|
||||
isLiked: sentence.isLiked,
|
||||
onToggle: (_) {
|
||||
|
||||
// 右侧:液态玻璃点赞按钮(icon + 数字)
|
||||
LiquidGlassActionButton(
|
||||
icon: sentence.isLiked
|
||||
? CupertinoIcons.heart_fill
|
||||
: CupertinoIcons.heart,
|
||||
label: NumberFormatter.formatCount(sentence.likeCount),
|
||||
isActive: sentence.isLiked,
|
||||
activeColor: const Color(0xFFFF3B30), // iOS systemRed
|
||||
activeColorLight: const Color(0xFFFF6B6B),
|
||||
ext: ext,
|
||||
hapticType: HapticType.like,
|
||||
semanticLabel: sentence.isLiked
|
||||
? '已点赞 ${sentence.likeCount}'
|
||||
: '点赞 ${sentence.likeCount}',
|
||||
onTap: () {
|
||||
if (mounted) widget.onLike();
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
|
||||
// 右侧:液态玻璃收藏按钮(icon + 文字"收藏"/"已藏")
|
||||
LiquidGlassActionButton(
|
||||
icon: sentence.isFavorited
|
||||
? CupertinoIcons.star_fill
|
||||
: CupertinoIcons.star,
|
||||
label: sentence.isFavorited
|
||||
? t.home.sentenceDetail.favorited
|
||||
: t.home.sentenceDetail.favorite,
|
||||
isActive: sentence.isFavorited,
|
||||
activeColor: const Color(0xFFFF9F0A), // iOS systemOrange
|
||||
activeColorLight: const Color(0xFFFFB84D),
|
||||
ext: ext,
|
||||
hapticType: HapticType.favorite,
|
||||
semanticLabel: sentence.isFavorited ? '已收藏' : '收藏',
|
||||
onTap: () {
|
||||
if (mounted) widget.onFavorite?.call();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -504,81 +526,316 @@ class _SentenceCardState extends ConsumerState<SentenceCard> {
|
||||
ref.read(pluginProvider.notifier).incrementPinyinCount();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 截图分享 — RepaintBoundary截取卡片 → PNG → share_plus系统分享
|
||||
Future<void> _shareAsImage() async {
|
||||
if (_isSharing) return;
|
||||
if (kIsWeb) {
|
||||
_shareAsText();
|
||||
return;
|
||||
}
|
||||
setState(() => _isSharing = true);
|
||||
// ============================================================
|
||||
// 工具图标按钮 — 拼音/翻译/TTS 共用的小尺寸图标按钮
|
||||
// ============================================================
|
||||
|
||||
try {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
/// 工具图标按钮(无背景,仅图标 + 间距)
|
||||
class _ToolIconButton extends StatelessWidget {
|
||||
const _ToolIconButton({
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.semanticLabel,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final boundary =
|
||||
_repaintKey.currentContext?.findRenderObject()
|
||||
as RenderRepaintBoundary?;
|
||||
if (boundary == null || !boundary.hasSize) {
|
||||
_shareAsText();
|
||||
return;
|
||||
}
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final String semanticLabel;
|
||||
final VoidCallback onTap;
|
||||
|
||||
final image = await boundary.toImage(pixelRatio: 3.0);
|
||||
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
if (byteData == null) {
|
||||
_shareAsText();
|
||||
return;
|
||||
}
|
||||
|
||||
final buffer = byteData.buffer;
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
final filePath = '${tempDir.path}/sentence_$timestamp.png';
|
||||
final file = File(filePath);
|
||||
await file.writeAsBytes(
|
||||
buffer.asUint8List(byteData.offsetInBytes, byteData.lengthInBytes),
|
||||
);
|
||||
|
||||
final authorPart = sentence.author != null
|
||||
? ' —— ${sentence.author}'
|
||||
: '';
|
||||
final t = ref.read(translationsProvider);
|
||||
final shareText =
|
||||
'📝 ${sentence.text}$authorPart\n\n${t.home.base.shareAppSignature}';
|
||||
|
||||
if (mounted) {
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(files: [XFile(filePath)], text: shareText),
|
||||
);
|
||||
}
|
||||
Log.i('分享句子卡片图片: ${sentence.id}');
|
||||
} catch (e) {
|
||||
Log.e('句子卡片截图分享失败', e);
|
||||
_shareAsText();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isSharing = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本分享降级方案
|
||||
void _shareAsText() {
|
||||
final t = ref.read(translationsProvider);
|
||||
final authorPart = sentence.author != null
|
||||
? ' ${t.home.base.authorPrefix}${sentence.author}'
|
||||
: '';
|
||||
final shareText =
|
||||
'📝 ${sentence.text}$authorPart\n\n${t.home.base.shareAppSignature}';
|
||||
|
||||
SharePlus.instance.share(ShareParams(text: shareText)).catchError((
|
||||
Object e,
|
||||
) {
|
||||
Log.e('文本分享失败', e);
|
||||
AppToast.showError(t.home.base.shareFailed);
|
||||
return const ShareResult('', ShareResultStatus.unavailable);
|
||||
});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: AppSpacing.sm),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Semantics(
|
||||
label: semanticLabel,
|
||||
button: true,
|
||||
child: Icon(icon, size: 18, color: color),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 液态玻璃操作按钮 (iOS 26 Liquid Glass 风格)
|
||||
// ============================================================
|
||||
|
||||
/// 按钮触觉类型 — 决定激活/取消时的触觉强度与音效
|
||||
enum HapticType {
|
||||
/// 点赞按钮:激活中等冲击 + like_pop 音效;取消轻冲击 + unlike_soft
|
||||
like,
|
||||
|
||||
/// 收藏按钮:激活中等冲击 + favorite_star 音效;取消轻冲击 + unfavorite
|
||||
favorite,
|
||||
}
|
||||
|
||||
/// 液态玻璃操作按钮
|
||||
///
|
||||
/// 长方形 icon + 文字 按钮,支持激活态切换:
|
||||
/// - 未激活:真正 BackdropFilter 毛玻璃 + 微高光 + 中性色 icon/文字
|
||||
/// - 已激活:渐变填充 + 内嵌高光 + 投射阴影 + 白色 icon/文字
|
||||
///
|
||||
/// 2026-06-27 升级:
|
||||
/// - 真正使用 BackdropFilter(参考 GlassContainer 实践,含性能降级)
|
||||
/// - 单一 AnimatedBuilder 统一驱动弹跳动画(移除 AnimatedScale + ScaleTransition 双重叠加)
|
||||
/// - 接入 HapticService + SfxService 差异化触觉与音效
|
||||
///
|
||||
/// 设计参考: docs/prototypes/sentence_card_redesign.html 方案C
|
||||
/// 动态主题: 自动跟随 AppThemeExtension 切换明/暗/AMOLED/主色
|
||||
class LiquidGlassActionButton extends StatefulWidget {
|
||||
const LiquidGlassActionButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.isActive,
|
||||
required this.activeColor,
|
||||
required this.activeColorLight,
|
||||
required this.ext,
|
||||
required this.onTap,
|
||||
this.hapticType,
|
||||
this.semanticLabel,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool isActive;
|
||||
|
||||
/// 激活态主色(如点赞红、收藏金)
|
||||
final Color activeColor;
|
||||
final Color activeColorLight;
|
||||
|
||||
/// 主题扩展,用于读取动态主题色
|
||||
final AppThemeExtension ext;
|
||||
|
||||
/// 点击回调
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// 触觉与音效类型(null 表示不触发触觉/音效)
|
||||
final HapticType? hapticType;
|
||||
|
||||
/// 无障碍标签
|
||||
final String? semanticLabel;
|
||||
|
||||
@override
|
||||
State<LiquidGlassActionButton> createState() =>
|
||||
_LiquidGlassActionButtonState();
|
||||
}
|
||||
|
||||
class _LiquidGlassActionButtonState extends State<LiquidGlassActionButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _popController;
|
||||
late final Animation<double> _popAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_popController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 400),
|
||||
);
|
||||
_popAnimation = Tween<double>(begin: 1.0, end: 1.35).animate(
|
||||
CurvedAnimation(
|
||||
parent: _popController,
|
||||
curve: Curves.easeInOutBack,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(LiquidGlassActionButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// 从未激活变为激活时,触发弹跳动画 + 触觉 + 音效
|
||||
if (!oldWidget.isActive && widget.isActive) {
|
||||
_popController.forward(from: 0.0);
|
||||
_fireActivateFeedback();
|
||||
} else if (oldWidget.isActive && !widget.isActive) {
|
||||
// 取消激活:轻冲击 + 取消音效
|
||||
_fireDeactivateFeedback();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_popController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 触发激活态反馈:中等冲击 + 激活音效
|
||||
void _fireActivateFeedback() {
|
||||
final type = widget.hapticType;
|
||||
if (type == null) return;
|
||||
HapticService.medium();
|
||||
switch (type) {
|
||||
case HapticType.like:
|
||||
SfxService.instance.play(SfxType.like);
|
||||
case HapticType.favorite:
|
||||
SfxService.instance.play(SfxType.favorite);
|
||||
}
|
||||
}
|
||||
|
||||
/// 触发取消态反馈:轻冲击 + 取消音效
|
||||
void _fireDeactivateFeedback() {
|
||||
final type = widget.hapticType;
|
||||
if (type == null) return;
|
||||
HapticService.light();
|
||||
switch (type) {
|
||||
case HapticType.like:
|
||||
SfxService.instance.play(SfxType.unlike);
|
||||
case HapticType.favorite:
|
||||
SfxService.instance.play(SfxType.unfavorite);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTap() {
|
||||
// 点击瞬间先触发反向缩放反馈(动画由 didUpdateWidget 在状态变化时驱动)
|
||||
_popController.forward(from: 0.0);
|
||||
widget.onTap();
|
||||
}
|
||||
|
||||
/// 计算有效的 BackdropFilter sigma 值
|
||||
///
|
||||
/// 参考 GlassContainer 实践:
|
||||
/// - 基于 GlassTokens.baseBlur(按钮较小,用 base 层 10.0)
|
||||
/// - 乘以 ext.glassBlurMultiplier(用户可调节的模糊强度倍数)
|
||||
/// - 通过 PerformanceOptimizer 在低端设备/省电模式降级
|
||||
/// - 鸿蒙端通过 OhosDeviceCapabilities.supportsBackdropFilter 判断是否支持
|
||||
double? _effectiveBlurSigma() {
|
||||
// 鸿蒙端不支持 BackdropFilter 时降级
|
||||
if (isOhos && !OhosDeviceCapabilities.supportsBackdropFilter) return null;
|
||||
if (!PerformanceOptimizer.instance.shouldEnableBackdropFilter) return null;
|
||||
final rawSigma = GlassTokens.baseBlur * widget.ext.glassBlurMultiplier;
|
||||
final sigma =
|
||||
PerformanceOptimizer.instance.suggestedBlurSigma(rawSigma);
|
||||
return sigma > 0 ? sigma : null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ext = widget.ext;
|
||||
final isDark = ext.isDark;
|
||||
|
||||
// 未激活态背景色:浅色/深色自适应半透明
|
||||
// v6.144.0: 加深背景与边框,让用户在未点赞/未收藏时能清晰看到按钮轮廓和点击位置
|
||||
final inactiveBg = isDark
|
||||
? const Color(0x26FFFFFF) // 15% white (原 8%)
|
||||
: const Color(0x1FFFFFFF).withValues(alpha: 0.28); // 28% white (原 18%)
|
||||
final inactiveBorder = isDark
|
||||
? const Color(0x4DFFFFFF) // 30% white (原 12%)
|
||||
: const Color(0x80FFFFFF); // 50% white (原 25%)
|
||||
final inactiveFg = ext.textSecondary;
|
||||
|
||||
// 激活态:渐变填充
|
||||
final activeBg = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
widget.activeColor.withValues(alpha: 0.92),
|
||||
widget.activeColorLight.withValues(alpha: 0.88),
|
||||
],
|
||||
);
|
||||
const activeBorder = Color(0x66FFFFFF); // 40% white
|
||||
const activeFg = Colors.white;
|
||||
|
||||
final bgColor = widget.isActive ? null : inactiveBg;
|
||||
final gradient = widget.isActive ? activeBg : null;
|
||||
final borderColor = widget.isActive ? activeBorder : inactiveBorder;
|
||||
final fgColor = widget.isActive ? activeFg : inactiveFg;
|
||||
|
||||
// 阴影:激活态有彩色投射
|
||||
final shadowColor = widget.isActive
|
||||
? widget.activeColor.withValues(alpha: 0.55)
|
||||
: Colors.transparent;
|
||||
final activeShadow = widget.isActive
|
||||
? [
|
||||
BoxShadow(
|
||||
color: shadowColor,
|
||||
offset: const Offset(0, 4),
|
||||
blurRadius: 14,
|
||||
spreadRadius: -2,
|
||||
),
|
||||
// 内嵌高光(顶部白线)
|
||||
const BoxShadow(
|
||||
color: Color(0x4DFFFFFF),
|
||||
offset: Offset(0, 1),
|
||||
),
|
||||
]
|
||||
: null;
|
||||
|
||||
// 按钮内容
|
||||
final buttonContent = Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
gradient: gradient,
|
||||
borderRadius: BorderRadius.circular(999), // pill
|
||||
// v6.144.0: 未激活态边框加粗 (0.5→1.2),让用户清晰看到按钮可点击区域
|
||||
border: Border.all(
|
||||
color: borderColor,
|
||||
width: widget.isActive ? 0.5 : 1.2,
|
||||
),
|
||||
boxShadow: activeShadow,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(widget.icon, size: 14, color: fgColor),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
widget.label,
|
||||
style: AppTypography.caption2.copyWith(
|
||||
color: fgColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// 真正的液态玻璃:BackdropFilter 包裹(仅未激活态使用,激活态用渐变填充无需模糊)
|
||||
// 用 RepaintBoundary 隔离重绘,避免列表滚动时重复模糊计算
|
||||
final blurSigma = widget.isActive ? null : _effectiveBlurSigma();
|
||||
final glassChild = blurSigma != null
|
||||
? RepaintBoundary(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: BackdropFilter(
|
||||
filter: ui.ImageFilter.blur(
|
||||
sigmaX: blurSigma,
|
||||
sigmaY: blurSigma,
|
||||
),
|
||||
child: buttonContent,
|
||||
),
|
||||
),
|
||||
)
|
||||
: buttonContent;
|
||||
|
||||
return Semantics(
|
||||
label: widget.semanticLabel,
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _handleTap,
|
||||
// 统一动画驱动:单一 AnimatedBuilder 同时缩放整个按钮(含 icon + 文字)
|
||||
// 移除原 AnimatedScale + ScaleTransition 双重叠加
|
||||
child: AnimatedBuilder(
|
||||
animation: _popAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _popAnimation.value,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: glassChild,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 首页Feed数据拉取Mixin
|
||||
/// 创建时间: 2026-05-12
|
||||
/// 更新时间: 2026-06-23
|
||||
/// 更新时间: 2026-06-27
|
||||
/// 作用: 频道/每日推荐/列表/降级/缓存的拉取逻辑
|
||||
/// 上次更新: 任务6修复 — 去重耗尽时重置seenIds实现循环加载,新增resetAndReload方法
|
||||
/// 上次更新: v6.144.0 — 新增 _mergeLocalInteractionState 合并本地DB互动状态,
|
||||
/// 解决已收藏/已点赞句子重复出现未显示状态;循环加载时保留已收藏句子去重
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:convert';
|
||||
@@ -74,6 +75,50 @@ mixin HomeFeedMixin on Notifier<HomeState> {
|
||||
return all.sublist(all.length - _maxSeenHashesForApi);
|
||||
}
|
||||
|
||||
/// 合并本地 DB 的互动状态(isFavorite/isLiked)
|
||||
///
|
||||
/// v6.144.0: 解决"已收藏/已点赞句子重复出现但未显示状态"的问题。
|
||||
/// 合并策略:local true wins(OR 合并)
|
||||
/// - 服务端 true → true(已登录用户的服务端权威状态)
|
||||
/// - 服务端 false 但本地 true → true(未登录或未同步的本地操作)
|
||||
/// - 两者都 false → false
|
||||
///
|
||||
/// 未登录场景:服务端始终返回 false,本地 DB 是唯一状态来源。
|
||||
/// 登录场景:服务端返回权威状态,本地 DB 兜底未同步的离线操作。
|
||||
Future<List<HomeSentence>> _mergeLocalInteractionState(
|
||||
List<HomeSentence> sentences,
|
||||
) async {
|
||||
if (sentences.isEmpty) return sentences;
|
||||
try {
|
||||
final ids = sentences.map((s) => s.id).toList();
|
||||
final localMap = await feedDb.getSentencesByIds(ids);
|
||||
if (localMap.isEmpty) return sentences;
|
||||
bool changed = false;
|
||||
final merged = sentences.map((s) {
|
||||
final local = localMap[s.id];
|
||||
if (local == null) return s;
|
||||
final mergedLiked = s.isLiked || local.isLiked;
|
||||
final mergedFavorited = s.isFavorited || local.isFavorite;
|
||||
if (mergedLiked == s.isLiked && mergedFavorited == s.isFavorited) {
|
||||
return s;
|
||||
}
|
||||
changed = true;
|
||||
return s.copyWith(
|
||||
isLiked: mergedLiked,
|
||||
isFavorited: mergedFavorited,
|
||||
);
|
||||
}).toList();
|
||||
if (changed) {
|
||||
Log.i('_mergeLocalInteractionState: 合并 ${sentences.length} 条, '
|
||||
'命中本地 ${localMap.length} 条');
|
||||
}
|
||||
return merged;
|
||||
} catch (e) {
|
||||
Log.w('_mergeLocalInteractionState: 合并失败, 保留服务端状态: $e');
|
||||
return sentences;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchRefreshSentences() async {
|
||||
try {
|
||||
final seenIds = _buildSeenIdList();
|
||||
@@ -105,7 +150,7 @@ mixin HomeFeedMixin on Notifier<HomeState> {
|
||||
// 当选择了具体分类时,只保留该分类的数据
|
||||
final selectedType = state.selectedType;
|
||||
|
||||
final newSentences = result.list
|
||||
var newSentences = result.list
|
||||
.map(HomeSentence.fromFeedItem)
|
||||
.where((s) => s.text.isNotEmpty)
|
||||
.where((s) {
|
||||
@@ -119,6 +164,9 @@ mixin HomeFeedMixin on Notifier<HomeState> {
|
||||
})
|
||||
.toList();
|
||||
|
||||
// v6.144.0: 合并本地 DB 互动状态,确保已收藏/已点赞句子显示正确状态
|
||||
newSentences = await _mergeLocalInteractionState(newSentences);
|
||||
|
||||
final unique = newSentences
|
||||
.where((s) => !allSeenIds.contains(s.id))
|
||||
.toList();
|
||||
@@ -623,10 +671,14 @@ mixin HomeFeedMixin on Notifier<HomeState> {
|
||||
})
|
||||
.toList();
|
||||
|
||||
// v6.144.0: 合并本地 DB 互动状态,确保已收藏/已点赞句子显示正确状态
|
||||
// 注意:此处不使用 var 重赋值,直接生成 mergedSentences 供后续使用
|
||||
final mergedSentences = await _mergeLocalInteractionState(newSentences);
|
||||
|
||||
final selfDeduped = <HomeSentence>[];
|
||||
final selfSeen = <String>{};
|
||||
final selfSeenTexts = <String>{};
|
||||
for (final s in newSentences) {
|
||||
for (final s in mergedSentences) {
|
||||
if (!selfSeen.contains(s.id)) {
|
||||
selfSeen.add(s.id);
|
||||
if (deduplicateContent && s.text.isNotEmpty) {
|
||||
@@ -669,33 +721,57 @@ mixin HomeFeedMixin on Notifier<HomeState> {
|
||||
// 避免骨架屏卡死 — 重置后用本次返回的数据继续填充列表
|
||||
if (state.cycleRound >= 3) {
|
||||
Log.w('fetchNewSentences: 去重3轮后仍无新数据, 重置已见集合进入循环加载');
|
||||
// v6.144.0: 保留已收藏句子的 ID 和文本,避免循环加载时已收藏内容重复出现
|
||||
// 用户已收藏的句子在"我的收藏"页面可见,无需在广场再次展示
|
||||
final favoritedIds = state.sentences
|
||||
.where((s) => s.isFavorited)
|
||||
.map((s) => s.id)
|
||||
.toSet();
|
||||
final favoritedTexts = deduplicateContent
|
||||
? state.sentences
|
||||
.where((s) => s.isFavorited && s.text.isNotEmpty)
|
||||
.map((s) => s.text.trim())
|
||||
.toSet()
|
||||
: <String>{};
|
||||
// 重置已见集合,允许相同内容再次出现(循环加载)
|
||||
allSeenIds.clear();
|
||||
if (deduplicateContent) {
|
||||
allSeenTexts.clear();
|
||||
}
|
||||
// 保留最近一批id避免立即重复
|
||||
final recentIds = state.sentences.length > 20
|
||||
? state.sentences.sublist(state.sentences.length - 20).map((s) => s.id).toSet()
|
||||
: <String>{};
|
||||
final recentTexts = deduplicateContent && state.sentences.length > 20
|
||||
? state.sentences.sublist(state.sentences.length - 20)
|
||||
// 恢复已收藏句子的去重记录,确保循环加载时跳过已收藏内容
|
||||
allSeenIds.addAll(favoritedIds);
|
||||
if (deduplicateContent) {
|
||||
allSeenTexts.addAll(favoritedTexts);
|
||||
}
|
||||
// 保留最近一批id避免立即重复 + v6.144.0: 已收藏句子 ID 合并排除
|
||||
final recentIds = <String>{
|
||||
if (state.sentences.length > 20)
|
||||
...state.sentences
|
||||
.sublist(state.sentences.length - 20)
|
||||
.map((s) => s.id),
|
||||
...favoritedIds,
|
||||
};
|
||||
final recentTexts = <String>{
|
||||
if (deduplicateContent && state.sentences.length > 20)
|
||||
...state.sentences
|
||||
.sublist(state.sentences.length - 20)
|
||||
.where((s) => s.text.isNotEmpty)
|
||||
.map((s) => s.text.trim()).toSet()
|
||||
: <String>{};
|
||||
.map((s) => s.text.trim()),
|
||||
...favoritedTexts,
|
||||
};
|
||||
|
||||
// 重新处理本次返回的数据,仅排除最近20条
|
||||
final cycledSentences = newSentences.where((s) {
|
||||
// 重新处理本次返回的数据,仅排除最近20条 + 已收藏内容
|
||||
final cycledSentences = mergedSentences.where((s) {
|
||||
if (recentIds.contains(s.id)) return false;
|
||||
if (deduplicateContent && s.text.isNotEmpty &&
|
||||
recentTexts.contains(s.text.trim())) return false;
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
// 如果排除最近20条后仍为空,直接用全部数据
|
||||
// 如果排除后仍为空,直接用全部数据(兜底防止空白)
|
||||
final finalBatch = cycledSentences.isNotEmpty
|
||||
? cycledSentences
|
||||
: newSentences;
|
||||
: mergedSentences;
|
||||
|
||||
if (finalBatch.isNotEmpty) {
|
||||
await saveToDb(finalBatch);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
@@ -55,8 +56,15 @@ mixin HomeInteractionMixin on Notifier<HomeState> {
|
||||
final oldValue = sentence.isLiked;
|
||||
if (!mounted) return;
|
||||
|
||||
// Step 1: 乐观更新UI
|
||||
updateSentence(id, (s) => s.copyWith(isLiked: !s.isLiked));
|
||||
// Step 1: 乐观更新UI — 同步翻转 isLiked 和递增/递减 likeCount
|
||||
// 修复: 原仅翻转 isLiked,likeCount 不变,导致点赞后数字不增加
|
||||
final newLikeCount = oldValue
|
||||
? (sentence.likeCount > 0 ? sentence.likeCount - 1 : 0)
|
||||
: sentence.likeCount + 1;
|
||||
updateSentence(id, (s) => s.copyWith(
|
||||
isLiked: !s.isLiked,
|
||||
likeCount: newLikeCount,
|
||||
));
|
||||
SfxService.instance.play(oldValue ? SfxType.unlike : SfxType.like);
|
||||
ref
|
||||
.read(characterMoodProvider.notifier)
|
||||
@@ -77,10 +85,53 @@ mixin HomeInteractionMixin on Notifier<HomeState> {
|
||||
}
|
||||
|
||||
/// 本地点赞持久化:写入本地数据库
|
||||
///
|
||||
/// 举一反三:与 _persistFavoriteLocally 同类问题——
|
||||
/// 若 sentences 表中不存在该 id 的记录,原 toggleLike 的 UPDATE 会静默失败,
|
||||
/// 导致本地 isLiked 字段未设置,重启 APP 后点赞状态丢失。
|
||||
/// 修复:先查再决定 set 或 upsert 兜底插入。
|
||||
Future<void> _persistLikeLocally(String id, bool oldValue) async {
|
||||
try {
|
||||
await interactionDb.toggleLike(id);
|
||||
Log.i('本地点赞已${!oldValue ? "添加" : "取消"}: $id');
|
||||
final newValue = !oldValue;
|
||||
final existing = await interactionDb.getSentencesById(id);
|
||||
if (existing != null) {
|
||||
await interactionDb.setLikeFlag(id, newValue);
|
||||
// 同步更新本地 likes 字段,保证 fromDb 读取时 likeCount 正确
|
||||
final s = findSentence(id);
|
||||
if (s != null) {
|
||||
final newLikeCount = oldValue
|
||||
? (s.likeCount > 0 ? s.likeCount - 1 : 0)
|
||||
: s.likeCount + 1;
|
||||
await interactionDb.setLikesCount(id, newLikeCount);
|
||||
}
|
||||
} else {
|
||||
final s = findSentence(id);
|
||||
await interactionDb.insertOrUpdateSentence(
|
||||
SentencesCompanion(
|
||||
id: Value(id),
|
||||
content: Value(s?.text ?? ''),
|
||||
author: Value(s?.author ?? ''),
|
||||
source: Value(s?.source ?? ''),
|
||||
tags: Value(s?.type ?? ''),
|
||||
feedType: Value(s?.feedType ?? ''),
|
||||
feedName: Value(s?.feedName ?? ''),
|
||||
feedIcon: Value(s?.feedIcon ?? ''),
|
||||
views: Value(s?.views ?? 0),
|
||||
imageUrl: const Value(''),
|
||||
isFavorite: Value(s?.isFavorited ?? false),
|
||||
isLiked: Value(newValue),
|
||||
isRead: const Value(false),
|
||||
createdAt: Value(DateTime.now()),
|
||||
updatedAt: Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
// 兜底插入后单独写入 likes 字段(SentencesCompanion 缺少 likes 命名参数)
|
||||
if (s != null) {
|
||||
await interactionDb.setLikesCount(id, s.likeCount);
|
||||
}
|
||||
Log.w('本地点赞 upsert 兜底插入: $id (原记录不存在)');
|
||||
}
|
||||
Log.i('本地点赞已${newValue ? "添加" : "取消"}: $id');
|
||||
} catch (e) {
|
||||
Log.e('本地点赞同步失败', e);
|
||||
}
|
||||
@@ -130,8 +181,15 @@ mixin HomeInteractionMixin on Notifier<HomeState> {
|
||||
final oldValue = sentence.isFavorited;
|
||||
if (!mounted) return;
|
||||
|
||||
// Step 1: 乐观更新UI
|
||||
updateSentence(id, (s) => s.copyWith(isFavorited: !s.isFavorited));
|
||||
// Step 1: 乐观更新UI — 同步翻转 isFavorited 和递增/递减 favoriteCount
|
||||
// 修复: 与 toggleLike 同类问题,原仅翻转 isFavorited,favoriteCount 不变
|
||||
final newFavCount = oldValue
|
||||
? (sentence.favoriteCount > 0 ? sentence.favoriteCount - 1 : 0)
|
||||
: sentence.favoriteCount + 1;
|
||||
updateSentence(id, (s) => s.copyWith(
|
||||
isFavorited: !s.isFavorited,
|
||||
favoriteCount: newFavCount,
|
||||
));
|
||||
SfxService.instance.play(
|
||||
oldValue ? SfxType.unfavorite : SfxType.favorite,
|
||||
);
|
||||
@@ -157,10 +215,47 @@ mixin HomeInteractionMixin on Notifier<HomeState> {
|
||||
}
|
||||
|
||||
/// 本地收藏持久化:写入本地数据库
|
||||
///
|
||||
/// 修复:原实现仅调用 `toggleFavorite(id)` UPDATE,若 sentences 表中
|
||||
/// 不存在该 id 的记录(如 Feed 列表未缓存/缓存被清),UPDATE 会静默失败,
|
||||
/// 导致本地 isFavorite 字段从未被设置 → "我的收藏"页面查不到刚收藏的句子。
|
||||
///
|
||||
/// 现在:先查询记录是否存在,
|
||||
/// - 存在:用 `setFavoriteFlag(id, !oldValue)` 精确设置(不用 toggle 防止并发抖动)
|
||||
/// - 不存在:插入一条最小化记录,isFavorite 设为目标值,保证后续可被查询到
|
||||
Future<void> _persistFavoriteLocally(String id, bool oldValue) async {
|
||||
try {
|
||||
await interactionDb.toggleFavorite(id);
|
||||
Log.i('本地收藏已${!oldValue ? "添加" : "取消"}: $id');
|
||||
final newValue = !oldValue;
|
||||
final existing = await interactionDb.getSentencesById(id);
|
||||
if (existing != null) {
|
||||
// 记录存在,精确设置(避免 toggle 在并发/重试时翻转两次回到原值)
|
||||
await interactionDb.setFavoriteFlag(id, newValue);
|
||||
} else {
|
||||
// 记录不存在,插入一条最小化兜底记录
|
||||
// 内容字段从当前内存中的 sentence 取,保证收藏列表可显示
|
||||
final s = findSentence(id);
|
||||
await interactionDb.insertOrUpdateSentence(
|
||||
SentencesCompanion(
|
||||
id: Value(id),
|
||||
content: Value(s?.text ?? ''),
|
||||
author: Value(s?.author ?? ''),
|
||||
source: Value(s?.source ?? ''),
|
||||
tags: Value(s?.type ?? ''),
|
||||
feedType: Value(s?.feedType ?? ''),
|
||||
feedName: Value(s?.feedName ?? ''),
|
||||
feedIcon: Value(s?.feedIcon ?? ''),
|
||||
views: Value(s?.views ?? 0),
|
||||
imageUrl: const Value(''),
|
||||
isFavorite: Value(newValue),
|
||||
isLiked: Value(s?.isLiked ?? false),
|
||||
isRead: const Value(false),
|
||||
createdAt: Value(DateTime.now()),
|
||||
updatedAt: Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
Log.w('本地收藏 upsert 兜底插入: $id (原记录不存在)');
|
||||
}
|
||||
Log.i('本地收藏已${newValue ? "添加" : "取消"}: $id');
|
||||
} catch (e) {
|
||||
Log.e('本地收藏同步失败', e);
|
||||
}
|
||||
|
||||
@@ -182,6 +182,8 @@ class HomeSentence {
|
||||
feedName: row.feedName.isEmpty ? null : row.feedName,
|
||||
feedIcon: row.feedIcon.isEmpty ? null : row.feedIcon,
|
||||
views: row.views,
|
||||
// likes 字段: .g.dart 未重新生成(build_runner 阻塞),Sentence 类缺 likes getter
|
||||
// 此处保持默认 0,下次 Feed 列表刷新会从服务端获取真实 like_count
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user