3 Commits

Author SHA1 Message Date
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
box-tool-f6b714fa
8543447df2 feat(link_view): 取身份票据 + 接收 H5 分享卡片信息(v5,2026-11-12)
- 打开前 wx.login → box_app /api/mp/public/link-ticket 换票据,web-view 打开 <地址>#ticket=<票据>;
  取票失败或 3 秒超时则打开不带票据的地址(H5 照旧公众号授权)。取票期间显示「正在进入…」,src 只设一次。
- 接收 H5 postMessage 的 {action:'share', imageUrl, title}:转发卡片用 H5 给的图(仅 https)和标题,
  没收到时退回原默认标题和微信截图。转发路径仍只带 t/k,不带票据。
- 规格:box_app docs/h5-auth-standard.md 第八节;链接/福利系统 docs/mp-share-card.md。

Co-Authored-By: AI 助手
2026-11-11 18:50:47 +00:00
53a43ea18c chore: .gitignore 忽略本地备份文件(*.bak* / *.origin-bak) 2026-11-11 11:30:17 +08:00
2 changed files with 96 additions and 11 deletions

4
.gitignore vendored
View File

@@ -28,3 +28,7 @@ project.config.json
# Android 签名证书(私钥,禁止入库;见 manifest.json app-plus.distribute.android.keystore)
keystore/
# 本地备份文件(历史页面/配置快照),不入库
*.bak*
*.origin-bak

View File

@@ -7,12 +7,17 @@
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),也不反查分享图/标题:两家都走自己的
公众号网页授权(方案第五节),所以本页**不引入** webviewHost / shareCard mixin。
⚠️ 不在网址里放明文 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):
@@ -20,7 +25,8 @@
-->
<template>
<view>
<web-view v-if="url" :src="url"></web-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>
@@ -34,8 +40,8 @@
/* 目标表(方案第三节):短键 → 地址前缀 + 标识规则。
base 写死在小程序里:换域名或新增系统要发版过审,这一点在方案第八节已知。 */
const TARGETS = {
l: { base: 'https://wenda.douba.top/s/', re: /^[0-9A-Za-z]{1,30}$/, title: '兜爸心选 · 好物推荐' },
f: { base: 'https://fuli.douba.top/a/', re: /^[a-z0-9-]{1,30}$/, title: '兜爸心选 · 福利活动' },
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"),个别机型/版本还会二次编码。
@@ -87,12 +93,51 @@
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) {
@@ -106,19 +151,54 @@
}
this.t = hit.t
this.k = hit.k
this.url = hit.url
console.log('[link-view] scene=' + (opt.scene || '') + ' 短键=' + hit.t +
' 标识=' + hit.k + ' → ' + hit.url)
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' }
return {
title: TARGETS[this.t].title,
path: '/pages/annex/link_view/index?t=' + this.t + '&k=' + this.k,
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' })
},
@@ -134,4 +214,5 @@
.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>