Files
crmeb-mp/mixins/shareCard.js
Karlcarlo b45a190a59 chore: 入库生产基线(2026-10-26 工作树快照)
- 含首页壳/容器页/授权页/分享图/首页配置等此前未入库的在途改动
- 新增未跟踪的 pages/index/mall.vue(第 5 个 tabBar 页,pages.json 已引用)
- 排除本地备份文件(*.bak* / *.origin-bak)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-26 17:07:49 +08:00

307 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// +----------------------------------------------------------------------
// | 分享卡片:box_app 后台配置的分享图 + H5 postMessage 覆盖
// +----------------------------------------------------------------------
// 背景(详见 crmeb/docs/分享图片获取逻辑.md):
// · web-view 里的 H5 不能自己拉起分享面板,卡片只能由承载 <web-view> 的页面产出;
// · 分享图有两个来源,优先级:
// ① 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 = '';
}
});
}
}
};