Kaynağa Gözat

内置浏览器渲染

liuq 3 hafta önce
ebeveyn
işleme
85b181ccc5

+ 2 - 1
pages.json

@@ -57,7 +57,8 @@
 		{
 			"path": "pages/webview/index",
 			"style": {
-				"navigationBarTitleText": "网页"
+				"navigationBarTitleText": "",
+				"navigationStyle": "custom"
 			}
 		}
 	],

+ 34 - 29
pages/app-center/index.vue

@@ -56,25 +56,22 @@
 						scroll-x
 						scroll-with-animation
 						:show-scrollbar="false"
+						:scroll-into-view="scrollIntoTabId"
 					>
 						<view class="category-list">
 							<view
-								v-for="cat in categories"
+								v-for="(cat, catIdx) in categories"
 								:key="cat.id"
+								:id="'cat-tab-' + catIdx"
 								class="category-item"
 								:class="{ active: cat.id === activeCategoryId }"
-								@click="activeCategoryId = cat.id"
+								@click="onCategoryTabClick(catIdx)"
 							>
 								<text class="category-name">{{ cat.name }}</text>
 								<view v-if="cat.id === activeCategoryId" class="category-underline" />
 							</view>
 						</view>
 					</scroll-view>
-
-					<!-- 右侧菜单 -->
-					<view class="category-menu" @click="onMore">
-						<image class="category-menu-icon" src="/static/icons/more.svg" mode="aspectFit" />
-					</view>
 				</view>
 			</view>
 
@@ -108,8 +105,6 @@
 							</view>
 						</swiper-item>
 					</swiper>
-
-			<view class="bottom-spacer" />
 		</scroll-view>
 	</view>
 </template>
@@ -181,7 +176,10 @@
 				activeCategoryId: 'recent',
 
 				// 全部应用网格数据:来自接口返回的 items
-				allApps: []
+				allApps: [],
+
+				/** 横向分类 Tab 与 swiper 同步时滚入视口(与 chat 页 scroll-into-view 用法一致) */
+				scrollIntoTabId: ''
 			}
 		},
 		async onLoad() {
@@ -395,8 +393,26 @@
 			onSearch() {
 				uni.navigateTo({ url: '/pages/search-center/index' })
 			},
-			onMore() {
-				uni.showToast({ title: '更多', icon: 'none' })
+
+			// 先清空再设锚点,避免同 id 时不滚动(与 pages/chat/index.vue 一致)
+			scrollCategoryTabIntoView(catIdx) {
+				const idx = Number(catIdx)
+				if (!Number.isFinite(idx) || idx < 0) return
+				const anchor = 'cat-tab-' + idx
+				this.scrollIntoTabId = ''
+				this.$nextTick(() => {
+					this.scrollIntoTabId = anchor
+					setTimeout(() => {
+						this.scrollIntoTabId = anchor
+					}, 50)
+				})
+			},
+
+			onCategoryTabClick(catIdx) {
+				const cat = this.categories[catIdx]
+				if (!cat) return
+				this.activeCategoryId = cat.id
+				this.scrollCategoryTabIntoView(catIdx)
 			},
 
 			// swiper 滑动切换分类 -> 同步更新 Tab 高亮
@@ -404,6 +420,7 @@
 				const idx = e && e.detail ? e.detail.current : 0
 				const cat = this.categories[idx]
 				if (cat) this.activeCategoryId = cat.id
+				this.scrollCategoryTabIntoView(idx)
 			},
 
 			// 根据分类 id 获取该页要渲染的应用列表
@@ -457,8 +474,8 @@
 		flex: 1;
 		min-height: 0;
 		height: 0;
-		padding-bottom: calc(24rpx + 120rpx + constant(safe-area-inset-bottom));
-		padding-bottom: calc(24rpx + 120rpx + env(safe-area-inset-bottom));
+		/* 与消息/通讯录一致:用框架 tabBar 区域高度,避免重复加 safe-area 造成大块白底或与原生 Tab 错位遮挡 */
+		padding-bottom: var(--window-bottom, 50px);
 		box-sizing: border-box;
 	}
 
@@ -607,6 +624,9 @@
 		display: flex;
 		align-items: flex-end;
 		white-space: nowrap;
+		/* 最后一项滚到最右时不贴边裁切 */
+		padding-right: 24rpx;
+		box-sizing: border-box;
 	}
 	.category-item {
 		display: flex;
@@ -631,18 +651,6 @@
 		background: #1f6feb;
 		margin-top: 12rpx;
 	}
-	.category-menu {
-		width: 56rpx;
-		height: 56rpx;
-		display: flex;
-		align-items: center;
-		justify-content: center;
-	}
-	.category-menu-icon {
-		width: 36rpx;
-		height: 36rpx;
-		opacity: 0.8;
-	}
 
 	.apps-swiper {
 		width: 100%;
@@ -657,7 +665,4 @@
 		padding: 24rpx 24rpx 0;
 	}
 
-	.bottom-spacer {
-		height: 40rpx;
-	}
 </style>

+ 227 - 27
pages/webview/index.vue

@@ -1,32 +1,209 @@
 <template>
 	<view class="webview-page">
-		<view class="header">
-			<view class="back" @click="goBack">
-				<text class="back-text">返回</text>
+		<!-- #ifdef APP-PLUS -->
+		<!-- App:web-view 为原生层会挡住普通 view 的点击,须用 cover-view 盖在上面 -->
+		<web-view class="webview" :src="decodedUrl" @load="onEmbeddedWebviewLoad" />
+		<cover-view class="header header--overlay" :style="headerStyle">
+			<cover-view class="header-inner">
+				<cover-view class="capsule">
+					<cover-view
+						class="capsule-btn"
+						style="display: flex; align-items: center; justify-content: center; width: 46px; height: 28px"
+						@tap="onCapsuleMoreMenu"
+					>
+						<!-- App:cover-view 内勿依赖 scoped;cover-image 宽高须内联 px -->
+						<!-- App 端 cover-image 多数不支持 SVG,须用 PNG -->
+						<cover-image
+							style="width: 20px; height: 20px"
+							src="/static/icons/capsule-more.png"
+							mode="aspectFit"
+							@tap="onCapsuleMoreMenu"
+						/>
+					</cover-view>
+					<cover-view class="capsule-divider" />
+					<cover-view
+						class="capsule-btn"
+						style="display: flex; align-items: center; justify-content: center; width: 46px; height: 28px"
+						@tap="onCapsuleClose"
+					>
+						<cover-image
+							style="width: 20px; height: 20px"
+							src="/static/icons/capsule-close.png"
+							mode="aspectFit"
+							@tap="onCapsuleClose"
+						/>
+					</cover-view>
+				</cover-view>
+			</cover-view>
+		</cover-view>
+		<!-- #endif -->
+		<!-- #ifndef APP-PLUS -->
+		<view class="header" :style="headerStyle">
+			<view class="header-inner">
+				<view class="capsule">
+					<view class="capsule-btn" @tap="onCapsuleMoreMenu">
+						<image class="capsule-icon" src="/static/icons/capsule-more.svg" mode="aspectFit" />
+					</view>
+					<view class="capsule-divider" />
+					<view class="capsule-btn" @tap="onCapsuleClose">
+						<image class="capsule-icon" src="/static/icons/capsule-close.svg" mode="aspectFit" />
+					</view>
+				</view>
 			</view>
-			<text class="title">{{ title }}</text>
 		</view>
 		<web-view class="webview" :src="decodedUrl" />
+		<!-- #endif -->
 	</view>
 </template>
 
 <script setup>
-import { ref } from 'vue'
-import { onLoad } from '@dcloudio/uni-app'
+import { computed, ref } from 'vue'
+import { onLoad, onReady, onShow } from '@dcloudio/uni-app'
 
 const decodedUrl = ref('')
-const title = ref('网页')
+
+/** 用系统信息算顶栏 padding-top(部分端上 CSS env 不可靠) */
+function computeHeaderPaddingTopPx() {
+	try {
+		const si = uni.getSystemInfoSync()
+		const insetTop =
+			si.safeAreaInsets && typeof si.safeAreaInsets.top === 'number'
+				? si.safeAreaInsets.top
+				: si.statusBarHeight || 0
+		const r88 = uni.upx2px(88)
+		const r24 = uni.upx2px(24)
+		return Math.max(r88, r24 + insetTop)
+	} catch (e) {
+		return uni.upx2px(88)
+	}
+}
+
+const headerPaddingTopPx = ref(computeHeaderPaddingTopPx())
+const headerStyle = computed(() => ({
+	paddingTop: `${headerPaddingTopPx.value}px`
+}))
 
 onLoad((options) => {
+	headerPaddingTopPx.value = computeHeaderPaddingTopPx()
 	const rawUrl = options && options.url ? options.url : ''
 	decodedUrl.value = rawUrl ? decodeURIComponent(rawUrl) : ''
-	if (options && options.title) {
-		title.value = decodeURIComponent(options.title)
+})
+
+/**
+ * App 端 web-view 为原生组件,默认会铺满窗口,内嵌 H5 会与状态栏重叠。
+ * 通过子 webview 的 setStyle 将内嵌区域限制在自定义顶栏下方。
+ * 不可假定 children()[0] 为内嵌页,须遍历带 getURL 的子窗体。
+ */
+// #ifdef APP-PLUS
+function findEmbeddedWebviewNative(currentWebview) {
+	try {
+		const list = currentWebview.children && currentWebview.children()
+		if (!list || !list.length) return null
+		for (let i = 0; i < list.length; i++) {
+			const w = list[i]
+			if (w && typeof w.setStyle === 'function' && typeof w.getURL === 'function') {
+				return w
+			}
+		}
+		for (let i = 0; i < list.length; i++) {
+			const w = list[i]
+			if (w && typeof w.setStyle === 'function') return w
+		}
+	} catch (e) {}
+	return null
+}
+// #endif
+
+function positionEmbeddedWebview() {
+	// #ifdef APP-PLUS
+	const pages = getCurrentPages()
+	const page = pages[pages.length - 1]
+	if (!page || typeof page.$getAppWebview !== 'function') return
+	const currentWebview = page.$getAppWebview()
+	const apply = () => {
+		const wv = findEmbeddedWebviewNative(currentWebview)
+		if (!wv) return false
+		uni.createSelectorQuery()
+			.select('.header')
+			.boundingClientRect((rect) => {
+				if (!rect || rect.height == null) return
+				const sys = uni.getSystemInfoSync()
+				const winH = sys.windowHeight || sys.screenHeight
+				const topPx = rect.height
+				wv.setStyle({
+					top: topPx,
+					height: winH - topPx,
+					left: 0,
+					width: '100%'
+				})
+			})
+			.exec()
+		return true
 	}
+	const delays = [0, 50, 100, 200, 400, 800, 1200, 2000]
+	delays.forEach((ms) => {
+		setTimeout(() => {
+			if (!apply()) {
+				setTimeout(apply, 200)
+			}
+		}, ms)
+	})
+	// #endif
+}
+
+/** 内嵌页 load 完成后再缩窗,避免子 webview 尚未就绪时仍全屏挡在胶囊下 */
+function onEmbeddedWebviewLoad() {
+	positionEmbeddedWebview()
+}
+
+onReady(() => {
+	positionEmbeddedWebview()
 })
 
-function goBack() {
-	uni.navigateBack()
+onShow(() => {
+	// 从后台返回时子 webview 可能重排,再对齐一次
+	positionEmbeddedWebview()
+})
+
+/** 更多:底部菜单(当前含「用系统浏览器打开」);关闭:清栈并回到应用中心 Tab */
+function onCapsuleMoreMenu() {
+	const url = decodedUrl.value
+	console.log('[webview/capsule] more', { url })
+	uni.showActionSheet({
+		itemList: ['用系统浏览器打开'],
+		success: (res) => {
+			if (res.tapIndex !== 0) return
+			openUrlInSystemBrowser(url)
+		}
+	})
+}
+
+function openUrlInSystemBrowser(url) {
+	const u = String(url || '').trim()
+	if (!u) {
+		uni.showToast({ title: '链接无效', icon: 'none' })
+		return
+	}
+	// #ifdef APP-PLUS
+	try {
+		plus.runtime.openURL(u)
+	} catch (e) {
+		uni.showToast({ title: '无法打开', icon: 'none' })
+	}
+	// #endif
+	// #ifdef H5
+	if (typeof window !== 'undefined' && window.open) {
+		window.open(u, '_blank')
+	}
+	// #endif
+	// #ifndef APP-PLUS || H5
+	uni.showToast({ title: '当前环境不支持', icon: 'none' })
+	// #endif
+}
+
+function onCapsuleClose() {
+	console.log('[webview/capsule] close', { url: decodedUrl.value })
+	uni.switchTab({ url: '/pages/app-center/index' })
 }
 </script>
 
@@ -38,31 +215,54 @@ function goBack() {
 	background-color: #f8f8f8;
 }
 .header {
-	height: 88rpx;
-	padding: 0 24rpx;
-	display: flex;
-	align-items: center;
+	padding: 0 24rpx 16rpx;
+	padding-right: max(24rpx, constant(safe-area-inset-right));
+	padding-right: max(24rpx, env(safe-area-inset-right));
 	background-color: #ffffff;
 	box-shadow: 0 2rpx 8rpx rgba(15, 23, 42, 0.06);
 }
-.back {
-	padding-right: 24rpx;
-	padding-left: 4rpx;
+/* App:盖在原生 web-view 上,需脱离文档流避免布局错位 */
+.header--overlay {
+	position: fixed;
+	top: 0;
+	left: 0;
+	right: 0;
+	z-index: 99999;
+	box-sizing: border-box;
+}
+.header-inner {
 	display: flex;
 	align-items: center;
+	justify-content: flex-end;
+	width: 100%;
 }
-.back-text {
-	font-size: 28rpx;
-	color: #259653;
+.capsule {
+	display: flex;
+	align-items: center;
+	flex-shrink: 0;
+	height: 52rpx;
+	padding: 0 2rpx;
+	background: rgba(0, 0, 0, 0.06);
+	border: 1rpx solid rgba(0, 0, 0, 0.08);
+	border-radius: 26rpx;
 }
-.title {
-	flex: 1;
-	font-size: 32rpx;
-	font-weight: 600;
-	color: #111827;
+.capsule-btn {
+	width: 52rpx;
+	height: 52rpx;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}
+.capsule-icon {
+	width: 30rpx;
+	height: 30rpx;
+}
+.capsule-divider {
+	width: 1rpx;
+	height: 26rpx;
+	background: rgba(0, 0, 0, 0.12);
 }
 .webview {
 	flex: 1;
 }
 </style>
-

BIN
static/icons/capsule-close.png


+ 3 - 0
static/icons/capsule-close.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
+  <path d="M7 7 L17 17 M17 7 L7 17" stroke="#111111" stroke-width="2.8" stroke-linecap="round"/>
+</svg>

BIN
static/icons/capsule-minimize.png


+ 3 - 0
static/icons/capsule-minimize.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
+  <line x1="5" y1="12" x2="19" y2="12" stroke="#111111" stroke-width="2.8" stroke-linecap="round"/>
+</svg>

BIN
static/icons/capsule-more.png


+ 5 - 0
static/icons/capsule-more.svg

@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
+  <circle cx="6" cy="12" r="2.2" fill="#111111"/>
+  <circle cx="12" cy="12" r="2.2" fill="#111111"/>
+  <circle cx="18" cy="12" r="2.2" fill="#111111"/>
+</svg>