// +---------------------------------------------------------------------- // | 分享卡片:box_app 后台配置的分享图 + H5 postMessage 覆盖 // +---------------------------------------------------------------------- // 背景(详见 crmeb/docs/分享图片获取逻辑.md): // · web-view 里的 H5 不能自己拉起分享面板,卡片只能由承载 的页面产出; // · 分享图有两个来源,优先级: // ① box_app App 表的 shareImageUrl(按当前 H5 的 URL 前缀反查) // ② H5 postMessage({action:'share', imageUrl})(有值才覆盖 ①) // · 有两个宿主页都需要这套逻辑: // pages/annex/web_view/index.vue —— 容器页(太阳码 / 分享落地 / JumpPath 进入) // pages/index/index.vue —— 首页壳(mama-box 应用宫格用 location.href 换 H5, // 小程序页面栈不变,所以这里的分享永远发生在首页壳) // 本 mixin 把「反查 + 缓存 + 朋友圈本地图转换」抽出来共用,避免两边逻辑漂移。 // // 用法: // ① import shareCard from '@/mixins/shareCard.js' → mixins: [shareCard] // ② 页面 onLoad 调一次 this._loadShareApps()(预热全量应用表,分享时可同步命中) // ③ onShareAppMessage(options) / 分享前: // const target = (options && options.webViewUrl) || this.shareHref || this.webUrl; // this.resolveShareCard(target); // 应用切换检测 + 每次按当前地址重解析(同步出图) // return { ..., imageUrl: this.shareImageUrl || '' }; // // ⚠️ 首页壳不会随 H5 应用切换重新 onLoad,所以: // · 必须缓存「全量应用表」,在分享那一刻用 options.webViewUrl 同步匹配; // · **不能把 shareImageUrl 当长期状态**:每次分享都要重新解析,并检测 target 是否换了应用, // 否则 B 应用会沿用 A 应用的图(resolveShareCard / syncShareCardTarget 就是干这个的)。 const BOX_API_BASE = 'https://mp.yizhezhe.com'; // 分享图反查表:box_app 专用接口,**含未上架应用**(mama-box 没有上架记录、laixin // 已下架但仍经底 tab/来信卡进入,它们也要有自定义转发图)。 // 与「宫格展示」用的 /api/mp/apps?isPublished=1 刻意解耦:上架状态只决定宫格显隐, // 不决定转发图 —— 反查只看 URL 前缀(接口只回 url+shareImageUrl,长前缀优先)。 const APPS_API = BOX_API_BASE + '/api/mp/public/share-images'; const APPS_CACHE_KEY = 'share_apps_index_v2'; // 反查表本地缓存 { t, items } // (v2:数据源从上架表换成含下架的反查表,bump key 让旧缓存立即失效) const APP_IMG_CACHE_PREFIX = 'web_view_share_img:'; // 单个应用分享图缓存(按 URL 基){ t, url } const APPS_CACHE_TTL = 60 * 60 * 1000; // 应用表缓存 1 小时 const APP_IMG_CACHE_TTL = 24 * 60 * 60 * 1000; // 单应用分享图缓存 24 小时(后台改图最迟一天生效) export default { data() { return { // ---------- 分享(H5 无法自己拉起分享面板,只能由承载 web-view 的页面转发) ---------- shareTitle: '', // H5 postMessage(action:'share') 自定义标题 shareSummary: '', // 分享摘要(部分场景用) shareHref: '', // H5 指定的分享链接(兜底;深链优先 webViewUrl) shareImageUrl: '', // 好友转发卡片图(网络 https) shareTimelineImage: '', // 朋友圈卡片图(必须本地路径,见 _prepareTimelineImage) shareImageSource: '', // 当前分享图来源:'box'(box_app 反查)| 'h5'(H5 postMessage 覆盖) shareImageBase: '', // 当前分享图所属的 H5 URL 基(切换应用时用它丢弃上一张) shareApps: [], // box_app 应用表(url → shareImageUrl),供同步匹配 shareAppsLoaded: false, // 应用表是否已就绪 shareAppsLoading: false,// 应用表请求中(防并发重入) _shareFetched: false, // 本页是否已做过一次异步兜底反查 }; }, methods: { /* 当前 H5 URL 去掉 query / hash / 末尾斜杠,作为应用 url 的匹配基 */ _shareUrlBase(url) { return String(url || '').split('#')[0].split('?')[0].replace(/\/+$/, ''); }, /* 清空分享卡片状态(应用切换时调用,避免张冠李戴) */ _clearShareCard() { this.shareImageUrl = ''; this.shareTimelineImage = ''; this.shareImageSource = ''; this.shareTitle = ''; this.shareSummary = ''; this.shareHref = ''; }, /** * 分享前调用:检测"当前要分享的 H5 是否换了一个应用"。 * 首页壳用 location.href 在 H5 内部换应用,小程序页面不重新 onLoad —— * 若不检测,上一个应用的分享图/标题/href 会一直留着(本次线上问题的根因)。 * @returns {string} 当前 target 的 URL 基 */ syncShareCardTarget(targetUrl) { const base = this._shareUrlBase(targetUrl); if (!base) return ''; if (this.shareImageBase && this.shareImageBase !== base) { // 换了应用:丢弃上一应用的分享数据 this._clearShareCard(); } this.shareImageBase = base; return base; }, /** * 分享前的统一解析(两个宿主页的 onShareAppMessage 都调它): * ① 应用切换检测 → 换应用先清空; * ② 每次都按"当前 H5 地址"重新反查 box_app 的 shareImageUrl * (不能因为 shareImageUrl 已有值就跳过 —— 那可能是上一个应用的图); * ③ 当前图若来自本应用 H5 的 postMessage(source='h5'),保留不覆盖。 */ resolveShareCard(targetUrl) { this.syncShareCardTarget(targetUrl); const fromBox = this._lookupShareImage(targetUrl); if (fromBox) { if (this.shareImageSource !== 'h5') this._applyShareImage(fromBox, 'box', targetUrl); } else if (this.shareImageSource === 'box') { // 当前应用没有 box_app 配置图,而现有图是上一个应用反查来的 → 丢弃 this.shareImageUrl = ''; this.shareImageSource = ''; this.shareTimelineImage = ''; } if (!this.shareAppsLoaded) this._loadShareApps(); }, /* 在内存应用表里按 URL 前缀匹配 shareImageUrl(同步,分享那一刻就能出图) */ _matchAppShareImage(targetUrl) { const base = this._shareUrlBase(targetUrl); if (!base) return ''; const apps = this.shareApps || []; for (let i = 0; i < apps.length; i++) { const app = apps[i]; if (!app || !app.url || !app.shareImageUrl) continue; const appBase = this._shareUrlBase(app.url); if (!appBase) continue; // 如 https://mp.yizhezhe.com/taidong == https://mp.yizhezhe.com/taidong/ if (base === appBase || base.indexOf(appBase + '/') === 0) return app.shareImageUrl; } return ''; }, /* 同步查图:优先内存应用表(onLoad 预热,1h 缓存),再退单应用缓存(24h TTL) */ _lookupShareImage(targetUrl) { const base = this._shareUrlBase(targetUrl); if (!base) return ''; // 1) 内存应用表:后台改图后最迟 1h 生效 const fromList = this._matchAppShareImage(targetUrl); if (fromList) return fromList; // 2) 单应用缓存兜底(应用表请求失败 / 冷启动还没回来时用) try { const cached = uni.getStorageSync(APP_IMG_CACHE_PREFIX + base); // 兼容旧格式(纯 URL 字符串,无 TTL) const rec = (cached && typeof cached === 'object') ? cached : { t: 0, url: cached }; if (rec && rec.url && String(rec.url).indexOf('http') === 0 && (!rec.t || (Date.now() - rec.t) < APP_IMG_CACHE_TTL)) { return rec.url; } } catch (e) {} return ''; }, /* 拉全量已上架应用(带 1h 本地缓存);cb 可选,拉完(或命中缓存)后回调 */ _loadShareApps(cb) { // 1) 本地缓存命中:冷启动也能同步出图 try { const cached = uni.getStorageSync(APPS_CACHE_KEY); if (cached && cached.items && cached.items.length && cached.t && (Date.now() - cached.t) < APPS_CACHE_TTL) { this.shareApps = cached.items; this.shareAppsLoaded = true; if (cb) cb(); return; } } catch (e) {} // 2) 请求中:直接返回,避免并发重复拉(漏掉的 cb 由调用方自身的兜底路径覆盖) if (this.shareAppsLoading) return; this.shareAppsLoading = true; uni.request({ url: APPS_API, method: 'GET', success: (res) => { const items = (res.data && res.data.result && res.data.result.items) || []; if (items.length) { this.shareApps = items; this.shareAppsLoaded = true; try { uni.setStorageSync(APPS_CACHE_KEY, { t: Date.now(), items: items }); } catch (e) {} } if (cb) cb(); }, fail: (err) => { // 常见原因:mp.yizhezhe.com 未加入小程序后台 request 合法域名白名单 console.warn('[shareCard] 应用表拉取失败(请检查 mp.yizhezhe.com 是否在 request 合法域名):', err); if (cb) cb(); }, complete: () => { this.shareAppsLoading = false; } }); }, /* 异步兜底反查(容器页 onLoad 用):同步没命中就拉应用表再匹配一次 */ _fetchShareImage(targetUrl) { if (!targetUrl) return; this.syncShareCardTarget(targetUrl); // 建立/校正"当前图属于哪个应用" if (this.shareImageUrl) return; // H5 postMessage 已给图,不覆盖 if (this._shareFetched) return; // 本页只兜底一次 this._shareFetched = true; const hit = this._lookupShareImage(targetUrl); if (hit) { this._applyShareImage(hit, 'box', targetUrl); return; } this._loadShareApps(() => { const matched = this._matchAppShareImage(targetUrl); if (!matched) return; try { uni.setStorageSync(APP_IMG_CACHE_PREFIX + this._shareUrlBase(targetUrl), { t: Date.now(), url: matched }); } catch (e) {} this._applyShareImage(matched, 'box', targetUrl); console.log('[shareCard] 分享图片(兜底): ' + matched); }); }, /* 统一设置分享图:好友转发用网络图;朋友圈另转本地图(见下) @param source 'box'(box_app 反查,默认)| 'h5'(H5 postMessage 覆盖) @param forUrl 该图所属的 H5 地址(用于切换应用时判断是否过期) */ _applyShareImage(url, source, forUrl) { if (!url) return; if (forUrl) this.shareImageBase = this._shareUrlBase(forUrl); if (url === this.shareImageUrl) { if (source) this.shareImageSource = source; return; // 同一张图不重复处理(H5 可能重复 postMessage) } this.shareImageUrl = url; this.shareImageSource = source || 'box'; this._prepareTimelineImage(url); }, /* 朋友圈分享图必须是本地路径/代码包路径(网络图会被微信静默忽略)→ getImageInfo 转本地 注意:getImageInfo 对网络图同样受 downloadFile 合法域名限制,域名未配时朋友圈无自定义图。 */ _prepareTimelineImage(url) { if (!url) return; uni.getImageInfo({ src: url, success: (res) => { this.shareTimelineImage = res.path || ''; console.log('[shareCard] 朋友圈分享图已转本地:', this.shareTimelineImage); }, fail: (err) => { console.warn('[shareCard] 朋友圈分享图转本地失败(网络图/域名问题,回退默认截图):', err); this.shareTimelineImage = ''; } }); } } };