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