import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:flutter/material.dart'; import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter_android/webview_flutter_android.dart'; class OnlyOfficeViewer extends StatefulWidget { final String onlyOfficeServerUrl; final String fileUrl; final String? jwtSecret; final String? type; // 'mobile', 'desktop', or 'embedded' final String? title; const OnlyOfficeViewer({ super.key, required this.onlyOfficeServerUrl, required this.fileUrl, this.jwtSecret, this.type, // 默认使用 desktop 模式以显示标题 this.title, }); @override State createState() => _OnlyOfficeViewerState(); } class _OnlyOfficeViewerState extends State { late final WebViewController _controller; String _effectiveType = 'embedded'; // 实际使用的类型(可能被降级) bool _hasRetriedWithDesktop = false; // 是否已经尝试过 desktop 模式 int? _detectedChromeVersion; // 检测到的 Chrome 版本 @override void dispose() { super.dispose(); } @override void initState() { super.initState(); // 如果 type 没有指定,根据文档类型决定:slide 使用 mobile,否则使用 embedded if (widget.type == null) { final fileExt = widget.fileUrl.split('.').last.toLowerCase(); final documentType = _getDocumentType(fileExt); _effectiveType = documentType == 'slide' ? 'mobile' : 'embedded'; print('type 未指定,文档类型为 $documentType,使用 $_effectiveType 模式'); } else { _effectiveType = widget.type!; } _controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setNavigationDelegate( NavigationDelegate( onPageFinished: (String url) async { await _checkWebViewVersion(); }, onWebResourceError: (WebResourceError error) { print('WebView Error: ${error.description}, type: ${error.errorType}'); // 如果检测到语法错误且还没重试过,自动切换到 desktop 模式 if (_isSyntaxError(error.description) && !_hasRetriedWithDesktop && _effectiveType == 'mobile') { print('检测到语法错误,自动切换到 desktop 模式以适配旧版 WebView'); _retryWithDesktopMode(); } }, ), ); if (_controller.platform is AndroidWebViewController) { AndroidWebViewController.enableDebugging(true); final androidController = _controller.platform as AndroidWebViewController; androidController.setMediaPlaybackRequiresUserGesture(false); } _loadContent(); } /// 检查 WebView 版本并决定是否需要降级 Future _checkWebViewVersion() async { try { final userAgent = await _controller.runJavaScriptReturningResult('navigator.userAgent'); final userAgentStr = userAgent.toString().replaceAll('"', ''); print('WebView UserAgent: $userAgentStr'); // 从 User-Agent 中提取 Chrome 版本号 final chromeMatch = RegExp(r'Chrome/(\d+)').firstMatch(userAgentStr); bool versionCheckPassed = false; if (chromeMatch != null) { _detectedChromeVersion = int.tryParse(chromeMatch.group(1) ?? ''); print('检测到 Chrome 版本: $_detectedChromeVersion'); // 如果 Chrome 版本 < 80,自动切换到 desktop 模式(原来的逻辑) if (_detectedChromeVersion != null && _detectedChromeVersion! < 80) { if (_effectiveType == 'mobile' && !_hasRetriedWithDesktop) { print('WebView 版本过低 (Chrome $_detectedChromeVersion < 80),自动切换到 desktop 模式'); _retryWithDesktopMode(); return; } } else if (_detectedChromeVersion != null && _detectedChromeVersion! >= 80) { // 版本检查通过 versionCheckPassed = true; } } // 版本检查通过,如果用户未指定 type 且文档类型是 slide,使用 mobile if (versionCheckPassed && widget.type == null) { final fileExt = widget.fileUrl.split('.').last.toLowerCase(); final documentType = _getDocumentType(fileExt); if (documentType == 'slide' && _effectiveType != 'mobile') { print('版本检查通过,文档类型为 slide,使用 mobile 模式'); setState(() { _effectiveType = 'mobile'; }); _loadContent(); } } } catch (e) { print('Failed to check WebView version: $e'); } } /// 判断是否是语法错误 bool _isSyntaxError(String errorDescription) { final lowerError = errorDescription.toLowerCase(); return lowerError.contains('syntaxerror') || lowerError.contains('unexpected token') || lowerError.contains('uncaught syntaxerror'); } /// 使用 desktop 模式重试加载 void _retryWithDesktopMode() { if (_hasRetriedWithDesktop) return; setState(() { _effectiveType = 'desktop'; _hasRetriedWithDesktop = true; }); // 重新加载内容 _loadContent(); } /// 加载内容 void _loadContent() { _controller.loadRequest( Uri.dataFromString(_buildHtml(), mimeType: 'text/html', encoding: Encoding.getByName('utf-8')), ); } @override Widget build(BuildContext context) { return WebViewWidget(controller: _controller); } String _buildHtml() { final apiJsUrl = '${_normalizeUrl(widget.onlyOfficeServerUrl)}/web-apps/apps/api/documents/api.js'; print('apiJsUrl: $apiJsUrl'); final config = _createConfig(); final configJson = jsonEncode(config); print('configJson: $configJson'); return ''' OnlyOffice Viewer
'''; } Map _createConfig() { final fileExt = widget.fileUrl.split('.').last.toLowerCase(); final documentType = _getDocumentType(fileExt); final config = { 'width': "100%", 'height': "100%", // 如果是 PPT 类型,强制使用 mobile 模式,以便将其余视图的浏览(如缩略图)放置在底部 // 'type': documentType == 'slide' ? 'mobile' : widget.type, 'type': _effectiveType, // 使用实际类型(可能已被降级) 'documentType': documentType, 'document': { 'fileType': fileExt, 'key': _generateDocKey(widget.fileUrl), 'title': widget.title ?? widget.fileUrl.split('/').last, // 'title': "fasdfasf范德萨发达发", 'url': widget.fileUrl, 'permissions': { 'comment': false, // 禁止评论 'commentGroups': false, // 禁止评论组 'copy': true, // 允许复制 'deleteCommentAuthorOnly': false, // 禁止删除评论 'download': false, // 允许下载 'edit': false, // 禁止编辑(核心设置) 'editCommentAuthorOnly': false, // 禁止编辑评论 'fillForms': false, // 禁止填写表单 'modifyFilter': false, // 禁止修改筛选器 'modifyContentControl': false, // 禁止修改内容控件 'print': false, // 允许打印 'protect': false, // 禁止保护文档 'review': false, // 禁止审阅/修订 'reviewGroups': false, // 禁止审阅组 'chat': false, // 禁止聊天 'changeHistory': false, // 禁止查看修改历史 }, }, 'editorConfig': { 'mode': 'view', 'lang': 'zh-CN', 'customization': { 'about': true, // 隐藏关于按钮 'autosave': false, // 禁用自动保存 'chat': false, // 禁用聊天 'comments': false, // 隐藏评论功能 'compactHeader': false, // 不使用紧凑标题,确保文件名完整显示 'feedback': false, // 隐藏反馈按钮 'forcesave': false, // 查看模式不需要强制保存 'goback': false, // 隐藏返回按钮 'hideRightMenu': true, // 隐藏右侧菜单 'disableSpellcheck': false, 'showHorizontalScroll': false, 'showVerticalScroll': false, 'help': false, // 隐藏帮助按钮 'hideNotes': false, 'logo': { 'image': "https://example.com/logo.png", 'imageDark': "https://example.com/dark-logo.png", 'imageLight': "https://example.com/light-logo.png", 'url': "https://example.com", 'visible': false, }, 'layout': { // 精确控制界面布局 'header': { 'editMode': false, 'menu': false, // 隐藏头部菜单(关键配置) 'user': false, // 隐藏用户信息和头像 'users': false, // 隐藏多用户图标 }, // 'leftPanel': false, 'leftMenu': { 'mode': true, // 隐藏左侧菜单 'navigation': false, 'spellcheck': false, }, 'rightPanel': {'mode': false, 'navigation': false, 'spellcheck': false}, 'toolbar': { 'collaboration': {'mailmerge': false}, 'layout': false, 'view': {'navigation': false}, }, }, 'customer': { 'address': '', // 隐藏地址 'info': '', // 隐藏信息 'logo': '', // 隐藏客户 logo 'mail': '', // 隐藏邮箱 'name': '', // 隐藏名称 'www': '', // 隐藏网站 }, 'features': { 'spellcheck': false, // 禁用拼写检查 }, 'review': { 'hideReviewDisplay': true, // 隐藏审阅显示 'showReviewChanges': false, // 不显示审阅更改 }, 'toolbarHideFileName': false, // 确保显示完整文件名(关键配置) 'toolbarNoTabs': true, // 隐藏工具栏标签页 'uiTheme': 'theme-classic-light', // 使用经典主题 }, 'anonymous': { 'request': false, // 不请求用户信息 'label': '', // 空标签 }, 'user': { 'group': '', 'id': 'viewer-001', // 固定ID避免被识别为未定义用户 'image': 's', // 透明图片 'name': 'Guest', // 默认名称,配合 CSS 隐藏,避免弹窗 }, 'coEditing': {'mode': 'fast', 'change': false}, }, }; // Sign the entire config with JWT if secret is provided if (widget.jwtSecret != null && widget.jwtSecret!.isNotEmpty) { final jwt = JWT(config); final token = jwt.sign(SecretKey(widget.jwtSecret!), algorithm: JWTAlgorithm.HS256); config['token'] = token; } return config; } String _getDocumentType(String extension) { const wordExtensions = [ 'doc', 'docx', 'docm', 'dot', 'dotx', 'dotm', 'odt', 'fodt', 'ott', 'rtf', 'txt', 'html', 'htm', 'mht', 'pdf', 'djvu', 'fb2', 'epub', 'xps', ]; const cellExtensions = ['xls', 'xlsx', 'xlsm', 'xlt', 'xltx', 'xltm', 'ods', 'fods', 'ots', 'csv']; const slideExtensions = ['ppt', 'pptx', 'pptm', 'pps', 'ppsx', 'ppsm', 'pot', 'potx', 'potm', 'odp', 'fodp', 'otp']; if (wordExtensions.contains(extension)) return 'word'; if (cellExtensions.contains(extension)) return 'cell'; if (slideExtensions.contains(extension)) return 'slide'; return 'word'; } String _generateDocKey(String url) { return sha256.convert(utf8.encode(url)).toString(); } String _normalizeUrl(String url) { var trimmedUrl = url.trim(); if (trimmedUrl.endsWith('/')) { return trimmedUrl.substring(0, trimmedUrl.length - 1); } return trimmedUrl; } }