瀏覽代碼

feat(indicator-favorite): T04 收藏后端化 前端接线(方案A)— 复用主线程已合入后端

Agnes Agent 1 周之前
父節點
當前提交
dbe8d2f5ca

+ 247 - 0
cfc-frontend/__tests__/utils/indicator-favorite.test.js

@@ -0,0 +1,247 @@
+/**
+ * utils/indicator-favorite 关注指标收藏单测(T04 方案A:后端持久化 + 本地降级)
+ * 规则来源:design §8.1 / §8.3(F4)、§11.3 T04
+ * 隔离维度:userId × familyId × memberId × sourceType → 不同 key,防串号
+ * 后端契约:/api/indicator/favorite/{toggle,list,remove},见
+ *          specs/2026-10-30-indicator-favorite-backend-design.md §4
+ */
+jest.mock('@/utils/api.js', () => ({
+  listIndicatorFavorites: jest.fn(),
+  toggleIndicatorFavorite: jest.fn(),
+  removeIndicatorFavorite: jest.fn()
+}))
+
+import { buildFavoriteKey, readFavorites, writeFavorites, toggleFavorite, removeFavorite } from '@/utils/indicator-favorite'
+import { listIndicatorFavorites, toggleIndicatorFavorite, removeIndicatorFavorite } from '@/utils/api'
+
+/** 后端成功响应包装(api.js 的 request() resolve 的是整个 Result) */
+function ok(data) {
+  return Promise.resolve({ code: 200, message: 'success', data: data })
+}
+
+const SCOPE = { userId: 1, familyId: 1, memberId: 1, sourceType: 'gut_flora' }
+
+describe('indicator-favorite 关注指标收藏(方案A:后端持久化 + 本地降级)', () => {
+  beforeEach(function() {
+    jest.clearAllMocks()
+    // 每个用例前清空 storage,避免用例间污染
+    if (global.uni && global.uni._storage) {
+      Object.keys(global.uni._storage).filter(function(k) { return k.indexOf('cfc:indicator:fav:') === 0 }).forEach(function(k) { delete global.uni._storage[k] })
+    }
+  })
+
+  describe('buildFavoriteKey', () => {
+    test('默认维度全部填充占位符', () => {
+      var key = buildFavoriteKey({})
+      expect(key).toMatch(/cfc:indicator:fav:anon:nofamily:nomember:all$/)
+    })
+    test('全维度隔离', () => {
+      var k1 = buildFavoriteKey({ userId: 1, familyId: 2, memberId: 3, sourceType: 'gut_flora' })
+      var k2 = buildFavoriteKey({ userId: 1, familyId: 2, memberId: 4, sourceType: 'gut_flora' })
+      expect(k2).not.toBe(k1)
+      expect(k2).toContain(':4:')
+    })
+    test('sourceType 隔离', () => {
+      var k1 = buildFavoriteKey({ userId: 1, familyId: 2, memberId: 3, sourceType: 'gut_flora' })
+      var k2 = buildFavoriteKey({ userId: 1, familyId: 2, memberId: 3, sourceType: 'food_recommend' })
+      expect(k2).not.toBe(k1)
+    })
+    test('不同 familyId 隔离', () => {
+      var k1 = buildFavoriteKey({ userId: 1, familyId: 2, memberId: 3 })
+      var k2 = buildFavoriteKey({ userId: 1, familyId: 5, memberId: 3 })
+      expect(k2).not.toBe(k1)
+    })
+  })
+
+  describe('readFavorites(后端优先)', () => {
+    test('后端返回 definitionIds → 原样返回并回写本地缓存', async () => {
+      listIndicatorFavorites.mockReturnValue(ok({ memberId: 1, sourceType: 'gut_flora', definitionIds: [456, 789] }))
+
+      var ids = await readFavorites(SCOPE)
+
+      expect(ids).toEqual([456, 789])
+      expect(listIndicatorFavorites).toHaveBeenCalledWith({ memberId: 1, sourceType: 'gut_flora' })
+      // 回写本地:下次离线可降级读
+      expect(global.uni._storage[buildFavoriteKey(SCOPE)]).toEqual([456, 789])
+    })
+
+    test('后端返回空集合 → []', async () => {
+      listIndicatorFavorites.mockReturnValue(ok({ memberId: 1, sourceType: 'gut_flora', definitionIds: [] }))
+
+      expect(await readFavorites(SCOPE)).toEqual([])
+    })
+
+    test('后端 reject → 降级读本地 Storage', async () => {
+      writeFavorites(SCOPE, [101, 102])
+      listIndicatorFavorites.mockReturnValue(Promise.reject({ code: 500, message: 'boom' }))
+
+      expect(await readFavorites(SCOPE)).toEqual([101, 102])
+    })
+
+    test('后端返回无 data(如 401 未登录)→ 降级读本地', async () => {
+      writeFavorites(SCOPE, [101])
+      listIndicatorFavorites.mockReturnValue(Promise.resolve({ code: 401, message: '请先登录', data: null }))
+
+      expect(await readFavorites(SCOPE)).toEqual([101])
+    })
+
+    test('本地无缓存且后端不可用 → []', async () => {
+      listIndicatorFavorites.mockReturnValue(Promise.reject(new Error('network')))
+
+      expect(await readFavorites(SCOPE)).toEqual([])
+    })
+
+    test('缺 memberId/sourceType(未登录/无视角)→ 不调后端,直接读本地', async () => {
+      writeFavorites({}, [101])
+
+      expect(await readFavorites({})).toEqual([101])
+      expect(listIndicatorFavorites).not.toHaveBeenCalled()
+    })
+
+    test('非法本地数据容错返回 []', async () => {
+      var key = buildFavoriteKey(SCOPE)
+      listIndicatorFavorites.mockReturnValue(Promise.reject(new Error('network')))
+      global.uni._storage[key] = 'not-a-json'
+      expect(await readFavorites(SCOPE)).toEqual([])
+      global.uni._storage[key] = 123
+      expect(await readFavorites(SCOPE)).toEqual([])
+    })
+
+    test('跨 scope 隔离:不同 memberId 读到各自缓存', async () => {
+      var s2 = { userId: 1, familyId: 1, memberId: 2, sourceType: 'gut_flora' }
+      var s3 = { userId: 1, familyId: 1, memberId: 3, sourceType: 'gut_flora' }
+      writeFavorites(s2, [101])
+      listIndicatorFavorites.mockReturnValue(Promise.reject(new Error('network')))
+
+      expect(await readFavorites(s2)).toEqual([101])
+      expect(await readFavorites(s3)).toEqual([])
+    })
+  })
+
+  describe('toggleFavorite(异步 + 乐观更新 + 失败回滚)', () => {
+    test('首次收藏 → 调后端,added=true,本地写入', async () => {
+      toggleIndicatorFavorite.mockReturnValue(ok({ definitionId: 101, added: true }))
+
+      var res = await toggleFavorite(SCOPE, 101)
+
+      expect(res.added).toBe(true)
+      expect(res.ids).toEqual([101])
+      expect(toggleIndicatorFavorite).toHaveBeenCalledWith({ memberId: 1, sourceType: 'gut_flora', definitionId: 101 })
+      expect(global.uni._storage[buildFavoriteKey(SCOPE)]).toEqual([101])
+    })
+
+    test('已收藏再点 → added=false,从本地移除', async () => {
+      writeFavorites(SCOPE, [101])
+      toggleIndicatorFavorite.mockReturnValue(ok({ definitionId: 101, added: false }))
+
+      var res = await toggleFavorite(SCOPE, 101)
+
+      expect(res.added).toBe(false)
+      expect(res.ids).toEqual([])
+      expect(global.uni._storage[buildFavoriteKey(SCOPE)]).toEqual([])
+    })
+
+    test('后端失败 → 回滚本地,added=false', async () => {
+      writeFavorites(SCOPE, [101])
+      toggleIndicatorFavorite.mockReturnValue(Promise.reject({ code: 500, message: 'boom' }))
+
+      var res = await toggleFavorite(SCOPE, 102)
+
+      expect(res.added).toBe(false)
+      expect(res.ids).toEqual([101]) // 回滚到调用前
+      expect(global.uni._storage[buildFavoriteKey(SCOPE)]).toEqual([101])
+    })
+
+    test('后端失败且原本为空 → 回滚为空', async () => {
+      toggleIndicatorFavorite.mockReturnValue(Promise.reject(new Error('network')))
+
+      var res = await toggleFavorite(SCOPE, 101)
+
+      expect(res.ids).toEqual([])
+      expect(global.uni._storage[buildFavoriteKey(SCOPE)]).toEqual([])
+    })
+
+    test('后端返回 added=false(服务端已有该收藏)→ 本地缓存同步为未收藏', async () => {
+      // 本地缓存过期(空),但服务端判定「已存在→删除」
+      toggleIndicatorFavorite.mockReturnValue(ok({ definitionId: 101, added: false }))
+
+      var res = await toggleFavorite(SCOPE, 101)
+
+      expect(res.added).toBe(false)
+      expect(res.ids).toEqual([])
+    })
+
+    test('定义 id 非法(NaN)→ 不改变存储、不调后端', async () => {
+      writeFavorites(SCOPE, [101])
+
+      var res = await toggleFavorite(SCOPE, 'not-a-number')
+
+      expect(res.ids).toEqual([101])
+      expect(res.added).toBe(false)
+      expect(toggleIndicatorFavorite).not.toHaveBeenCalled()
+    })
+
+    test('缺 memberId(未登录)→ 本地模式,不调后端', async () => {
+      var res = await toggleFavorite({}, 101)
+
+      expect(res.added).toBe(true)
+      expect(res.ids).toEqual([101])
+      expect(toggleIndicatorFavorite).not.toHaveBeenCalled()
+    })
+
+    test('跨 sourceType 收藏隔离', async () => {
+      var s2 = { userId: 1, familyId: 1, memberId: 1, sourceType: 'food_recommend' }
+      toggleIndicatorFavorite.mockReturnValue(ok({ definitionId: 101, added: true }))
+
+      await toggleFavorite(SCOPE, 101)
+
+      expect(global.uni._storage[buildFavoriteKey(SCOPE)]).toEqual([101])
+      expect(global.uni._storage[buildFavoriteKey(s2)]).toBeUndefined()
+      expect(await readFavorites(s2)).toEqual([])
+    })
+  })
+
+  describe('removeFavorite(显式删除,幂等)', () => {
+    test('移除已收藏 → removed=true,本地删除', async () => {
+      writeFavorites(SCOPE, [101, 102])
+      removeIndicatorFavorite.mockReturnValue(ok({ definitionId: 101, removed: true }))
+
+      var res = await removeFavorite(SCOPE, 101)
+
+      expect(res.removed).toBe(true)
+      expect(res.ids).toEqual([102])
+      expect(removeIndicatorFavorite).toHaveBeenCalledWith({ memberId: 1, sourceType: 'gut_flora', definitionId: 101 })
+    })
+
+    test('移除不存在的项(后端幂等)→ removed=true,本地不变', async () => {
+      writeFavorites(SCOPE, [102])
+      removeIndicatorFavorite.mockReturnValue(ok({ definitionId: 999, removed: true }))
+
+      var res = await removeFavorite(SCOPE, 999)
+
+      expect(res.removed).toBe(true)
+      expect(res.ids).toEqual([102])
+    })
+
+    test('后端失败 → 回滚本地,removed=false', async () => {
+      writeFavorites(SCOPE, [101])
+      removeIndicatorFavorite.mockReturnValue(Promise.reject(new Error('network')))
+
+      var res = await removeFavorite(SCOPE, 101)
+
+      expect(res.removed).toBe(false)
+      expect(res.ids).toEqual([101])
+      expect(global.uni._storage[buildFavoriteKey(SCOPE)]).toEqual([101])
+    })
+
+    test('定义 id 非法 → 不调后端', async () => {
+      writeFavorites(SCOPE, [101])
+
+      var res = await removeFavorite(SCOPE, 'x')
+
+      expect(res.removed).toBe(false)
+      expect(res.ids).toEqual([101])
+      expect(removeIndicatorFavorite).not.toHaveBeenCalled()
+    })
+  })
+})

+ 60 - 4
cfc-frontend/pages/health/indicator-timeline.vue

@@ -18,14 +18,20 @@
         v-for="def in indicatorOptions"
         v-for="def in indicatorOptions"
         :key="getIndicatorKey(def)"
         :key="getIndicatorKey(def)"
         @tap="onIndicatorTap(getIndicatorId(def))"
         @tap="onIndicatorTap(getIndicatorId(def))"
-      >{{ getIndicatorName(def) }}</view>
+      >
+        <text class="chip-star" v-if="isFavoriteDef(getIndicatorId(def))">★</text>
+        <text>{{ getIndicatorName(def) }}</text>
+      </view>
     </scroll-view>
     </scroll-view>
 
 
     <!-- ② 指标头部卡 -->
     <!-- ② 指标头部卡 -->
     <view class="head-card" v-if="hasItem">
     <view class="head-card" v-if="hasItem">
       <view class="head-row">
       <view class="head-row">
         <text class="head-name">{{ item.name }}</text>
         <text class="head-name">{{ item.name }}</text>
-        <text class="status-tag" :class="latestMeta._cssClass">{{ latestMeta._statusText }}</text>
+        <view class="head-side">
+          <text class="fav-btn" :class="currentFavClass" @tap="onToggleFavorite(item.definitionId)">{{ currentFavText }}</text>
+          <text class="status-tag" :class="latestMeta._cssClass">{{ latestMeta._statusText }}</text>
+        </view>
       </view>
       </view>
       <view class="head-value-row">
       <view class="head-value-row">
         <text class="head-value">{{ latestValueText }}</text>
         <text class="head-value">{{ latestValueText }}</text>
@@ -148,6 +154,7 @@
 import { getFamilyMemberList, getIndicatorTimeline, getIndicatorSourceTypes, getIndicatorChangeRanking } from '@/utils/api'
 import { getFamilyMemberList, getIndicatorTimeline, getIndicatorSourceTypes, getIndicatorChangeRanking } from '@/utils/api'
 import { resolveChange, formatCompareDate } from '@/utils/indicator-change.js'
 import { resolveChange, formatCompareDate } from '@/utils/indicator-change.js'
 import { consumeChartMode, buildAllPeriodDates, buildPeriodRows, formatPeriodDate, dateWithSourceMark, normalizeDate } from '@/utils/indicator-timeline.js'
 import { consumeChartMode, buildAllPeriodDates, buildPeriodRows, formatPeriodDate, dateWithSourceMark, normalizeDate } from '@/utils/indicator-timeline.js'
+import { readFavorites, toggleFavorite } from '@/utils/indicator-favorite.js'
 import IndicatorIncompleteBanner from '@/pages/health/components/IndicatorIncompleteBanner.vue'
 import IndicatorIncompleteBanner from '@/pages/health/components/IndicatorIncompleteBanner.vue'
 import IndicatorDegrade from '@/pages/health/components/IndicatorDegrade.vue'
 import IndicatorDegrade from '@/pages/health/components/IndicatorDegrade.vue'
 import TrendChart from '@/pages/health/components/TrendChart.vue'
 import TrendChart from '@/pages/health/components/TrendChart.vue'
@@ -171,7 +178,8 @@ export default {
       granularity: 'raw',
       granularity: 'raw',
       _hasLeftPage: false,
       _hasLeftPage: false,
       _pendingEntry: false,
       _pendingEntry: false,
-      reportId: null
+      reportId: null,
+      favoriteIds: []
     }
     }
   },
   },
   computed: {
   computed: {
@@ -316,6 +324,18 @@ export default {
     },
     },
     hasUnitMismatch: function() {
     hasUnitMismatch: function() {
       return this.item && this.item.unitMismatch === true
       return this.item && this.item.unitMismatch === true
+    },
+    /** 单指标头部卡收藏按钮态(预计算,:class 不调用带参方法) */
+    currentFavClass: function() { return this.isFavoriteDef(this.definitionId) ? 'fav-on' : '' },
+    currentFavText: function() { return this.isFavoriteDef(this.definitionId) ? '★ 已关注' : '☆ 关注' },
+    /** 收藏持久化隔离维度:用户×家庭×成员×报告类型(避免串号) */
+    favoriteScope: function() {
+      return {
+        userId: uni.getStorageSync('userId'),
+        familyId: uni.getStorageSync('familyId'),
+        memberId: this.currentMemberId,
+        sourceType: this.activeReportType
+      }
     }
     }
   },
   },
   onLoad: function(options) {
   onLoad: function(options) {
@@ -348,7 +368,7 @@ export default {
     onMemberChange: function(e) {
     onMemberChange: function(e) {
       var idx = Number(e.detail.value)
       var idx = Number(e.detail.value)
       var m = this.members[idx]
       var m = this.members[idx]
-      if (m) { this.currentMemberId = m.id; this.reloadAll() }
+      if (m) { this.currentMemberId = m.id; this.favoriteIds = []; this.reloadAll() }
     },
     },
     loadSourceTypes: function() {
     loadSourceTypes: function() {
       var self = this
       var self = this
@@ -376,6 +396,7 @@ export default {
     },
     },
     onReportTypeSelected: function() {
     onReportTypeSelected: function() {
       this.loadIndicatorOptions()
       this.loadIndicatorOptions()
+      this.loadFavorites()
       this.loadTimeline()
       this.loadTimeline()
     },
     },
     loadIndicatorOptions: function() {
     loadIndicatorOptions: function() {
@@ -463,6 +484,35 @@ export default {
     /** 重载全部数据(切成员 / 下拉刷新) */
     /** 重载全部数据(切成员 / 下拉刷新) */
     reloadAll: function() {
     reloadAll: function() {
       this.loadSourceTypes()
       this.loadSourceTypes()
+    },
+    /** 收藏持久化(方案A:后端优先 + 本地降级读,异步) */
+    isFavoriteDef: function(definitionId) {
+      var id = Number(definitionId)
+      if (isNaN(id)) return false
+      return this.favoriteIds.indexOf(id) >= 0
+    },
+    /** 异步加载收藏(后端优先,失败降级本地);视角已切换则丢弃过期响应 */
+    loadFavorites: function() {
+      var self = this
+      var scope = this.favoriteScope
+      readFavorites(scope).then(function(ids) {
+        if (!self._isSameFavoriteScope(scope)) return
+        self.favoriteIds = ids || []
+      })
+    },
+    /** 收藏/取消(异步 + 乐观更新,失败由 toggleFavorite 内部回滚) */
+    onToggleFavorite: function(definitionId) {
+      var self = this
+      var scope = this.favoriteScope
+      toggleFavorite(scope, definitionId).then(function(res) {
+        if (!self._isSameFavoriteScope(scope)) return
+        self.favoriteIds = res.ids || []
+      })
+    },
+    /** 判断异步返回时的视角是否仍是发起请求时的视角(防串号/串态) */
+    _isSameFavoriteScope: function(scope) {
+      var cur = this.favoriteScope
+      return Number(cur.memberId) === Number(scope.memberId) && cur.sourceType === scope.sourceType
     }
     }
   }
   }
 }
 }
@@ -539,4 +589,10 @@ export default {
 /* ---- ⑥ 合规 footer ---- */
 /* ---- ⑥ 合规 footer ---- */
 .footer { padding: 24rpx 0; }
 .footer { padding: 24rpx 0; }
 .footer-text { font-size: calc(22rpx * var(--font-scale, 1)); color: #9ca3af; text-align: center; line-height: 1.6; }
 .footer-text { font-size: calc(22rpx * var(--font-scale, 1)); color: #9ca3af; text-align: center; line-height: 1.6; }
+
+/* ---- 指标收藏(方案A) ---- */
+.chip-star { color: #F59E0B; font-size: calc(22rpx * var(--font-scale, 1)); margin-right: 6rpx; }
+.head-side { display: flex; flex-direction: row; align-items: center; gap: 12rpx; }
+.fav-btn { font-size: calc(22rpx * var(--font-scale, 1)); padding: 6rpx 14rpx; border-radius: 20rpx; color: #6b7280; background: #f3f4f6; }
+.fav-btn.fav-on { color: #b45309; background: #fef3c7; font-weight: 600; }
 </style>
 </style>

+ 11 - 0
cfc-frontend/utils/api.js

@@ -3514,3 +3514,14 @@ export const getContentRelated = (data) => request('/api/content/related-items',
 
 
 // 商品浏览停留时长埋点上报(支撑运营看板「商品停留时长 Top」)
 // 商品浏览停留时长埋点上报(支撑运营看板「商品停留时长 Top」)
 export const reportProductDwell = (data) => request('/api/stats/product-view', 'POST', data || {})
 export const reportProductDwell = (data) => request('/api/stats/product-view', 'POST', data || {})
+
+// ============ 指标收藏(T04 方案A:后端持久化,跨端同步)============
+// 契约见 specs/2026-10-30-indicator-favorite-backend-design.md §4
+// 一律 _skipDedup(toggle 是写操作,3 秒去重窗口会把快速双击第二次吞掉/回放旧结果)
+// + _noToast(收藏是增强功能,失败由 indicator-favorite.js 静默降级,不弹 toast 打断主流程)
+/** 收藏/取消:{ memberId, sourceType, definitionId } → { definitionId, added } */
+export const toggleIndicatorFavorite = (params) => request('/api/indicator/favorite/toggle', 'POST', params || {}, { _skipDedup: true, _noToast: true })
+/** 已收藏集合:{ memberId, sourceType } → { memberId, sourceType, definitionIds:[] } */
+export const listIndicatorFavorites = (params) => request('/api/indicator/favorite/list', 'POST', params || {}, { _skipDedup: true, _noToast: true })
+/** 移除收藏:{ memberId, sourceType, definitionId } → { definitionId, removed } */
+export const removeIndicatorFavorite = (params) => request('/api/indicator/favorite/remove', 'POST', params || {}, { _skipDedup: true, _noToast: true })

+ 215 - 0
cfc-frontend/utils/indicator-favorite.js

@@ -0,0 +1,215 @@
+/* ==============================================================
+ * 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<number[]>}
+ */
+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 }
+  })
+}