@@ -0,0 +1,218 @@
<!--
小程序专用 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 : 70 vh ; padding : 0 40 rpx ; }
. lv - emoji { font - size : 64 rpx ; line - height : 1 ; }
. lv - title { margin - top : 24 rpx ; font - size : 32 rpx ; font - weight : 700 ; color : # 2 B2320 ; }
. lv - desc { margin - top : 12 rpx ; font - size : 26 rpx ; color : # A79C95 ; line - height : 1.6 ; text - align : center ; }
. lv - btn { margin - top : 36 rpx ; padding : 0 48 rpx ; height : 80 rpx ; line - height : 80 rpx ; border - radius : 40 rpx ; background : # F4593C ; color : # fff ; font - size : 28 rpx ; }
. lv - btn - hover { opacity : .85 ; }
. lv - loading { display : flex ; align - items : center ; justify - content : center ; min - height : 70 vh ; font - size : 26 rpx ; color : # A79C95 ; }
< / style >