/* ============================================================== * utils/indicator-favorite.js - 关注指标收藏(方案A:后端持久化) * 主路径:调后端 /api/indicator/favorite/*(JWT 自动附带,跨设备同步) * 降级读:后端不可用/离线/未登录时回落到本地 Storage 旧 key(方案B 兼容) * 隔离维度:userId × familyId × memberId × sourceType × definitionId * (前四维决定 Storage key,后端另加 definitionId 作唯一键) * * ⚠️ readFavorites / toggleFavorite / removeFavorite 自方案A 起为**异步**(返回 Promise), * 调用方必须 .then() 消费;writeFavorites 仍为同步(仅写本地缓存,供降级读)。 * ============================================================== */ import { toggleIndicatorFavorite, listIndicatorFavorites, removeIndicatorFavorite } from './api.js' const KEY_PREFIX = 'cfc:indicator:fav' /** * 拼装存储 key(维度隔离;缺失维度用占位符,避免不同账号/成员互相覆盖) * @param {{userId?:*, familyId?:*, memberId?:*, sourceType?:*}} scope * @returns {string} */ export function buildFavoriteKey(scope) { var s = scope || {} var userId = (s.userId === undefined || s.userId === null || s.userId === '') ? 'anon' : String(s.userId) var familyId = (s.familyId === undefined || s.familyId === null || s.familyId === '') ? 'nofamily' : String(s.familyId) var memberId = (s.memberId === undefined || s.memberId === null || s.memberId === '') ? 'nomember' : String(s.memberId) var sourceType = (s.sourceType === undefined || s.sourceType === null || s.sourceType === '') ? 'all' : String(s.sourceType) return KEY_PREFIX + ':' + userId + ':' + familyId + ':' + memberId + ':' + sourceType } /** 数字数组去重、丢弃非法值 */ function normalizeIds(val) { if (val === undefined || val === null || val === '') return [] if (typeof val === 'string') { try { val = JSON.parse(val) } catch (e) { return [] } } if (!Array.isArray(val)) return [] var out = [] var seen = {} for (var i = 0; i < val.length; i++) { var n = Number(val[i]) if (isNaN(n)) continue if (seen[n]) continue seen[n] = true out.push(n) } return out } /** * 读取本地缓存(同步,仅作降级读/乐观更新快照) * @param {Object} scope * @returns {number[]} */ function readLocal(scope) { var key = buildFavoriteKey(scope) var val try { val = uni.getStorageSync(key) } catch (e) { return [] } return normalizeIds(val) } /** * 写入本地缓存(同步;后端成功结果/乐观更新都会回写,供离线降级读) * @param {Object} scope * @param {number[]} ids * @returns {number[]} 写入后的规范化列表 */ function writeLocal(scope, ids) { var key = buildFavoriteKey(scope) var arr = normalizeIds(ids) try { uni.setStorageSync(key, arr) } catch (e) { // 存储失败静默(收藏是增强功能,不影响主流程) console.warn('[indicator-favorite] 写入失败', key, e) } return arr } /** * 写入某视角的本地收藏缓存(同步)。 * 方案A 起仅供降级读与测试使用,正常收藏路径走 toggleFavorite/removeFavorite。 * @param {Object} scope * @param {number[]} ids * @returns {number[]} 写入后的规范化列表 */ export function writeFavorites(scope, ids) { return writeLocal(scope, ids) } /** scope 是否具备调后端的最小维度(memberId + sourceType) */ function canUseBackend(scope) { var s = scope || {} var memberId = Number(s.memberId) return !!memberId && !isNaN(memberId) && !!s.sourceType } /** * 静默调用(失败返回 null,不抛异常、不弹 toast): * api.js 三函数已带 _noToast,这里只做「非 200 / 网络错误 / 无 data」统一归一为 null。 */ function silentCall(fn, params) { var p try { p = fn(params) } catch (e) { return Promise.resolve(null) } return Promise.resolve(p).then(function (res) { if (res && res.code === 200 && res.data) return res.data return null }, function () { return null }) } /** * 读取某视角的收藏指标 definitionId 列表(异步): * 优先后端;后端不可用/未登录/缺维度时降级读本地 Storage。 * @param {Object} scope * @returns {Promise} */ export function readFavorites(scope) { if (!canUseBackend(scope)) { return Promise.resolve(readLocal(scope)) } var params = { memberId: Number(scope.memberId), sourceType: scope.sourceType } return silentCall(listIndicatorFavorites, params).then(function (data) { if (data && Array.isArray(data.definitionIds)) { // 回写本地缓存:下次离线/后端不可用时可降级读 return writeLocal(scope, data.definitionIds) } return readLocal(scope) }) } /** * 切换某指标的收藏状态(异步):乐观更新本地 → 调后端 → 失败回滚。 * @param {Object} scope * @param {number|string} definitionId * @returns {Promise<{ids:number[], added:boolean}>} added=true 表示新加入收藏 */ export function toggleFavorite(scope, definitionId) { var id = Number(definitionId) if (isNaN(id)) { return Promise.resolve({ ids: readLocal(scope), added: false }) } var before = readLocal(scope) // 乐观更新本地(后端失败时回滚到 before) var optimistic = before.slice() var idx = optimistic.indexOf(id) if (idx >= 0) optimistic.splice(idx, 1) else optimistic.push(id) writeLocal(scope, optimistic) if (!canUseBackend(scope)) { // 未登录/无成员视角:本地模式(与方案B 行为一致) return Promise.resolve({ ids: optimistic, added: idx < 0 }) } var params = { memberId: Number(scope.memberId), sourceType: scope.sourceType, definitionId: id } return silentCall(toggleIndicatorFavorite, params).then(function (data) { if (!data) { // 后端不可用:回滚本地,保持与后端一致的「未变更」语义 writeLocal(scope, before) return { ids: before, added: false } } // 以后端返回的 added 为准同步本地缓存(本地缓存可能已过期) var synced = before.slice() var i = synced.indexOf(id) if (data.added === true) { if (i < 0) synced.push(id) } else if (i >= 0) { synced.splice(i, 1) } writeLocal(scope, synced) return { ids: normalizeIds(synced), added: data.added === true } }) } /** * 显式移除收藏(异步):乐观更新本地 → 调后端 /remove → 失败回滚。 * 与 toggle 的区别:不做状态翻转判断,后端幂等(不存在也视作已删除)。 * @param {Object} scope * @param {number|string} definitionId * @returns {Promise<{ids:number[], removed:boolean}>} */ export function removeFavorite(scope, definitionId) { var id = Number(definitionId) if (isNaN(id)) { return Promise.resolve({ ids: readLocal(scope), removed: false }) } var before = readLocal(scope) var optimistic = before.slice() var idx = optimistic.indexOf(id) if (idx >= 0) optimistic.splice(idx, 1) writeLocal(scope, optimistic) if (!canUseBackend(scope)) { return Promise.resolve({ ids: optimistic, removed: true }) } var params = { memberId: Number(scope.memberId), sourceType: scope.sourceType, definitionId: id } return silentCall(removeIndicatorFavorite, params).then(function (data) { if (!data) { writeLocal(scope, before) return { ids: before, removed: false } } return { ids: normalizeIds(optimistic), removed: data.removed !== false } }) }