yx_only_office_flutter/lib/onlyoffice_viewer.dart

370 lines
13 KiB
Dart
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.

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<OnlyOfficeViewer> createState() => _OnlyOfficeViewerState();
}
class _OnlyOfficeViewerState extends State<OnlyOfficeViewer> {
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<void> _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 '''
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>OnlyOffice Viewer</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; width: 100%; overflow: hidden; }
#placeholder { height: 100%; }
</style>
</head>
<body>
<div id="placeholder"></div>
<script type="text/javascript" src="$apiJsUrl"></script>
<script type="text/javascript">
var config = $configJson;
config.events = {
'onAppReady': function() {
console.log('OnlyOffice App Ready');
},
'onDocumentReady': function() {
console.log('OnlyOffice Document Ready');
},
'onError': function(event) {
console.error('OnlyOffice Error:', event.data);
}
};
new DocsAPI.DocEditor("placeholder", config);
</script>
</body>
</html>
''';
}
Map<String, dynamic> _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;
}
}