memberNormalize.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. /**
  2. * 家庭成员去重规范化工具(T25 统一成员选择器 · 层3 前端防御)
  3. *
  4. * 背景:
  5. * family_members 存量数据中同一 (user_id, family_id) 可能存在多条重复行,
  6. * 导致同一用户在前端被渲染成两项("本人" + "本人所对应的成员")。
  7. *
  8. * 重要边界(见设计文档 §一.3):
  9. * 本工具只是**前端防御层**。重复的 family_members 行各自挂着不同的外键数据
  10. * (任务/打卡/健康等),单纯折叠会"隐藏"其中一行的数据。
  11. * 真正的根治是数据层合并(T24-C),需单独授权执行。
  12. * 后端 FamilyMemberService.listMembers 已按 user_id 做确定性折叠,
  13. * 本工具用于兜住仍返回重复行的历史接口/前端缓存数据。
  14. *
  15. * 折叠规则:
  16. * 1. 按 user_id 分组折叠(userId 为空的"不能登录用户"各自独立成组)
  17. * 2. canonical 行优先级:isSelf 行 → status='active' → 外键数据最多 → id 最小
  18. * 3. 每个真实用户至多输出一行,且全列表 isSelf 唯一
  19. * 4. 统一身份键为 familyMemberId(family_members.id),userId 仅作折叠依据
  20. */
  21. /**
  22. * 取成员的统一身份键(familyMemberId)。
  23. * 后端 FamilyMemberVO 已下发 familyMemberId;历史接口缺失时回退到 id。
  24. */
  25. export function memberIdOf(member) {
  26. if (!member) return null
  27. if (member.familyMemberId !== null && member.familyMemberId !== undefined && member.familyMemberId !== '') {
  28. return member.familyMemberId
  29. }
  30. if (member.id !== null && member.id !== undefined && member.id !== '') {
  31. return member.id
  32. }
  33. return null
  34. }
  35. /** 成员是否为有效状态(active 优先;未带 status 字段的历史数据视为 active) */
  36. function isActive(member) {
  37. if (!member || member.status === null || member.status === undefined || member.status === '') {
  38. return true
  39. }
  40. return member.status === 'active'
  41. }
  42. /**
  43. * 成员"数据量"权重(用于 canonical 选取的第三级 tie-break)。
  44. * 后端若下发 fkCount(外键引用数)则直接采用;否则用可见的数据丰度做近似。
  45. */
  46. function dataWeight(member) {
  47. if (!member) return 0
  48. if (typeof member.fkCount === 'number' && !isNaN(member.fkCount)) return member.fkCount
  49. if (typeof member.refCount === 'number' && !isNaN(member.refCount)) return member.refCount
  50. var n = 0
  51. if (member.totalPoints) n += 1
  52. if (member.streakDays) n += 1
  53. if (member.lastInteractionAt) n += 1
  54. if (member.trustScore !== null && member.trustScore !== undefined) n += 1
  55. if (member.intimacyScore !== null && member.intimacyScore !== undefined) n += 1
  56. if (member.communicationScore !== null && member.communicationScore !== undefined) n += 1
  57. return n
  58. }
  59. function numericId(member) {
  60. var id = memberIdOf(member)
  61. var n = Number(id)
  62. return isNaN(n) ? Number.MAX_SAFE_INTEGER : n
  63. }
  64. /**
  65. * canonical 行比较器:返回 <0 表示 a 更优先。
  66. * 优先级:isSelf → status='active' → 数据量最多 → id 最小。
  67. */
  68. function compareCanonical(a, b) {
  69. var aSelf = a && a.isSelf ? 1 : 0
  70. var bSelf = b && b.isSelf ? 1 : 0
  71. if (aSelf !== bSelf) return bSelf - aSelf
  72. var aActive = isActive(a) ? 1 : 0
  73. var bActive = isActive(b) ? 1 : 0
  74. if (aActive !== bActive) return bActive - aActive
  75. var aWeight = dataWeight(a)
  76. var bWeight = dataWeight(b)
  77. if (aWeight !== bWeight) return bWeight - aWeight
  78. return numericId(a) - numericId(b)
  79. }
  80. function hasLoginUser(member) {
  81. return !!member && member.userId !== null && member.userId !== undefined && member.userId !== '' && Number(member.userId) !== 0
  82. }
  83. /**
  84. * 规范化成员列表。
  85. *
  86. * @param {Array} rawMembers 原始成员数组(后端 VO 或其变体)
  87. * @param {Object} [viewer] 当前查看者,形如 { userId, memberId }(可只给其一)
  88. * @returns {Array} 每个真实用户至多一条、isSelf 唯一的成员数组
  89. */
  90. export function normalizeMembers(rawMembers, viewer) {
  91. var list = rawMembers instanceof Array ? rawMembers : []
  92. if (list.length === 0) return []
  93. var viewerUserId = viewer && viewer.userId !== null && viewer.userId !== undefined && viewer.userId !== ''
  94. ? String(viewer.userId) : ''
  95. var viewerMemberId = viewer && viewer.memberId !== null && viewer.memberId !== undefined && viewer.memberId !== ''
  96. ? String(viewer.memberId) : ''
  97. // 1) 按 user_id 分组(保留首次出现顺序)
  98. var groups = []
  99. var groupIndex = {}
  100. for (var i = 0; i < list.length; i++) {
  101. var row = list[i]
  102. if (!row || typeof row !== 'object') continue
  103. var key
  104. if (hasLoginUser(row)) {
  105. key = 'u:' + String(row.userId)
  106. } else {
  107. // 不能登录用户(无 userId):每行独立,按自身 id 去重
  108. key = 'm:' + String(row.id !== undefined && row.id !== null ? row.id : ('idx:' + i))
  109. }
  110. if (groupIndex[key] === undefined) {
  111. groupIndex[key] = groups.length
  112. groups.push([])
  113. }
  114. groups[groupIndex[key]].push(row)
  115. }
  116. // 2) 每组选 canonical 行(shallow-clone 输出,避免污染父组件共享的成员数组)
  117. var result = []
  118. for (var g = 0; g < groups.length; g++) {
  119. var group = groups[g]
  120. var winner = group[0]
  121. if (group.length > 1) {
  122. for (var k = 1; k < group.length; k++) {
  123. if (compareCanonical(group[k], winner) < 0) {
  124. winner = group[k]
  125. }
  126. }
  127. }
  128. var copy = {}
  129. for (var key in winner) {
  130. if (Object.prototype.hasOwnProperty.call(winner, key)) {
  131. copy[key] = winner[key]
  132. }
  133. }
  134. result.push(copy)
  135. }
  136. // 3) isSelf 唯一化:优先认 viewer,其次保留后端标记的那一条
  137. var selfIndex = -1
  138. if (viewerMemberId) {
  139. for (var j = 0; j < result.length; j++) {
  140. if (String(memberIdOf(result[j])) === viewerMemberId) {
  141. selfIndex = j
  142. break
  143. }
  144. }
  145. }
  146. if (selfIndex < 0 && viewerUserId) {
  147. for (var j2 = 0; j2 < result.length; j2++) {
  148. if (hasLoginUser(result[j2]) && String(result[j2].userId) === viewerUserId) {
  149. selfIndex = j2
  150. break
  151. }
  152. }
  153. }
  154. if (selfIndex < 0) {
  155. for (var j3 = 0; j3 < result.length; j3++) {
  156. if (result[j3] && result[j3].isSelf) {
  157. selfIndex = j3
  158. break
  159. }
  160. }
  161. }
  162. for (var j4 = 0; j4 < result.length; j4++) {
  163. result[j4].isSelf = (j4 === selfIndex)
  164. }
  165. // 4) 补齐统一身份键 + 渲染用唯一 key
  166. var usedKeys = {}
  167. for (var j5 = 0; j5 < result.length; j5++) {
  168. var m = result[j5]
  169. var mid = memberIdOf(m)
  170. m.familyMemberId = mid
  171. m.memberId = mid
  172. var base = (m.source === 'parent' ? 'parent:' : 'member:') + String(mid)
  173. if (usedKeys[base]) {
  174. var n = 2
  175. while (usedKeys[base + '#' + n]) n++
  176. base = base + '#' + n
  177. }
  178. usedKeys[base] = true
  179. m.selectorKey = base
  180. }
  181. return result
  182. }
  183. /**
  184. * 把历史遗留的成员标识归一到统一身份键(familyMemberId)。
  185. *
  186. * 背景:部分页面从 URL 参数 / 本地缓存拿到的还是旧口径标识
  187. * (users.id 或遗留 children.id),直接当选择器主键会选不中任何一项。
  188. * 命中规则:先按 canonical memberId 精确匹配 → 再按 id 匹配 → 最后按 userId 匹配。
  189. *
  190. * @param {Array} members 成员列表(已规范化或原始均可)
  191. * @param {String|Number} rawId 历史标识
  192. * @returns {String|Number|null} 统一身份键;无法匹配时返回 null
  193. */
  194. export function resolveMemberId(members, rawId) {
  195. if (rawId === null || rawId === undefined || rawId === '') return null
  196. var list = members instanceof Array ? members : []
  197. var target = String(rawId)
  198. var i
  199. // 1) canonical memberId 精确匹配
  200. for (i = 0; i < list.length; i++) {
  201. if (String(memberIdOf(list[i])) === target) return memberIdOf(list[i])
  202. }
  203. // 2) 行 id 匹配
  204. for (i = 0; i < list.length; i++) {
  205. if (list[i] && list[i].id !== null && list[i].id !== undefined && String(list[i].id) === target) {
  206. return memberIdOf(list[i])
  207. }
  208. }
  209. // 3) userId 匹配(旧口径:把 users.id 当成员主键)
  210. for (i = 0; i < list.length; i++) {
  211. if (list[i] && hasLoginUser(list[i]) && String(list[i].userId) === target) {
  212. return memberIdOf(list[i])
  213. }
  214. }
  215. return null
  216. }
  217. /** 从(已规范化或未规范化的)成员列表中找出"本人"一行 */
  218. export function findSelfMember(members) {
  219. if (!(members instanceof Array)) return null
  220. for (var i = 0; i < members.length; i++) {
  221. if (members[i] && members[i].isSelf) return members[i]
  222. }
  223. return null
  224. }
  225. /** 从成员列表中排除"本人"行(用于"个人 + 各成员"形态,避免同一人出现两项) */
  226. export function membersExceptSelf(members) {
  227. if (!(members instanceof Array)) return []
  228. var out = []
  229. for (var i = 0; i < members.length; i++) {
  230. if (members[i] && !members[i].isSelf) out.push(members[i])
  231. }
  232. return out
  233. }
  234. export default normalizeMembers
  235. /**
  236. * 健康打卡页(饮食/运动/睡眠)解析当前打卡成员(memberId),与首页 growth-main 的
  237. * `currentChildId || currentMemberId || userId` 解析逻辑对齐。
  238. *
  239. * 背景(生产缺陷):三个打卡页 onLoad 原先用
  240. * `if (role !== 'child') { memberId = options.memberId || currentMemberId || null }`
  241. * 与首页解析不一致:
  242. * 1) role==='child' 时 memberId 恒为 null(子用户打卡页拿不到成员);
  243. * 2) parent 角色缺 currentChildId / userId 兜底。
  244. * 当从首页点击进入且当日无未完成任务(handleProgressClick 仅在 item.taskId 存在时
  245. * 拼 memberId 参数,无任务时 URL 不带 memberId)时,memberId 为空 → 提交被
  246. * `if (!memberId)` 拦截提示"成员ID无效"。
  247. *
  248. * 优先级:options.memberId(URL/页面参数)> currentChildId > currentMemberId > userId > null。
  249. * 不再受 role 限制(child 角色同样回退到 currentChildId/userId)。
  250. *
  251. * @param {Object|null} options 页面 onLoad 参数(含 memberId/taskId)
  252. * @param {Object|null} storage uni.getStorageSync 的可选快照(含 currentChildId/currentMemberId/userId/currentRole)
  253. * @returns {String|Number|null} 解析出的 memberId;无法解析时返回 null(由调用方决定是否拦截)
  254. */
  255. export function resolveCheckinMemberId(options, storage) {
  256. var opts = options || {}
  257. var st = storage || {}
  258. var memberId = opts.memberId
  259. if (memberId === null || memberId === undefined || memberId === '') {
  260. memberId = st.currentChildId
  261. }
  262. if (memberId === null || memberId === undefined || memberId === '') {
  263. memberId = st.currentMemberId
  264. }
  265. if (memberId === null || memberId === undefined || memberId === '') {
  266. memberId = st.userId
  267. }
  268. if (memberId === null || memberId === undefined || memberId === '') {
  269. return null
  270. }
  271. return memberId
  272. }