/** * 家庭成员去重规范化工具(T25 统一成员选择器 · 层3 前端防御) * * 背景: * family_members 存量数据中同一 (user_id, family_id) 可能存在多条重复行, * 导致同一用户在前端被渲染成两项("本人" + "本人所对应的成员")。 * * 重要边界(见设计文档 §一.3): * 本工具只是**前端防御层**。重复的 family_members 行各自挂着不同的外键数据 * (任务/打卡/健康等),单纯折叠会"隐藏"其中一行的数据。 * 真正的根治是数据层合并(T24-C),需单独授权执行。 * 后端 FamilyMemberService.listMembers 已按 user_id 做确定性折叠, * 本工具用于兜住仍返回重复行的历史接口/前端缓存数据。 * * 折叠规则: * 1. 按 user_id 分组折叠(userId 为空的"不能登录用户"各自独立成组) * 2. canonical 行优先级:isSelf 行 → status='active' → 外键数据最多 → id 最小 * 3. 每个真实用户至多输出一行,且全列表 isSelf 唯一 * 4. 统一身份键为 familyMemberId(family_members.id),userId 仅作折叠依据 */ /** * 取成员的统一身份键(familyMemberId)。 * 后端 FamilyMemberVO 已下发 familyMemberId;历史接口缺失时回退到 id。 */ export function memberIdOf(member) { if (!member) return null if (member.familyMemberId !== null && member.familyMemberId !== undefined && member.familyMemberId !== '') { return member.familyMemberId } if (member.id !== null && member.id !== undefined && member.id !== '') { return member.id } return null } /** 成员是否为有效状态(active 优先;未带 status 字段的历史数据视为 active) */ function isActive(member) { if (!member || member.status === null || member.status === undefined || member.status === '') { return true } return member.status === 'active' } /** * 成员"数据量"权重(用于 canonical 选取的第三级 tie-break)。 * 后端若下发 fkCount(外键引用数)则直接采用;否则用可见的数据丰度做近似。 */ function dataWeight(member) { if (!member) return 0 if (typeof member.fkCount === 'number' && !isNaN(member.fkCount)) return member.fkCount if (typeof member.refCount === 'number' && !isNaN(member.refCount)) return member.refCount var n = 0 if (member.totalPoints) n += 1 if (member.streakDays) n += 1 if (member.lastInteractionAt) n += 1 if (member.trustScore !== null && member.trustScore !== undefined) n += 1 if (member.intimacyScore !== null && member.intimacyScore !== undefined) n += 1 if (member.communicationScore !== null && member.communicationScore !== undefined) n += 1 return n } function numericId(member) { var id = memberIdOf(member) var n = Number(id) return isNaN(n) ? Number.MAX_SAFE_INTEGER : n } /** * canonical 行比较器:返回 <0 表示 a 更优先。 * 优先级:isSelf → status='active' → 数据量最多 → id 最小。 */ function compareCanonical(a, b) { var aSelf = a && a.isSelf ? 1 : 0 var bSelf = b && b.isSelf ? 1 : 0 if (aSelf !== bSelf) return bSelf - aSelf var aActive = isActive(a) ? 1 : 0 var bActive = isActive(b) ? 1 : 0 if (aActive !== bActive) return bActive - aActive var aWeight = dataWeight(a) var bWeight = dataWeight(b) if (aWeight !== bWeight) return bWeight - aWeight return numericId(a) - numericId(b) } function hasLoginUser(member) { return !!member && member.userId !== null && member.userId !== undefined && member.userId !== '' && Number(member.userId) !== 0 } /** * 规范化成员列表。 * * @param {Array} rawMembers 原始成员数组(后端 VO 或其变体) * @param {Object} [viewer] 当前查看者,形如 { userId, memberId }(可只给其一) * @returns {Array} 每个真实用户至多一条、isSelf 唯一的成员数组 */ export function normalizeMembers(rawMembers, viewer) { var list = rawMembers instanceof Array ? rawMembers : [] if (list.length === 0) return [] var viewerUserId = viewer && viewer.userId !== null && viewer.userId !== undefined && viewer.userId !== '' ? String(viewer.userId) : '' var viewerMemberId = viewer && viewer.memberId !== null && viewer.memberId !== undefined && viewer.memberId !== '' ? String(viewer.memberId) : '' // 1) 按 user_id 分组(保留首次出现顺序) var groups = [] var groupIndex = {} for (var i = 0; i < list.length; i++) { var row = list[i] if (!row || typeof row !== 'object') continue var key if (hasLoginUser(row)) { key = 'u:' + String(row.userId) } else { // 不能登录用户(无 userId):每行独立,按自身 id 去重 key = 'm:' + String(row.id !== undefined && row.id !== null ? row.id : ('idx:' + i)) } if (groupIndex[key] === undefined) { groupIndex[key] = groups.length groups.push([]) } groups[groupIndex[key]].push(row) } // 2) 每组选 canonical 行(shallow-clone 输出,避免污染父组件共享的成员数组) var result = [] for (var g = 0; g < groups.length; g++) { var group = groups[g] var winner = group[0] if (group.length > 1) { for (var k = 1; k < group.length; k++) { if (compareCanonical(group[k], winner) < 0) { winner = group[k] } } } var copy = {} for (var key in winner) { if (Object.prototype.hasOwnProperty.call(winner, key)) { copy[key] = winner[key] } } result.push(copy) } // 3) isSelf 唯一化:优先认 viewer,其次保留后端标记的那一条 var selfIndex = -1 if (viewerMemberId) { for (var j = 0; j < result.length; j++) { if (String(memberIdOf(result[j])) === viewerMemberId) { selfIndex = j break } } } if (selfIndex < 0 && viewerUserId) { for (var j2 = 0; j2 < result.length; j2++) { if (hasLoginUser(result[j2]) && String(result[j2].userId) === viewerUserId) { selfIndex = j2 break } } } if (selfIndex < 0) { for (var j3 = 0; j3 < result.length; j3++) { if (result[j3] && result[j3].isSelf) { selfIndex = j3 break } } } for (var j4 = 0; j4 < result.length; j4++) { result[j4].isSelf = (j4 === selfIndex) } // 4) 补齐统一身份键 + 渲染用唯一 key var usedKeys = {} for (var j5 = 0; j5 < result.length; j5++) { var m = result[j5] var mid = memberIdOf(m) m.familyMemberId = mid m.memberId = mid var base = (m.source === 'parent' ? 'parent:' : 'member:') + String(mid) if (usedKeys[base]) { var n = 2 while (usedKeys[base + '#' + n]) n++ base = base + '#' + n } usedKeys[base] = true m.selectorKey = base } return result } /** * 把历史遗留的成员标识归一到统一身份键(familyMemberId)。 * * 背景:部分页面从 URL 参数 / 本地缓存拿到的还是旧口径标识 * (users.id 或遗留 children.id),直接当选择器主键会选不中任何一项。 * 命中规则:先按 canonical memberId 精确匹配 → 再按 id 匹配 → 最后按 userId 匹配。 * * @param {Array} members 成员列表(已规范化或原始均可) * @param {String|Number} rawId 历史标识 * @returns {String|Number|null} 统一身份键;无法匹配时返回 null */ export function resolveMemberId(members, rawId) { if (rawId === null || rawId === undefined || rawId === '') return null var list = members instanceof Array ? members : [] var target = String(rawId) var i // 1) canonical memberId 精确匹配 for (i = 0; i < list.length; i++) { if (String(memberIdOf(list[i])) === target) return memberIdOf(list[i]) } // 2) 行 id 匹配 for (i = 0; i < list.length; i++) { if (list[i] && list[i].id !== null && list[i].id !== undefined && String(list[i].id) === target) { return memberIdOf(list[i]) } } // 3) userId 匹配(旧口径:把 users.id 当成员主键) for (i = 0; i < list.length; i++) { if (list[i] && hasLoginUser(list[i]) && String(list[i].userId) === target) { return memberIdOf(list[i]) } } return null } /** 从(已规范化或未规范化的)成员列表中找出"本人"一行 */ export function findSelfMember(members) { if (!(members instanceof Array)) return null for (var i = 0; i < members.length; i++) { if (members[i] && members[i].isSelf) return members[i] } return null } /** 从成员列表中排除"本人"行(用于"个人 + 各成员"形态,避免同一人出现两项) */ export function membersExceptSelf(members) { if (!(members instanceof Array)) return [] var out = [] for (var i = 0; i < members.length; i++) { if (members[i] && !members[i].isSelf) out.push(members[i]) } return out } export default normalizeMembers