@@ -0,0 +1,499 @@
< template >
< view data -component -name = " diy -webview " class = "diy-webview" :style = "containerStyle" @tap ="handleTap" >
< template v-if = "src" >
< ! - - # ifdef H5 - - >
< iframe
v-if = "!lazyPending"
class = "diy-webview-iframe"
:src = "src"
:scrolling = "scrollMode"
referrerpolicy = "no-referrer"
frameborder = "0"
allow = "fullscreen"
loading = "lazy"
@load ="handleIframeLoad"
@error ="handleIframeError"
> < / iframe >
<!-- 后台配置的占位图 / 懒加载未进入视口时的展示 -- >
< image
v-if = "placeholderUrl && (lazyPending || !loaded)"
class = "diy-webview-placeholder"
:src = "placeholderUrl"
mode = "aspectFill"
> < / image >
<!-- 加载中遮罩 : iframe 触发 load 前展示 , 避免白屏观感 -- >
< view v-if = "!loaded && !loadError && !lazyPending" class="webview-loading" :style="errorStyle" >
< view class = "webview-loading-spinner" > < / view >
< text class = "webview-loading-text" > 网页加载中 ... < / text >
< / view >
<!-- H5 下 iframe 内部点击不会冒泡到父容器 , 提供角标按钮保证 "点击跳转" 可用 -- >
< view class = "webview-open-chip" @tap.stop ="mpGo" >
< text class = "iconfont icon-fuzhilianjie" > < / text >
< text class = "webview-open-chip-text" > { { mpGoText } } < / text >
< / view >
<!-- # endif -- >
<!-- # ifndef H5 -- >
<!-- 小程序 / APP : 内嵌 web - view 会整页覆盖原生层 , 改为卡片展示 + 点击跳转 -- >
< view class = 'webview-mp-go' : style = 'mpGoStyle' @ tap.stop = ' mpGo ' >
< text class = 'iconfont icon-fuzhilianjie' > < / text >
< text class = 'webview-mp-go-text' > { { mpGoText } } < / text >
< / view >
<!-- # endif -- >
<!-- 加载失败兜底 ( 如目标站禁止被嵌入 / 内核过旧加载失败 ) : 可点击直接打开 -- >
< view v-if = "loadError" class="webview-error" :style="errorStyle" @tap.stop="mpGo" >
< text class = "iconfont icon-fuzhilianjie" > < / text >
< text class = "error-text" > { { errorText } } < / text >
< text class = "error-open" > 点击此处打开网页 < / text >
< / view >
< / template >
< view v-else class = "webview-empty" :style = "emptyStyle" >
< text class = "iconfont icon-fuzhilianjie" > < / text >
< text class = "empty-text" > 请配置网页链接 < / text >
< / view >
< / view >
< / template >
< script >
// 网页/WebView 组件:后台配置 URL 后嵌入展示, H5 使用 iframe, 小程序/APP 使用原生 web-view
import DiyMinx from './minx.js'
export default {
name : 'diy-webview' ,
props : {
value : {
type : Object ,
default : ( ) => ( { } )
}
} ,
data ( ) {
return {
src : '' ,
loaded : false ,
loadError : false ,
// 后台开启"懒加载"时, iframe 需等容器滚动进入视口后才真正渲染
lazyPending : false
} ;
} ,
mixins : [ DiyMinx ] ,
created ( ) {
this . initWebview ( ) ;
} ,
mounted ( ) {
this . _mountedFlag = true ;
// #ifdef H5
if ( this . lazyPending ) this . _setupLazyObserver ( ) ;
// #endif
} ,
beforeDestroy ( ) {
if ( this . _observer ) {
try { this . _observer . disconnect ( ) ; } catch ( e ) { }
this . _observer = null ;
}
this . _clearLoadTimer ( ) ;
} ,
watch : {
value : {
immediate : true ,
deep : true ,
handler ( newVal ) {
if ( newVal ) {
this . initWebview ( ) ;
}
}
}
} ,
computed : {
containerStyle ( ) {
let style = '' ;
// 后台高度为 px 值,与项目其他组件一致 × 2 转 rpx( diy-horz-blank 同款换算)
const height = Number ( this . value ? . height ) || 300 ;
style += ` height: ${ height * 2 } rpx; ` ;
// 组件角度 round 时按后台圆角值渲染( × 2 rpx)
if ( this . value ? . componentAngle === 'round' ) {
const topR = Number ( this . value ? . topAroundRadius ) || 0 ;
const bottomR = Number ( this . value ? . bottomAroundRadius ) || 0 ;
style += ` border-radius: ${ topR * 2 } rpx ${ topR * 2 } rpx ${ bottomR * 2 } rpx ${ bottomR * 2 } rpx; ` ;
}
if ( this . value ? . bgColor ) {
style += ` background: ${ this . value . bgColor } ; ` ;
}
if ( this . value ? . marginTop ) {
style += ` margin-top: ${ this . value . marginTop } rpx; ` ;
}
if ( this . value ? . marginBottom ) {
style += ` margin-bottom: ${ this . value . marginBottom } rpx; ` ;
}
if ( this . value ? . padding ) {
style += ` padding: ${ this . value . padding } rpx; ` ;
}
return style ;
} ,
emptyStyle ( ) {
let style = '' ;
if ( this . value ? . bgColor ) {
style += ` background: ${ this . value . bgColor } ; ` ;
}
return style ;
} ,
errorStyle ( ) {
let style = '' ;
if ( this . value ? . bgColor ) {
style += ` background: ${ this . value . bgColor } ; ` ;
}
return style ;
} ,
mpGoText ( ) {
// 注意: this.$lang(key) 在 key 不存在时返回 key 字符串本身,不能仅靠 this.$lang 是否存在判断。
// 取到 key 与传入 key 不同才视为命中语言包,否则用 fallback。
const t = this . $lang && this . $lang ( 'components-diy.diy-webview.mpGo' ) ;
return ( t && t !== 'components-diy.diy-webview.mpGo' ) ? t : '点击查看网页内容' ;
} ,
mpGoStyle ( ) {
let style = '' ;
if ( this . value && this . value . bgColor ) {
style += 'background:' + this . value . bgColor + ';' ;
}
return style ;
} ,
errorText ( ) {
const t = this . $lang && this . $lang ( 'components-diy.diy-webview.loadError' ) ;
return ( t && t !== 'components-diy.diy-webview.loadError' ) ? t : '网页加载失败,请检查链接' ;
} ,
// iframe 滚动条开关(后台"滚动"配置: true 显示 / false 隐藏)
scrollMode ( ) {
const s = this . value ? . scrolling ;
return ( s === false || s === 'false' || s === 0 ) ? 'no' : 'yes' ;
} ,
// 占位图(后台 imageUrl, iframe 加载完成前展示)
placeholderUrl ( ) {
const url = this . value ? . imageUrl ;
return url ? ( this . $util . img ? this . $util . img ( url ) : url ) : '' ;
}
} ,
methods : {
initWebview ( ) {
const config = this . value || { } ;
this . loadError = false ;
this . loaded = false ;
// 调试用:把后台下发的完整 value 对象打印到控制台,方便定位 URL 字段名
console . log ( '[diy-webview] value:' , JSON . stringify ( config ) ) ;
// 兼容后台可能使用的多种链接字段名/嵌套结构
const url = this . extractUrl ( config ) ;
// 懒加载:开启时等容器进入视口再渲染 iframe
this . lazyPending = ! ! ( config . lazyRender || config . lazyLoad ) ;
// #ifdef H5
// 数据热更新时(后台重新保存)若尚未进入过视口,需重建观察器(仅在挂载后才可建立)
if ( this . lazyPending && this . _mountedFlag ) this . _setupLazyObserver ( ) ;
// #endif
// 优先使用后台直接下发的 url; 若配置的是接口来源(source=api),则通过接口拉取
if ( config . source === 'api' && config . apiUrl ) {
this . loadUrlFromApi ( config . apiUrl ) ;
} else if ( url ) {
this . src = this . buildSrc ( url ) ;
console . log ( '[diy-webview] src:' , this . src ) ;
this . _startLoadTimer ( ) ;
} else {
this . src = '' ;
console . warn ( '[diy-webview] 未找到有效链接字段' ) ;
}
} ,
// 在格式化后的 URL 基础上,拼接后台配置的"附加参数"与"携带登录态"(token / member_id)
buildSrc ( url ) {
let src = this . formatUrl ( url ) ;
const config = this . value || { } ;
const extra = [ ] ;
// 附加参数(后台 urlParams 字段,格式 "a=1&b=2",多组参数用 & 连接)
const rawExtra = config . urlParams || config . extraParams || config . appendParams || config . additionalParams || config . params ;
if ( rawExtra && typeof rawExtra === 'string' ) {
extra . push ( rawExtra . replace ( /^[?&]+/ , '' ) ) ;
}
// 携带登录态(后台 carryToken 字段: none 不携带 / token / member_id)
const loginState = config . carryToken || config . loginState || config . login _state ;
if ( loginState === 'token' ) {
const token = ( this . $store && this . $store . state && this . $store . state . token ) || '' ;
if ( token ) extra . push ( 'token=' + encodeURIComponent ( token ) ) ;
} else if ( loginState === 'member_id' ) {
const memberInfo = ( this . $store && this . $store . state && this . $store . state . memberInfo ) || { } ;
const memberId = memberInfo . member _id || memberInfo . memberId || '' ;
if ( memberId ) extra . push ( 'member_id=' + memberId ) ;
}
if ( extra . length ) {
src += ( src . indexOf ( '?' ) !== - 1 ? '&' : '?' ) + extra . filter ( Boolean ) . join ( '&' ) ;
}
return src ;
} ,
// #ifdef H5
// 懒加载:容器滚动进入视口(含预加载缓冲 200px) 后才渲染 iframe
_setupLazyObserver ( ) {
try {
if ( this . _observer ) {
this . _observer . disconnect ( ) ;
this . _observer = null ;
}
this . _observer = uni . createIntersectionObserver ( this , { thresholds : [ 0 ] } ) ;
this . _observer . relativeToViewport ( { bottom : 200 } ) . observe ( '.diy-webview' , ( ) => {
this . lazyPending = false ;
if ( this . _observer ) {
this . _observer . disconnect ( ) ;
this . _observer = null ;
}
} ) ;
} catch ( e ) {
// 观察器异常时直接渲染,避免内容永远空白
this . lazyPending = false ;
}
} ,
// #endif
extractUrl ( config ) {
if ( ! config || typeof config !== 'object' ) return '' ;
// 优先读取常见字段名(兼容后台不同命名习惯)
let direct = config . url || config . link || config . linkUrl || config . pageUrl || config . webUrl || config . web _url || config . externalUrl || config . targetUrl || config . src || config . href || config . path || config . address ;
// 兼容装修链接选择器下发的对象形态: link = { wap_url / url / h5_url ... }
if ( direct && typeof direct === 'object' ) {
direct = direct . wap _url || direct . h5 _url || direct . url || direct . app _url || direct . link || '' ;
}
if ( direct && typeof direct === 'string' ) return direct . trim ( ) ;
// 兼容嵌套结构:如 value.value.url / value.config.url / value.data.url
for ( const key of [ 'value' , 'config' , 'data' , 'content' , 'params' ] ) {
if ( config [ key ] && typeof config [ key ] === 'object' ) {
const nested = this . extractUrl ( config [ key ] ) ;
if ( nested ) return nested ;
}
}
return '' ;
} ,
formatUrl ( url ) {
if ( ! url || typeof url !== 'string' ) return '' ;
let formatted = url . trim ( ) ;
// 如果后台把 URL 编码存储,做一次解码
if ( formatted . indexOf ( '%' ) !== - 1 ) {
try { formatted = decodeURIComponent ( formatted ) ; } catch ( e ) { }
}
// 支持以 http:// 或 https:// 开头的完整地址
if ( /^https?:\/\//i . test ( formatted ) ) return formatted ;
// 支持相对路径或站点内链接,自动补全
return this . $util . img ? this . $util . img ( formatted ) : formatted ;
} ,
loadUrlFromApi ( apiUrl ) {
this . $api . sendRequest ( {
url : apiUrl ,
success : res => {
if ( res . code == 0 && res . data ) {
const data = typeof res . data === 'string' ? { url : res . data } : res . data ;
this . src = this . formatUrl ( data . url || data . link || '' ) ;
}
} ,
fail : ( ) => {
this . src = '' ;
}
} ) ;
} ,
updateNavbarTitle ( e ) {
// 参照 pages-tool/webview.vue: 当 web-view 页面标题更新时,可同步设置导航栏标题
const title = e ? . detail ? . title || e ? . title || '' ;
if ( title ) {
uni . setNavigationBarTitle ( { title } ) ;
}
this . $emit ( 'titleUpdate' , e ) ;
} ,
// 点击网页容器整体 → 跳转到配置的链接(走统一 diyRedirectTo, 外链自动进 web-view / 新窗口)
handleTap ( ) {
if ( this . src ) this . mpGo ( ) ;
} ,
mpGo ( ) {
if ( ! this . src ) return ;
if ( this . $util && this . $util . diyRedirectTo ) {
this . $util . diyRedirectTo ( {
wap _url : this . src ,
title : ( this . value && this . value . title ) || ( this . value && this . value . name ) || '网页' ,
name : ( this . value && this . value . name ) ? this . value . name : '网页'
} ) ;
} else {
uni . navigateTo ( { url : '/pages_tool/webview/webview?src=' + encodeURIComponent ( this . src ) } ) ;
}
} ,
// 注意:方法名不能叫 onLoad/onError —— uni-app 会把组件里的 onLoad 当页面
// 生命周期提升出 methods, 导致 @load="onLoad" 渲染时报 handler undefined。
handleIframeLoad ( e ) {
this . loaded = true ;
this . loadError = false ;
this . _clearLoadTimer ( ) ;
this . $emit ( 'load' , e ) ;
} ,
handleIframeError ( e ) {
this . loadError = true ;
this . loaded = true ;
this . _clearLoadTimer ( ) ;
console . error ( '[diy-webview] 加载失败:' , this . src , e ) ;
this . $emit ( 'error' , e ) ;
} ,
// 加载超时兜底:部分浏览器内核(如 HBuilderX 内置浏览器的老 CEF 内核)对跨域 iframe
// 请求失败时不会触发 error 事件,用定时器兜住,避免容器一直白屏
_startLoadTimer ( ) {
this . _clearLoadTimer ( ) ;
// #ifdef H5
this . _loadTimer = setTimeout ( ( ) => {
if ( ! this . loaded ) {
this . loadError = true ;
console . warn ( '[diy-webview] 加载超时(10s),显示兜底内容:' , this . src ) ;
}
} , 10000 ) ;
// #endif
} ,
_clearLoadTimer ( ) {
if ( this . _loadTimer ) {
clearTimeout ( this . _loadTimer ) ;
this . _loadTimer = null ;
}
} ,
onMessage ( e ) {
// 接收 web-view / iframe 发来的消息,可用于与后台数据交互
this . $emit ( 'message' , e ) ;
// 同时通过事件总线广播,方便页面级监听
this . _ _$emitEvent ( {
eventName : 'diy-webview-message' ,
data : {
componentName : this . value ? . componentName ,
moduleIndex : this . value ? . moduleIndex ,
message : e ? . detail ? . data || e ? . data
}
} ) ;
}
}
} ;
< / script >
< style lang = "scss" >
. diy - webview {
width : 100 % ;
position : relative ;
overflow : hidden ;
box - sizing : border - box ;
. diy - webview - iframe ,
. diy - webview - native {
width : 100 % ;
height : 100 % ;
display : block ;
border : none ;
}
/* 占位图 / 懒加载未进入视口时的展示 */
. diy - webview - placeholder {
position : absolute ;
top : 0 ;
left : 0 ;
width : 100 % ;
height : 100 % ;
z - index : 1 ;
}
/* H5 角标跳转按钮: iframe 内部点击不冒泡,用它兜底"点击跳转" */
. webview - open - chip {
position : absolute ;
right : 16 rpx ;
bottom : 16 rpx ;
z - index : 3 ;
display : flex ;
align - items : center ;
padding : 8 rpx 20 rpx ;
border - radius : 999 rpx ;
background : rgba ( 0 , 0 , 0 , 0.55 ) ;
color : # fff ;
font - size : 22 rpx ;
line - height : 1 ;
. iconfont {
font - size : 24 rpx ;
margin - right : 6 rpx ;
}
}
/* 加载中遮罩 */
. webview - loading {
position : absolute ;
top : 0 ;
left : 0 ;
width : 100 % ;
height : 100 % ;
z - index : 2 ;
display : flex ;
flex - direction : column ;
align - items : center ;
justify - content : center ;
background : # f7f8fa ;
color : # 999 ;
font - size : 24 rpx ;
}
. webview - loading - spinner {
width : 44 rpx ;
height : 44 rpx ;
border : 4 rpx solid # e5e6eb ;
border - top - color : # 576 b95 ;
border - radius : 50 % ;
margin - bottom : 16 rpx ;
animation : webview - spin 0.8 s linear infinite ;
}
@ keyframes webview - spin {
to {
transform : rotate ( 360 deg ) ;
}
}
. webview - empty ,
. webview - mp - go {
width : 100 % ;
height : 100 % ;
display : flex ;
flex - direction : column ;
align - items : center ;
justify - content : center ;
background : # f5f5f5 ;
color : # 576 B95 ;
font - size : 26 rpx ;
. iconfont {
font - size : 48 rpx ;
margin - bottom : 12 rpx ;
}
}
. webview - error {
width : 100 % ;
height : 100 % ;
display : flex ;
flex - direction : column ;
align - items : center ;
justify - content : center ;
background : # f5f5f5 ;
color : # 999 ;
font - size : 26 rpx ;
. iconfont {
font - size : 48 rpx ;
margin - bottom : 12 rpx ;
}
}
. webview - error {
position : absolute ;
top : 0 ;
left : 0 ;
z - index : 2 ;
background : rgba ( 255 , 255 , 255 , 0.96 ) ;
color : # ff6b35 ;
. error - open {
margin - top : 16 rpx ;
font - size : 24 rpx ;
color : # 576 b95 ;
text - decoration : underline ;
}
}
}
< / style >