indicator-favorite.js 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. /* ==============================================================
  2. * utils/indicator-favorite.js - 关注指标收藏(方案A:后端持久化)
  3. * 主路径:调后端 /api/indicator/favorite/*(JWT 自动附带,跨设备同步)
  4. * 降级读:后端不可用/离线/未登录时回落到本地 Storage 旧 key(方案B 兼容)
  5. * 隔离维度:userId × familyId × memberId × sourceType × definitionId
  6. * (前四维决定 Storage key,后端另加 definitionId 作唯一键)
  7. *
  8. * ⚠️ readFavorites / toggleFavorite / removeFavorite 自方案A 起为**异步**(返回 Promise),
  9. * 调用方必须 .then() 消费;writeFavorites 仍为同步(仅写本地缓存,供降级读)。
  10. * ============================================================== */
  11. import { toggleIndicatorFavorite, listIndicatorFavorites, removeIndicatorFavorite } from './api.js'
  12. const KEY_PREFIX = 'cfc:indicator:fav'
  13. /**
  14. * 拼装存储 key(维度隔离;缺失维度用占位符,避免不同账号/成员互相覆盖)
  15. * @param {{userId?:*, familyId?:*, memberId?:*, sourceType?:*}} scope
  16. * @returns {string}
  17. */
  18. export function buildFavoriteKey(scope) {
  19. var s = scope || {}
  20. var userId = (s.userId === undefined || s.userId === null || s.userId === '') ? 'anon' : String(s.userId)
  21. var familyId = (s.familyId === undefined || s.familyId === null || s.familyId === '') ? 'nofamily' : String(s.familyId)
  22. var memberId = (s.memberId === undefined || s.memberId === null || s.memberId === '') ? 'nomember' : String(s.memberId)
  23. var sourceType = (s.sourceType === undefined || s.sourceType === null || s.sourceType === '') ? 'all' : String(s.sourceType)
  24. return KEY_PREFIX + ':' + userId + ':' + familyId + ':' + memberId + ':' + sourceType
  25. }
  26. /** 数字数组去重、丢弃非法值 */
  27. function normalizeIds(val) {
  28. if (val === undefined || val === null || val === '') return []
  29. if (typeof val === 'string') {
  30. try { val = JSON.parse(val) } catch (e) { return [] }
  31. }
  32. if (!Array.isArray(val)) return []
  33. var out = []
  34. var seen = {}
  35. for (var i = 0; i < val.length; i++) {
  36. var n = Number(val[i])
  37. if (isNaN(n)) continue
  38. if (seen[n]) continue
  39. seen[n] = true
  40. out.push(n)
  41. }
  42. return out
  43. }
  44. /**
  45. * 读取本地缓存(同步,仅作降级读/乐观更新快照)
  46. * @param {Object} scope
  47. * @returns {number[]}
  48. */
  49. function readLocal(scope) {
  50. var key = buildFavoriteKey(scope)
  51. var val
  52. try {
  53. val = uni.getStorageSync(key)
  54. } catch (e) {
  55. return []
  56. }
  57. return normalizeIds(val)
  58. }
  59. /**
  60. * 写入本地缓存(同步;后端成功结果/乐观更新都会回写,供离线降级读)
  61. * @param {Object} scope
  62. * @param {number[]} ids
  63. * @returns {number[]} 写入后的规范化列表
  64. */
  65. function writeLocal(scope, ids) {
  66. var key = buildFavoriteKey(scope)
  67. var arr = normalizeIds(ids)
  68. try {
  69. uni.setStorageSync(key, arr)
  70. } catch (e) {
  71. // 存储失败静默(收藏是增强功能,不影响主流程)
  72. console.warn('[indicator-favorite] 写入失败', key, e)
  73. }
  74. return arr
  75. }
  76. /**
  77. * 写入某视角的本地收藏缓存(同步)。
  78. * 方案A 起仅供降级读与测试使用,正常收藏路径走 toggleFavorite/removeFavorite。
  79. * @param {Object} scope
  80. * @param {number[]} ids
  81. * @returns {number[]} 写入后的规范化列表
  82. */
  83. export function writeFavorites(scope, ids) {
  84. return writeLocal(scope, ids)
  85. }
  86. /** scope 是否具备调后端的最小维度(memberId + sourceType) */
  87. function canUseBackend(scope) {
  88. var s = scope || {}
  89. var memberId = Number(s.memberId)
  90. return !!memberId && !isNaN(memberId) && !!s.sourceType
  91. }
  92. /**
  93. * 静默调用(失败返回 null,不抛异常、不弹 toast):
  94. * api.js 三函数已带 _noToast,这里只做「非 200 / 网络错误 / 无 data」统一归一为 null。
  95. */
  96. function silentCall(fn, params) {
  97. var p
  98. try {
  99. p = fn(params)
  100. } catch (e) {
  101. return Promise.resolve(null)
  102. }
  103. return Promise.resolve(p).then(function (res) {
  104. if (res && res.code === 200 && res.data) return res.data
  105. return null
  106. }, function () {
  107. return null
  108. })
  109. }
  110. /**
  111. * 读取某视角的收藏指标 definitionId 列表(异步):
  112. * 优先后端;后端不可用/未登录/缺维度时降级读本地 Storage。
  113. * @param {Object} scope
  114. * @returns {Promise<number[]>}
  115. */
  116. export function readFavorites(scope) {
  117. if (!canUseBackend(scope)) {
  118. return Promise.resolve(readLocal(scope))
  119. }
  120. var params = { memberId: Number(scope.memberId), sourceType: scope.sourceType }
  121. return silentCall(listIndicatorFavorites, params).then(function (data) {
  122. if (data && Array.isArray(data.definitionIds)) {
  123. // 回写本地缓存:下次离线/后端不可用时可降级读
  124. return writeLocal(scope, data.definitionIds)
  125. }
  126. return readLocal(scope)
  127. })
  128. }
  129. /**
  130. * 切换某指标的收藏状态(异步):乐观更新本地 → 调后端 → 失败回滚。
  131. * @param {Object} scope
  132. * @param {number|string} definitionId
  133. * @returns {Promise<{ids:number[], added:boolean}>} added=true 表示新加入收藏
  134. */
  135. export function toggleFavorite(scope, definitionId) {
  136. var id = Number(definitionId)
  137. if (isNaN(id)) {
  138. return Promise.resolve({ ids: readLocal(scope), added: false })
  139. }
  140. var before = readLocal(scope)
  141. // 乐观更新本地(后端失败时回滚到 before)
  142. var optimistic = before.slice()
  143. var idx = optimistic.indexOf(id)
  144. if (idx >= 0) optimistic.splice(idx, 1)
  145. else optimistic.push(id)
  146. writeLocal(scope, optimistic)
  147. if (!canUseBackend(scope)) {
  148. // 未登录/无成员视角:本地模式(与方案B 行为一致)
  149. return Promise.resolve({ ids: optimistic, added: idx < 0 })
  150. }
  151. var params = { memberId: Number(scope.memberId), sourceType: scope.sourceType, definitionId: id }
  152. return silentCall(toggleIndicatorFavorite, params).then(function (data) {
  153. if (!data) {
  154. // 后端不可用:回滚本地,保持与后端一致的「未变更」语义
  155. writeLocal(scope, before)
  156. return { ids: before, added: false }
  157. }
  158. // 以后端返回的 added 为准同步本地缓存(本地缓存可能已过期)
  159. var synced = before.slice()
  160. var i = synced.indexOf(id)
  161. if (data.added === true) {
  162. if (i < 0) synced.push(id)
  163. } else if (i >= 0) {
  164. synced.splice(i, 1)
  165. }
  166. writeLocal(scope, synced)
  167. return { ids: normalizeIds(synced), added: data.added === true }
  168. })
  169. }
  170. /**
  171. * 显式移除收藏(异步):乐观更新本地 → 调后端 /remove → 失败回滚。
  172. * 与 toggle 的区别:不做状态翻转判断,后端幂等(不存在也视作已删除)。
  173. * @param {Object} scope
  174. * @param {number|string} definitionId
  175. * @returns {Promise<{ids:number[], removed:boolean}>}
  176. */
  177. export function removeFavorite(scope, definitionId) {
  178. var id = Number(definitionId)
  179. if (isNaN(id)) {
  180. return Promise.resolve({ ids: readLocal(scope), removed: false })
  181. }
  182. var before = readLocal(scope)
  183. var optimistic = before.slice()
  184. var idx = optimistic.indexOf(id)
  185. if (idx >= 0) optimistic.splice(idx, 1)
  186. writeLocal(scope, optimistic)
  187. if (!canUseBackend(scope)) {
  188. return Promise.resolve({ ids: optimistic, removed: true })
  189. }
  190. var params = { memberId: Number(scope.memberId), sourceType: scope.sourceType, definitionId: id }
  191. return silentCall(removeIndicatorFavorite, params).then(function (data) {
  192. if (!data) {
  193. writeLocal(scope, before)
  194. return { ids: before, removed: false }
  195. }
  196. return { ids: normalizeIds(optimistic), removed: data.removed !== false }
  197. })
  198. }