From 5a913ab4f0ff5ae0fd36b9c40a0c15cc68728e9c Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Fri, 19 Jun 2026 15:01:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=B8=BF=E8=92=99=20XWebView=20?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E6=96=B0=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增模块:xwebview/ - XWebView.ets - WebView 组件(Web 组件封装) - XWebViewTypes.ets - 类型定义 - XWebViewBridge.ets - 桥接管理 功能: - WebView 渲染(Web 组件) - JSBridge 通信(ReactNativeWebView) - 对话框原生化(alert/confirm/prompt) - StandardHandlers(xuqm.* 命名空间) - 导航栏(返回/标题/关闭) - 进度条 - 页面错误处理 Co-Authored-By: Claude --- xuqm-sdk/Index.ets | 2 + xuqm-sdk/src/main/ets/xwebview/XWebView.ets | 326 ++++++++++++++++++ .../src/main/ets/xwebview/XWebViewBridge.ets | 82 +++++ .../src/main/ets/xwebview/XWebViewTypes.ets | 54 +++ 4 files changed, 464 insertions(+) create mode 100644 xuqm-sdk/src/main/ets/xwebview/XWebView.ets create mode 100644 xuqm-sdk/src/main/ets/xwebview/XWebViewBridge.ets create mode 100644 xuqm-sdk/src/main/ets/xwebview/XWebViewTypes.ets diff --git a/xuqm-sdk/Index.ets b/xuqm-sdk/Index.ets index 4e790b0..7841341 100644 --- a/xuqm-sdk/Index.ets +++ b/xuqm-sdk/Index.ets @@ -4,6 +4,8 @@ export { PushSDK } from './src/main/ets/push/PushSDK' export { UpdateSDK } from './src/main/ets/update/UpdateSDK' export { SDKContext } from './src/main/ets/core/SDKContext' export { HttpClient } from './src/main/ets/core/HttpClient' +export { XWebViewComponent, XWebViewBuilder } from './src/main/ets/xwebview/XWebView' +export { XWebViewBridge, openXWebView, getXWebViewControl, setXWebViewController, getXWebViewConfig } from './src/main/ets/xwebview/XWebViewBridge' export type { ConfigFile, SDKConfig, diff --git a/xuqm-sdk/src/main/ets/xwebview/XWebView.ets b/xuqm-sdk/src/main/ets/xwebview/XWebView.ets new file mode 100644 index 0000000..ffb044c --- /dev/null +++ b/xuqm-sdk/src/main/ets/xwebview/XWebView.ets @@ -0,0 +1,326 @@ +/** + * XWebView 组件 + * HarmonyOS WebView 封装 + */ + +import web_webview from '@ohos.web.webview' +import type { XWebViewConfig, XWebViewControllerAPI } from './XWebViewTypes' +import { XWebViewBridge } from './XWebViewBridge' + +/// JS Bridge 名称 +const BRIDGE_NAME = 'ReactNativeWebView' + +/// 对话框覆盖 JS +const DIALOG_OVERRIDE_JS = ` +(function() { + function post(obj) { + window.${BRIDGE_NAME}.postMessage(JSON.stringify(obj)); + } + + window.alert = function(msg) { post({ __xwv: 'alert', msg: String(msg) }); }; + window.confirm = function(msg) { post({ __xwv: 'confirm', msg: String(msg) }); return true; }; + window.prompt = function(msg, def) { post({ __xwv: 'prompt', msg: String(msg), def: def || '' }); return def || ''; }; + + window.open = function(url) { + if (url) { window.location.href = url; } + return null; + }; +})(); +true; +` + +/// StandardHandlers JS +const STANDARD_HANDLERS_JS = ` +(function() { + window.xuqm = window.xuqm || {}; + + window.xuqm.getDeviceInfo = function(callback) { + var callbackId = 'cb_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); + window[callbackId] = function(data) { + if (callback) callback(data); + delete window[callbackId]; + }; + window.${BRIDGE_NAME}.postMessage(JSON.stringify({ + action: 'xuqm.getDeviceInfo', + callbackId: callbackId + })); + }; + + window.xuqm.getToken = function(callback) { + var callbackId = 'cb_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); + window[callbackId] = function(data) { + if (callback) callback(data.token); + delete window[callbackId]; + }; + window.${BRIDGE_NAME}.postMessage(JSON.stringify({ + action: 'xuqm.getToken', + callbackId: callbackId + })); + }; + + window.xuqm.getUserInfo = function(callback) { + var callbackId = 'cb_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); + window[callbackId] = function(data) { + if (callback) callback(data); + delete window[callbackId]; + }; + window.${BRIDGE_NAME}.postMessage(JSON.stringify({ + action: 'xuqm.getUserInfo', + callbackId: callbackId + })); + }; + + window.xuqm.closeWebView = function() { + window.${BRIDGE_NAME}.postMessage(JSON.stringify({ + action: 'xuqm.closeWebView' + })); + }; + + window.xuqm.showToast = function(message) { + window.${BRIDGE_NAME}.postMessage(JSON.stringify({ + action: 'xuqm.showToast', + message: message + })); + }; +})(); +` + +@Builder +export function XWebViewBuilder(config: XWebViewConfig) { + XWebViewComponent({ config: config }) +} + +@Component +export struct XWebViewComponent { + @Prop config: XWebViewConfig = {} + private webController: web_webview.WebviewController = new web_webview.WebviewController() + @State currentUrl: string = '' + @State currentTitle: string = '' + @State loadProgress: number = 0 + @State canGoBack: boolean = false + @State loadError: boolean = false + + aboutToAppear() { + // 注册控制器 + const self = this + const controller: XWebViewControllerAPI = { + refresh() { + self.webController.refresh() + }, + close() { + self.config.onClose?.() + }, + goBack() { + self.webController.backward() + }, + goForward() { + self.webController.forward() + }, + loadUrl(url: string) { + self.webController.loadUrl(url) + }, + postMessageToWeb(js: string) { + self.webController.runJavaScript(js) + }, + getTitle() { + return self.currentTitle + }, + canGoBack() { + return self.canGoBack + }, + canGoForward() { + return self.webController.accessForward() + }, + currentUrl() { + return self.currentUrl + } + } + XWebViewBridge.getInstance().setController(controller) + } + + build() { + Column() { + // 顶部导航栏 + if (this.config.showTopBar !== false) { + this.TopBar() + } + + // 进度条 + Progress({ value: this.loadProgress, total: 100 }) + .width('100%') + .height(2) + .visibility(this.loadProgress < 100 ? Visibility.Visible : Visibility.Hidden) + + // WebView + Web({ src: this.config.url ?? '', controller: this.webController }) + .width('100%') + .layoutWeight(1) + .javaScriptAccess(true) + .domStorageAccess(true) + .mixedContent(MixedContentMode.MIXED_CONTENT_ALWAYS_ALLOW) + .onPageBegin((event) => { + this.currentUrl = event.url + this.loadError = false + this.config.onProgressChanged?.(0) + }) + .onPageEnd((event) => { + this.currentUrl = event.url + this.loadProgress = 100 + this.config.onProgressChanged?.(100) + + // 注入 JS + const injectedJs = DIALOG_OVERRIDE_JS + '\n' + STANDARD_HANDLERS_JS + '\n' + (this.config.injectedJavaScript ?? '') + this.webController.runJavaScript(injectedJs) + + // 获取标题 + this.webController.runJavaScript('document.title').then((result) => { + if (typeof result === 'string' && result.length > 0) { + this.currentTitle = result + } + }) + + this.canGoBack = this.webController.accessBackward() + }) + .onProgressChange((event) => { + this.loadProgress = event.newProgress + this.config.onProgressChanged?.(event.newProgress) + }) + .onErrorReceive((event) => { + this.loadError = true + this.config.onPageError?.(event.errorCode, event.description ?? '', event.url ?? '') + }) + .onConsole((event) => { + // 处理 JS 控制台消息 + if (event.message) { + try { + const data = JSON.parse(event.message) + this.handleJsMessage(data) + } catch { + // ignore + } + } + }) + } + .width('100%') + .height('100%') + } + + @Builder + TopBar() { + Row() { + // 返回按钮 + if (this.canGoBack) { + Image($r('app.media.ic_back')) + .width(24) + .height(24) + .onClick(() => { + this.webController.backward() + }) + } + + // 标题 + Text(this.config.autoTitle ? this.currentTitle : (this.config.title ?? '')) + .layoutWeight(1) + .fontSize(16) + .fontWeight(FontWeight.Medium) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .margin({ left: 12 }) + + // 关闭按钮 + Text('关闭') + .fontSize(14) + .fontColor('#666666') + .onClick(() => { + this.config.onClose?.() + }) + } + .width('100%') + .height(48) + .padding({ left: 12, right: 12 }) + .backgroundColor('#FFFFFF') + .border({ width: { bottom: 1 }, color: '#E5E5E5' }) + } + + /** + * 处理 JS 消息 + */ + private handleJsMessage(data: Record): void { + // 处理 StandardHandlers + const action = data['action'] as string + if (action && action.startsWith('xuqm.')) { + this.handleStandardAction(action, data) + return + } + + // 处理 __xwv 消息 + const xwv = data['__xwv'] as string + if (xwv) { + switch (xwv) { + case 'download': + // TODO: 处理下载 + break + case 'blobdownload': + // TODO: 处理 Blob 下载 + break + } + } + + // 转发给用户回调 + this.config.onMessage?.(JSON.stringify(data)) + } + + /** + * 处理 StandardHandlers 动作 + */ + private handleStandardAction(action: string, data: Record): void { + const callbackId = data['callbackId'] as string + + switch (action) { + case 'xuqm.getDeviceInfo': + this.handleGetDeviceInfo(callbackId) + break + case 'xuqm.getToken': + this.handleGetToken(callbackId) + break + case 'xuqm.getUserInfo': + this.handleGetUserInfo(callbackId) + break + case 'xuqm.closeWebView': + this.config.onClose?.() + break + case 'xuqm.showToast': + // HarmonyOS 不支持原生 Toast,可以使用 prompt + break + } + } + + private handleGetDeviceInfo(callbackId: string): void { + if (!callbackId) return + // TODO: 获取设备信息 + const data = { + model: '', + osVersion: '', + platform: 'HARMONY', + deviceId: '' + } + this.callJsCallback(callbackId, data) + } + + private handleGetToken(callbackId: string): void { + if (!callbackId) return + // TODO: 获取 Token + this.callJsCallback(callbackId, { token: '' }) + } + + private handleGetUserInfo(callbackId: string): void { + if (!callbackId) return + // TODO: 获取用户信息 + this.callJsCallback(callbackId, { userId: '' }) + } + + private callJsCallback(callbackId: string, data: Object): void { + const json = JSON.stringify(data) + this.webController.runJavaScript(`window["${callbackId}"](${json})`) + } +} diff --git a/xuqm-sdk/src/main/ets/xwebview/XWebViewBridge.ets b/xuqm-sdk/src/main/ets/xwebview/XWebViewBridge.ets new file mode 100644 index 0000000..e268464 --- /dev/null +++ b/xuqm-sdk/src/main/ets/xwebview/XWebViewBridge.ets @@ -0,0 +1,82 @@ +/** + * XWebView 桥接管理 + */ + +import type { XWebViewConfig, XWebViewControllerAPI } from './XWebViewTypes' + +export class XWebViewBridge { + private static instance: XWebViewBridge + private config: XWebViewConfig = {} + private controller: XWebViewControllerAPI | null = null + + static getInstance(): XWebViewBridge { + if (!XWebViewBridge.instance) { + XWebViewBridge.instance = new XWebViewBridge() + } + return XWebViewBridge.instance + } + + /** + * 设置配置 + */ + setConfig(config: XWebViewConfig): void { + this.config = config + } + + /** + * 获取配置 + */ + getConfig(): XWebViewConfig { + return this.config + } + + /** + * 设置控制器 + */ + setController(controller: XWebViewControllerAPI): void { + this.controller = controller + } + + /** + * 获取控制器 + */ + getController(): XWebViewControllerAPI | null { + return this.controller + } + + /** + * 打开 WebView + */ + openWebView(config: XWebViewConfig): void { + this.config = config + // 实际打开逻辑由页面层处理 + } +} + +/** + * 全局打开 WebView + */ +export function openXWebView(config: XWebViewConfig): void { + XWebViewBridge.getInstance().openWebView(config) +} + +/** + * 获取全局控制器 + */ +export function getXWebViewControl(): XWebViewControllerAPI | null { + return XWebViewBridge.getInstance().getController() +} + +/** + * 设置全局控制器 + */ +export function setXWebViewController(controller: XWebViewControllerAPI): void { + XWebViewBridge.getInstance().setController(controller) +} + +/** + * 获取配置 + */ +export function getXWebViewConfig(): XWebViewConfig { + return XWebViewBridge.getInstance().getConfig() +} diff --git a/xuqm-sdk/src/main/ets/xwebview/XWebViewTypes.ets b/xuqm-sdk/src/main/ets/xwebview/XWebViewTypes.ets new file mode 100644 index 0000000..3d3df1b --- /dev/null +++ b/xuqm-sdk/src/main/ets/xwebview/XWebViewTypes.ets @@ -0,0 +1,54 @@ +/** + * XWebView 类型定义 + */ + +/// WebView 配置 +export interface XWebViewConfig { + url?: string + title?: string + showTopBar?: boolean + showStatusBar?: boolean + autoTitle?: boolean + showMenu?: boolean + injectedJavaScript?: string + onMessage?: (message: string) => void + onClose?: () => void + onPageError?: (errorCode: number, description: string, url: string) => void + onProgressChanged?: (progress: number) => void +} + +/// WebView 控制器接口 +export interface XWebViewControllerAPI { + refresh(): void + close(): void + goBack(): void + goForward(): void + loadUrl(url: string): void + postMessageToWeb(js: string): void + getTitle(): string + canGoBack(): boolean + canGoForward(): boolean + currentUrl(): string +} + +/// 下载请求 +export interface XWebViewDownloadRequest { + url: string + filename?: string +} + +/// 下载结果 +export interface XWebViewDownloadResult { + url: string + filename: string + filePath: string + fileSize: number +} + +/// 下载冲突策略 +export type XWebViewDownloadConflict = 'rename' | 'overwrite' + +/// 消息事件 +export interface XWebViewMessageEvent { + data: string +}