feat: 添加components-diy\diy-webview.vue
This commit is contained in:
499
components-diy/diy-webview.vue
Normal file
499
components-diy/diy-webview.vue
Normal file
@@ -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: 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>
|
||||
Reference in New Issue
Block a user