// +---------------------------------------------------------------------- // | 分享卡片: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_v3'; // 反查表本地缓存 { t, items } // (v2:数据源从上架表换成含下架的反查表;v3:每行新增 shareTitle) // bump key 让旧缓存立即失效,别等 1h 才生效 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 上报的优先,其次 box_app 反查) shareTitleSource: '', // 当前标题来源:'box'(box_app 反查)| 'h5'(H5 postMessage 覆盖) shareSummary: '', // 分享摘要(部分场景用) shareHref: '', // H5 指定的分享链接(兜底;深链优先 webViewUrl) shareImageUrl: '', // 好友转发卡片图(网络 https) shareTimelineImage: '', // 朋友圈卡片图(必须本地路径,见 _prepareTimelineImage) shareImageSource: '', // 当前分享图来源:'box'(box_app 反查)| 'h5'(H5 postMessage 覆盖) shareImageBase: '', // 当前分享数据所属的 H5 URL 基(切换应用时用它丢弃上一应用的图/标题) shareApps: [], // box_app 反查表(url → shareImageUrl / shareTitle),供同步匹配 shareAppsLoaded: false, // 应用表是否已就绪 shareAppsLoading: false,// 反查表请求中(防并发重入) _shareAppsCbs: [], // 请求中的回调队列(请求结束统一回调) _shareFetched: false, // 本页是否已做过一次异步兜底反查 _shareTargetUrl: '', // 最近一次要分享的 H5 地址(反查表就绪后按它补解析) }; }, 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.shareTitleSource = ''; 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 / onShareTimeline 都调它): * ① 应用切换检测 → 换应用先清空; * ② 每次都按"当前 H5 地址"重新反查 box_app 的分享图 + 分享标题 * (不能因为已有值就跳过 —— 那可能是上一个应用的); * ③ H5 自己上报过标题/图(source='h5')的,保留不覆盖。 */ resolveShareCard(targetUrl) { // 进入本次解析时的归属基:换应用时 syncShareCardTarget 会清掉标题, // 用它区分「同一应用的标题」与「上一个应用遗留的标题」 const baseAtEntry = this.shareImageBase; 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 = ''; } // ---- 分享标题(box_app 反查,服务端已把「后台分享标题为空」回退成应用名) ---- const boxTitle = this._matchAppShareTitle(targetUrl); if (boxTitle) { if (this.shareTitleSource !== 'h5') { this.shareTitle = boxTitle; this.shareTitleSource = 'box'; } } else if (this.shareTitleSource === 'box' && this.shareImageBase && this.shareImageBase !== baseAtEntry) { // 换到了反查表里查不到的应用,而现有标题是上一个应用反查来的 → 丢弃。 // ⚠️ 同一地址不清:太阳码场景的标题来自 /api/mp/apps(不在反查表里也能有标题)。 this.shareTitle = ''; this.shareTitleSource = ''; } if (!this.shareAppsLoaded) this._loadShareApps(); }, /* 在内存应用表里按 URL 前缀匹配 shareImageUrl(同步,分享那一刻就能出图) */ _matchAppShareImage(targetUrl) { const app = this._matchShareApp(targetUrl); return (app && app.shareImageUrl) || ''; }, /* 在内存应用表里按 URL 前缀匹配分享标题(同步;服务端已回退应用名,空串表示没查到) */ _matchAppShareTitle(targetUrl) { const app = this._matchShareApp(targetUrl); return (app && app.shareTitle) || ''; }, /** * 用「已经拿到的应用对象」直接设标题(太阳码 scene 路径:/api/mp/apps 一次就带回 * shareTitle / name,不必再等反查表)。 * 后台没配 shareTitle 时回退应用名称 —— 与反查表服务端同口径。 * @param app 应用对象(含 shareTitle / name / url) */ _applyShareTitleFromApp(app) { if (!app) return; const t = app.shareTitle || app.name || ''; if (!t) return; this.shareTitle = t; this.shareTitleSource = 'box'; }, /** * 反查表里按 URL 前缀找当前应用(长前缀优先 —— 服务端已按 url 长度倒序下发, * 防「/a 是 /a/b 的前缀」时张冠李戴)。图与标题共用同一条记录,避免两次匹配漂移。 */ _matchShareApp(targetUrl) { const base = this._shareUrlBase(targetUrl); if (!base) return null; const apps = this.shareApps || []; for (let i = 0; i < apps.length; i++) { const app = apps[i]; if (!app || !app.url) 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; } return null; }, /* 同步查图:优先内存应用表(onLoad 预热,1h 缓存),再退单应用缓存(24h TTL) */ _lookupShareImage(targetUrl) { const base = this._shareUrlBase(targetUrl); if (!base) return ''; // 1) 内存应用表:后台改图后最迟 1h 生效 const fromList = this._matchAppShareImage(targetUrl); if (fromList) { // 顺手写单应用缓存:冷启动时表还没回来也能立刻出图 try { uni.setStorageSync(APP_IMG_CACHE_PREFIX + base, { t: Date.now(), url: fromList }); } catch (e) {} 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 ''; }, /* 拉 box_app 分享卡片反查表(url → 分享图/标题,带 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) 请求中:把回调排队,请求结束统一回调(避免并发重复拉,也别丢调用方) if (this.shareAppsLoading) { if (cb) this._shareAppsCbs.push(cb); return; } this.shareAppsLoading = true; if (cb) this._shareAppsCbs.push(cb); const finish = () => { this.shareAppsLoading = false; const cbs = this._shareAppsCbs || []; this._shareAppsCbs = []; cbs.forEach(fn => { try { fn(); } catch (e) {} }); }; 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) {} } finish(); }, fail: (err) => { // 常见原因:mp.yizhezhe.com 未加入小程序后台 request 合法域名白名单 console.warn('[shareCard] 反查表拉取失败(请检查 mp.yizhezhe.com 是否在 request 合法域名):', err); finish(); } }); }, /* 异步兜底反查(容器页 onLoad 用):同步没命中就拉反查表再匹配一次 */ _fetchShareImage(targetUrl) { if (!targetUrl) return; this._shareTargetUrl = targetUrl; // 拉表回来后按它补解析分享图/标题 this.syncShareCardTarget(targetUrl); // 建立/校正"当前数据属于哪个应用" // 反查表还没预热就先拉(标题/图都靠它;拉回来自己会补解析) if (!this.shareAppsLoaded) this._loadShareApps(() => this._syncShareCard(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(() => { this._syncShareCard(targetUrl); if (this.shareImageUrl) console.log('[shareCard] 分享图片(兜底): ' + this.shareImageUrl); }); }, /* 反查表就绪后补解析(onLoad 阶段表还没回来时用;已就绪则走 resolveShareCard) */ _syncShareCard(targetUrl) { const url = targetUrl || this._shareTargetUrl; if (!url) return; this.resolveShareCard(url); }, /* 统一设置分享图:好友转发用网络图;朋友圈另转本地图(见下) @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 = ''; } }); } } };