Files
lucky_shop/components-diy/diy-webview.vue

500 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>