- 含首页壳/容器页/授权页/分享图/首页配置等此前未入库的在途改动 - 新增未跟踪的 pages/index/mall.vue(第 5 个 tabBar 页,pages.json 已引用) - 排除本地备份文件(*.bak* / *.origin-bak) Co-authored-by: Cursor <cursoragent@cursor.com>
307 lines
15 KiB
JavaScript
307 lines
15 KiB
JavaScript
// +----------------------------------------------------------------------
|
||
// | 分享卡片: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 = '';
|
||
}
|
||
});
|
||
}
|
||
}
|
||
};
|