Files
crmeb-mp/pages/annex/link_view/index.vue
box-tool-f6b714fa 9b41ba19fb fix(link_view): 转发路径跟随 H5 当前页面,避免「卡片是 A、点开是 B」
H5 的 share 消息可带 page/slug:
- page 是本目标能打开的页面(l→'s' 商品,f→'a' 活动)且 slug 合法 → 转发路径用该 slug;
- 其他页面(如链接系统集合页 'c',壳页打不开)→ 清空图和标题,退回入口页 + 默认标题;
- 不带 page/slug 的旧版消息 → 视为入口页信息(兼容)。

Co-Authored-By: AI 助手
2026-11-11 18:52:19 +00:00

219 lines
9.7 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
<!--
小程序专用 web-view 壳页(2026-11-04)—— 只给「链接系统 / 福利系统」的小程序码用
方案:box_app/docs/lianjie-wxacode-plan.md(v4 硬编码,第四节之一 4.2)
入口:
1) 扫小程序码:options.scene = "l=g8I0yMDYzlCu" / "f=zanxte3x"(微信会 URL 编码 scene)
2) 转发卡片: /pages/annex/link_view/index?t=l&k=g8I0yMDYzlCu
只做三件事:解析 scene 或转发参数 → 拼地址 → web-view 打开;解析不出来显示兜底屏。
2026-11-12 起再加两件(v5,box_app/docs/h5-auth-standard.md 第八节):
a) 打开前用 wx.login 换身份票据,拼成 <地址>#ticket=<票据>;取票失败或 3 秒超时 → 打开不带票据的地址(H5 照旧公众号授权)。
b) 接收 H5 postMessage 的 {action:'share', imageUrl, title},用作转发卡片的图和标题(docs/mp-share-card.md)。
⚠️ TARGETS 必须和 box_app backend/app/services/link_wxacode_serv.py 的同名表一致
(短键、地址前缀、标识规则)。改这张表 = 两端一起改 + 小程序发版;
码里只有标识,所以发版后旧码照常可用。
⚠️ 不在网址里放明文 openid/unionid:身份只走 hash 里的签名票据,由 H5 自己验签(影子模式期间仍以公众号授权为准)。
本页**不引入** webviewHost / shareCard mixin:分享图/标题只认 H5 发来的消息,不反查 box_app。
⚠️ 取票接口 https://mp.yizhezhe.com/api/mp/public/link-ticket:该域名已在小程序 request 合法域名里(shareCard 同用)。
⚠️ 转发路径只带 t/k,绝不带票据(票据一次性、带身份)。
⚠️ 本页路径必须等于 box_app 生产 .env 的 LINK_WXACODE_PAGE(默认就是这个路径),
改路径要同步改那边,否则已生成的码全部扫不进页面。
⚠️ web-view 打开的域名必须在小程序后台「业务域名」里(方案 4.4):
wenda.douba.top / fuli.douba.top / dbh5.ywsz.com.cn。
-->
<template>
<view>
<web-view v-if="url" :src="url" @message="onH5Message"></web-view>
<view v-else-if="loading" class="lv-loading">正在进入…</view>
<view v-else class="lv-fallback">
<view class="lv-emoji">🔗</view>
<view class="lv-title">链接已失效</view>
<view class="lv-desc">这个码可能已过期,或者链接已下架</view>
<button class="lv-btn" hover-class="lv-btn-hover" @tap="goHome">去首页</button>
</view>
</view>
</template>
<script>
/* 目标表(方案第三节):短键 → 地址前缀 + 标识规则。
base 写死在小程序里:换域名或新增系统要发版过审,这一点在方案第八节已知。 */
const TARGETS = {
l: { base: 'https://wenda.douba.top/s/', re: /^[0-9A-Za-z]{1,30}$/, title: '兜爸心选 · 好物推荐', page: 's' },
f: { base: 'https://fuli.douba.top/a/', re: /^[a-z0-9-]{1,30}$/, title: '兜爸心选 · 福利活动', page: 'a' },
}
/* 微信给小程序的 scene 是 URL 编码的("l%3Dslug"),个别机型/版本还会二次编码。
必须 try/catch:裸 '%' 会让 decodeURIComponent 抛错,直接冲掉 onLoad → 白屏。
解不开就用原文,后面正则校验会把它挡成兜底屏,绝不会把野值塞进 web-view。 */
function decodeScene(raw) {
let s = String(raw == null ? '' : raw)
for (let i = 0; i < 2 && s.indexOf('%') !== -1; i++) {
try {
const next = decodeURIComponent(s)
if (next === s) break
s = next
} catch (e) {
break
}
}
return s
}
/* "l=slug&ch=XX" → { l: 'slug', ch: 'XX' }
按第一个 '=' 切,值里再出现 '=' 也不会被截断。 */
function parseScene(raw) {
const out = {}
String(raw || '').split('&').forEach((kv) => {
const i = kv.indexOf('=')
if (i > 0) out[kv.slice(0, i)] = kv.slice(i + 1)
})
return out
}
/* 返回 { t, k, url };返回 null 表示走兜底屏。
优先级:显式 t/k(转发落地)> scene(扫码)——同一条码两种情况都要能用。 */
function resolveTarget(options) {
const opt = options || {}
let t = opt.t ? String(opt.t) : ''
let k = opt.k ? String(opt.k) : ''
if (!t && opt.scene) {
const sc = parseScene(decodeScene(opt.scene))
for (const key in TARGETS) {
if (sc[key]) {
t = key
k = sc[key]
break
}
}
}
const target = TARGETS[t]
if (!target || !k || !target.re.test(k)) return null
return { t, k, url: target.base + k }
}
/* 取身份票据:wx.login → box_app 签票。任何失败都 resolve(''),由调用方退回不带票据的地址。
返回体是本仓惯例 {code, message, result:{ticket, exp}},HTTP 恒 200。 */
const TICKET_API = 'https://mp.yizhezhe.com/api/mp/public/link-ticket'
const TICKET_TIMEOUT = 3000
function fetchTicket(t, k) {
const work = new Promise((resolve) => {
uni.login({
provider: 'weixin',
success(res) {
if (!res || !res.code) return resolve('')
uni.request({
url: TICKET_API,
method: 'POST',
data: { code: res.code, t, k },
timeout: TICKET_TIMEOUT,
success(r) {
const d = (r && r.data) || {}
const tk = d.code === 200 && d.result && d.result.ticket
if (!tk) console.warn('[link-view] 取票失败 code=' + d.code + ' ' + (d.message || ''))
resolve(typeof tk === 'string' ? tk : '')
},
fail(e) {
console.warn('[link-view] 取票请求失败', e && e.errMsg)
resolve('')
},
})
},
fail() { resolve('') },
})
})
const timer = new Promise((resolve) => setTimeout(() => resolve(''), TICKET_TIMEOUT))
return Promise.race([work, timer])
}
export default {
data() {
return {
url: '', // 为空 = 兜底屏(web-view 不渲染)
t: '', // 短键(分享时回填)
k: '', // 标识(分享时回填)
loading: false, // 正在取票据(不显示兜底屏)
shareImage: '', // H5 上报的转发卡片图(https)
shareTitle: '', // H5 上报的转发卡片标题
shareK: '', // H5 上报的当前页面标识:用户在 web-view 里跳到别的商品后,转发的是当前商品而不是入口商品
}
},
onLoad(options) {
const opt = options || {}
const hit = resolveTarget(opt)
if (!hit) {
// 真机排查用:一条日志区分「码/参数不对」和「域名没加业务域名」
console.warn('[link-view] 标识不合法,显示兜底屏: scene=' + (opt.scene || '') +
' t=' + (opt.t || '') + ' k=' + (opt.k || ''))
return
}
this.t = hit.t
this.k = hit.k
this.loading = true
// web-view 的 src 只设一次:拿到票据(或超时)后再渲染,避免页面加载两遍
fetchTicket(hit.t, hit.k).then((ticket) => {
this.loading = false
this.url = ticket ? hit.url + '#ticket=' + ticket : hit.url
console.log('[link-view] scene=' + (opt.scene || '') + ' 短键=' + hit.t +
' 标识=' + hit.k + ' → ' + hit.url + (ticket ? '(带票据)' : '(无票据)'))
})
},
onShareAppMessage() {
// 转发出去还是这个壳页:好友点开按 t/k 重新拼地址,不带上任何身份参数
if (!this.url) return { title: '兜爸心选', path: '/pages/index/index' }
const card = {
title: this.shareTitle || TARGETS[this.t].title,
path: '/pages/annex/link_view/index?t=' + this.t + '&k=' + (this.shareK || this.k),
}
// 没收到 H5 的图就不传 imageUrl,微信用页面截图
if (this.shareImage) card.imageUrl = this.shareImage
return card
},
methods: {
/* web-view 的 postMessage 只在转发、后退等时机批量送达,e.detail.data 是数组;按顺序处理,最后一条 share 生效。
消息:{action:'share', imageUrl, title, page, slug}
- 带 page/slug:page 必须是本目标能打开的页面(l→'s' 商品,f→'a' 活动)且 slug 合法,转发路径就用这个 slug;
否则(如链接系统集合页 'c',壳页打不开)清空图和标题,转发退回入口页 + 默认标题,避免「卡片是 A、点开是 B」。
- 不带 page/slug(H5 旧版):视为入口页的信息。 */
onH5Message(e) {
const raw = e && e.detail && e.detail.data
const list = Array.isArray(raw) ? raw : (raw ? [raw] : [])
const target = TARGETS[this.t]
list.forEach((m) => {
if (!m || m.action !== 'share' || !target) return
let k = ''
if (m.page != null || m.slug != null) {
const ok = m.page === target.page && typeof m.slug === 'string' && target.re.test(m.slug)
if (!ok) {
this.shareImage = ''
this.shareTitle = ''
this.shareK = ''
return
}
k = m.slug
}
this.shareK = k
this.shareImage = typeof m.imageUrl === 'string' && /^https:\/\//.test(m.imageUrl) ? m.imageUrl : ''
this.shareTitle = typeof m.title === 'string' && m.title.trim() ? m.title.trim().slice(0, 40) : ''
})
},
goHome() {
uni.switchTab({ url: '/pages/index/index' })
},
},
}
</script>
<style scoped>
/* 兜底屏沿用宿主页配色(pages/annex/web_view):#2B2320 主文字 / #A79C95 次要 / #F4593C 主按钮 */
.lv-fallback { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 70vh; padding: 0 40rpx; }
.lv-emoji { font-size: 64rpx; line-height: 1; }
.lv-title { margin-top: 24rpx; font-size: 32rpx; font-weight: 700; color: #2B2320; }
.lv-desc { margin-top: 12rpx; font-size: 26rpx; color: #A79C95; line-height: 1.6; text-align: center; }
.lv-btn { margin-top: 36rpx; padding: 0 48rpx; height: 80rpx; line-height: 80rpx; border-radius: 40rpx; background: #F4593C; color: #fff; font-size: 28rpx; }
.lv-btn-hover { opacity: .85; }
.lv-loading { display: flex; align-items: center; justify-content: center; min-height: 70vh; font-size: 26rpx; color: #A79C95; }
</style>