diff --git a/common/js/mp-share-default.js b/common/js/mp-share-default.js new file mode 100644 index 0000000..2b37d25 --- /dev/null +++ b/common/js/mp-share-default.js @@ -0,0 +1,201 @@ +/** + * 全局兜底分享 mixin + * + * 仅在微信小程序编译产物中被加载: + * main.js 中的 import + Vue.mixin 调用被 // #ifdef MP-WEIXIN 包住, + * 其它平台编译时整个模块不会进入产物,因此文件内部无需再写条件编译。 + * + * 背景: + * - 微信小程序要求每个要支持「转发 / 朋友圈」的页面显式声明 onShareAppMessage / onShareTimeline, + * 否则页面右上角"三个点"菜单不会显示"转发"按钮。 + * - 本项目之前只有 ~17 个页面声明了分享钩子,剩余页面无法转发。 + * - 业务对"全页面都支持转发 + 复制小程序路径"有强诉求。 + * + * 设计原则: + * 1. 不侵入现有页面:所有页面通过 Vue.mixin 自动获得默认行为 + * 2. 已有 onShareAppMessage / onShareTimeline 的页面优先级更高(uni-app 会优先用页面自身的钩子) + * 3. 默认分享内容由后端 /weapp/api/weapp/share 按当前路由下发, + * 拉取失败时退化到站点名 + 当前路径,避免完全无法分享 + * 4. 暴露 $mpShare 工具方法(currentPath / copyPath / refresh), + * 方便子页面在 onShareAppMessage 中复用 path + */ + +// #ifdef MP-WEIXIN +const _safeGetCurrentPage = () => { + try { + const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : []; + return pages.length > 0 ? pages[pages.length - 1] : null; + } catch (e) { + return null; + } +}; + +const _getCurrentPath = (withQuery = true) => { + const page = _safeGetCurrentPage(); + if (!page) return ''; + // page.route 在 mp-weixin 上不带前导斜杠、不带 query;page.options 携带 query + const route = page.route || (page.$page && page.$page.path) || ''; + const options = page.options || {}; + if (!withQuery) return route ? '/' + route.replace(/^\//, '') : ''; + const query = Object.keys(options) + .filter(k => options[k] !== undefined && options[k] !== null && options[k] !== '') + .map(k => `${encodeURIComponent(k)}=${encodeURIComponent(options[k])}`) + .join('&'); + return query ? `/${route}?${query}` : `/${route}`; +}; + +// 模块级缓存:避免重复请求后端 +let _shareCache = null; +let _shareCacheAt = 0; +const SHARE_CACHE_TTL = 60 * 1000; // 60s + +const _fetchShareFromServer = (path) => { + const Http = require('./http.js').default || require('./http.js'); + return new Promise((resolve) => { + Http.sendRequest({ + url: '/weapp/api/weapp/share', + data: { path }, + success: (res) => { + if (res && res.code >= 0 && res.data && res.data.data) { + resolve(res.data.data); + } else { + resolve(null); + } + }, + fail: () => resolve(null) + }); + }); +}; + +const _buildFallback = (path) => ({ + title: '小程序分享', + path, + imageUrl: '' +}); + +const _getShareConfig = async (path) => { + const now = Date.now(); + if (_shareCache && (now - _shareCacheAt) < SHARE_CACHE_TTL) { + return _shareCache; + } + const cfg = await _fetchShareFromServer(path); + _shareCache = cfg || _buildFallback(path); + _shareCacheAt = now; + return _shareCache; +}; + +export default { + data() { + return { + __mpShareCache: null, // 当前页面的分享配置 + }; + }, + onLoad() { + try { + const path = _getCurrentPath(true); + _getShareConfig(path).then(cfg => { + // 后端未返回配置时,注入站点名作为默认标题 + if (cfg && !cfg.title) { + const siteName = (this.$store && this.$store.state && this.$store.state.siteInfo && this.$store.state.siteInfo.site_name) || ''; + cfg.title = siteName || '小程序分享'; + } + this.__mpShareCache = cfg; + }); + } catch (e) {} + }, + onShow() { + // 兜底:进入页面后如果尚未就绪,再异步拉一次 + if (!this.__mpShareCache) { + try { + const path = _getCurrentPath(true); + _getShareConfig(path).then(cfg => { + this.__mpShareCache = cfg; + }); + } catch (e) {} + } + }, + /** + * 全局默认 onShareAppMessage: + * uni-app 规范:组件自身的钩子优先级 > mixin,因此已显式声明的页面不受影响。 + */ + onShareAppMessage() { + const cfg = this.__mpShareCache; + if (cfg && cfg.title) { + return { + title: cfg.title, + path: cfg.path, + imageUrl: cfg.imageUrl || '' + }; + } + // 后端无配置时:使用站点名 + 当前路径 + return { + title: _buildFallback(_getCurrentPath(true)).title, + path: _getCurrentPath(true) + }; + }, + /** + * 全局默认 onShareTimeline(朋友圈): + * 朋友圈只支持 query(不能直接传 path),从 cfg.path 中抽取 ? 之后的 query。 + */ + onShareTimeline() { + const cfg = this.__mpShareCache; + if (cfg && cfg.title) { + const path = cfg.path || _getCurrentPath(true); + const query = path.indexOf('?') > 0 ? path.split('?')[1] : path; + return { + title: cfg.title, + query, + imageUrl: cfg.imageUrl || '' + }; + } + const path = _getCurrentPath(true); + const query = path.indexOf('?') > 0 ? path.split('?')[1] : ''; + return { + title: _buildFallback(path).title, + query + }; + }, + methods: { + /** + * 获取当前小程序页面路径(含 query) + */ + $mpShare_currentPath() { + return _getCurrentPath(true); + }, + /** + * 刷新当前页分享配置(用于页面参数变更后想重置转发 path 的场景) + */ + $mpShare_refresh() { + _shareCache = null; + _shareCacheAt = 0; + return _getShareConfig(_getCurrentPath(true)).then(cfg => { + this.__mpShareCache = cfg; + return cfg; + }); + }, + /** + * 复制当前页面小程序路径到剪贴板 + * 微信小程序没有"页面 URL"概念,仅可复制形如: + * pages/goods/detail?id=123&source_member=xx + * 的小程序内部路径,便于分享者在聊天里粘贴。 + * 优先复用 util.copyCurrentPath。 + */ + $mpShare_copyPath() { + const path = _getCurrentPath(true); + const text = path.replace(/^\//, ''); // 去掉前导斜杠 + if (this.$util && typeof this.$util.copyCurrentPath === 'function') { + return this.$util.copyCurrentPath({}); + } + // util.copyCurrentPath 不存在(极端情况:util 模块未加载)时直接调 uni API + return new Promise((resolve, reject) => { + uni.setClipboardData({ + data: text, + success: () => resolve(text), + fail: (err) => reject(err) + }); + }); + } + } +}; + +// #endif \ No newline at end of file diff --git a/common/js/util.js b/common/js/util.js index 41ec82f..eed9cd6 100644 --- a/common/js/util.js +++ b/common/js/util.js @@ -6,6 +6,7 @@ import Lang from './lang.js' import { Weixin } from 'common/js/wx-jssdk.js'; +import { copyText } from './uniapp.utils.js'; // export function decodeEntity(str, amp) { @@ -1735,6 +1736,28 @@ export default { } }) }, + /** + * 复制当前页面的小程序路径(含 query)到剪贴板(仅微信小程序) + * 小程序本身没有"页面 URL"概念,仅可复制形如 pages/goods/detail?id=123 的内部路径, + * 便于分享者在聊天里粘贴给对方识别。 + * 内部复用 common/js/uniapp.utils.js 导出的 copyText(回调形式),为保持 Promise 一致性包裹一层。 + * @param {Object} options { copySuccess: '', copyFailed: '' } + * @returns {Promise} 返回实际复制的文本 + */ + // #ifdef MP-WEIXIN + copyCurrentPath(options = {}) { + const { copySuccess = '', copyFailed = '' } = options; + const route = this.getCurrentRoute(); + // 去掉前导斜杠,保持与转发 path 兼容 + const text = (route && route.path ? route.path : '').replace(/^\//, ''); + return new Promise((resolve, reject) => { + copyText(text, { copySuccess, copyFailed }, (success, err) => { + if (success) resolve(text); + else reject(err || new Error('复制失败')); + }); + }); + }, + // #endif //获取当前路由 getCurrentRoute() { let currentRoutes = getCurrentPages(); // 获取当前打开过的页面路由数组 diff --git a/main.js b/main.js index bc6638b..c85bce8 100644 --- a/main.js +++ b/main.js @@ -41,6 +41,16 @@ Vue.prototype.$eventBus = EventBus; Vue.mixin(globalConfig); +// #ifdef MP-WEIXIN +// 全局兜底分享 mixin: +// 为所有未显式声明 onShareAppMessage / onShareTimeline 的页面提供默认实现, +// 保证"全页面都支持转发 / 朋友圈 / 复制路径"。 +// - 已显式声明的页面优先级更高(uni-app 规范),不受影响 +// - 后端 /weapp/api/weapp/share 提供标题 / 封面,失败时退化到站点名 + 当前路径 +import MpShareDefault from './common/js/mp-share-default.js'; +Vue.mixin(MpShareDefault); +// #endif + // 远端 i18n 翻译热更新:复用项目原有"componentRefresh"机制(同 diy.js 下拉刷新) // lang.js 在翻译覆写更新时 commit('setComponentRefresh'), // golbalConfig.js 已把 componentRefresh 注册为全局 computed,