500 lines
15 KiB
Vue
500 lines
15 KiB
Vue
<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: 16rpx;
|
||
bottom: 16rpx;
|
||
z-index: 3;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 8rpx 20rpx;
|
||
border-radius: 999rpx;
|
||
background: rgba(0, 0, 0, 0.55);
|
||
color: #fff;
|
||
font-size: 22rpx;
|
||
line-height: 1;
|
||
|
||
.iconfont {
|
||
font-size: 24rpx;
|
||
margin-right: 6rpx;
|
||
}
|
||
}
|
||
|
||
/* 加载中遮罩 */
|
||
.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: 24rpx;
|
||
}
|
||
|
||
.webview-loading-spinner {
|
||
width: 44rpx;
|
||
height: 44rpx;
|
||
border: 4rpx solid #e5e6eb;
|
||
border-top-color: #576b95;
|
||
border-radius: 50%;
|
||
margin-bottom: 16rpx;
|
||
animation: webview-spin 0.8s linear infinite;
|
||
}
|
||
|
||
@keyframes webview-spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
.webview-empty,
|
||
.webview-mp-go {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: #f5f5f5;
|
||
color: #576B95;
|
||
font-size: 26rpx;
|
||
|
||
.iconfont {
|
||
font-size: 48rpx;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
}
|
||
|
||
.webview-error {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: #f5f5f5;
|
||
color: #999;
|
||
font-size: 26rpx;
|
||
|
||
.iconfont {
|
||
font-size: 48rpx;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
}
|
||
|
||
.webview-error {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
z-index: 2;
|
||
background: rgba(255, 255, 255, 0.96);
|
||
color: #ff6b35;
|
||
|
||
.error-open {
|
||
margin-top: 16rpx;
|
||
font-size: 24rpx;
|
||
color: #576b95;
|
||
text-decoration: underline;
|
||
}
|
||
}
|
||
}
|
||
</style>
|