import 'dart:async'; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter_android/webview_flutter_android.dart'; import 'config.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); // 全面屏:内容延伸到状态栏/导航栏之下,刘海与手势区交给 SafeArea 处理 await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); runApp(const DshApp()); } class DshApp extends StatelessWidget { const DshApp({super.key}); @override Widget build(BuildContext context) { return AnnotatedRegion( // 站点为深色主题:状态栏透明、图标反白 value: const SystemUiOverlayStyle( statusBarColor: Colors.transparent, statusBarIconBrightness: Brightness.light, systemNavigationBarColor: Colors.transparent, systemNavigationBarIconBrightness: Brightness.light, ), child: MaterialApp( title: 'DSH 工作台', debugShowCheckedModeBanner: false, theme: ThemeData( useMaterial3: true, colorScheme: ColorScheme.fromSeed( seedColor: const Color(kThemeColorValue), brightness: Brightness.dark, ), scaffoldBackgroundColor: const Color(kThemeColorValue), ), home: const HomePage(), ), ); } } class HomePage extends StatefulWidget { const HomePage({super.key}); @override State createState() => _HomePageState(); } class _HomePageState extends State { /// 原生下载通道(MainActivity 中用系统 DownloadManager 实现) static const MethodChannel _downloadsChannel = MethodChannel( 'dsh.workbench/downloads', ); static final Uri _siteUri = Uri.parse(kSiteUrl); late final WebViewController _controller; bool _loading = true; String? _loadError; @override void initState() { super.initState(); _controller = _buildController(); } WebViewController _buildController() { final controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setBackgroundColor(const Color(kThemeColorValue)) ..setNavigationDelegate( NavigationDelegate( onPageStarted: (_) { if (mounted) { setState(() { _loading = true; _loadError = null; }); } }, onPageFinished: (_) { if (mounted) setState(() => _loading = false); }, onWebResourceError: (error) { // 只把主文档加载失败视作页面错误;子资源错误(图片等) // 以及被新导航打断(ERR_ABORTED)的情况交给页面自行处理 if (!(error.isForMainFrame ?? false)) return; if (error.description.contains('ERR_ABORTED')) return; if (mounted) { setState(() { _loading = false; _loadError = error.description; }); } }, onNavigationRequest: _onNavigationRequest, ), ) ..loadRequest(Uri.parse(kSiteUrl)); // Android 专属能力:文件选择器(上传)、原生 JS 对话框(alert/confirm) final platform = controller.platform; if (platform is AndroidWebViewController) { platform ..setOnShowFileSelector(_onShowFileSelector) ..setOnJavaScriptAlertDialog(_onJavaScriptAlert) ..setOnJavaScriptConfirmDialog(_onJavaScriptConfirm); } return controller; } /// 面板用 window.location.assign 触发下载(文件/目录 ZIP/备份包), /// 这里拦下来交给原生 DownloadManager(携带登录 Cookie),WebView 停留原页。 NavigationDecision _onNavigationRequest(NavigationRequest request) { final uri = Uri.tryParse(request.url); if (uri != null && _isPanelDownload(uri)) { _startDownload(uri); return NavigationDecision.prevent; } return NavigationDecision.navigate; } bool _isPanelDownload(Uri uri) { if (uri.host != _siteUri.host) return false; // 工作区文件/目录 ZIP 下载 if (uri.path.endsWith('/api/ws/download')) return true; // 配置备份包下载 if (uri.path.endsWith('/backups') && uri.queryParameters.containsKey('download')) { return true; } return false; } Future _startDownload(Uri uri) async { try { await _downloadsChannel.invokeMethod('download', { 'url': uri.toString(), }); } on PlatformException catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('下载失败:${e.message ?? '未知错误'}')), ); } } } /// WebView 里的 上传(工作区上传/备份导入)。 /// Android 的类型过滤一次只支持单一 MIME 或单一扩展名, /// 多约束时退化为不限类型,避免选择器异常。 Future> _onShowFileSelector(FileSelectorParams params) async { final typeGroups = []; final mimes = params.acceptTypes.where((t) => t.contains('/')).toList(); if (mimes.length == 1) { typeGroups.add(XTypeGroup(mimeTypes: mimes)); } else { final exts = params.acceptTypes .where((t) => t.startsWith('.')) .map((t) => t.toLowerCase()) .toList(); if (exts.length == 1) typeGroups.add(XTypeGroup(extensions: exts)); } if (params.mode == FileSelectorMode.openMultiple) { final files = await openFiles(acceptedTypeGroups: typeGroups); return files.map((f) => f.path).toList(); } final file = await openFile(acceptedTypeGroups: typeGroups); return file == null ? const [] : [file.path]; } /// 面板少量路径仍使用 window.alert / window.confirm, /// 未注册回调时 WebView 会静默返回 false,必须在这里补上。 Future _onJavaScriptAlert(JavaScriptAlertDialogRequest request) async { if (!mounted) return; await showDialog( context: context, builder: (context) => AlertDialog( content: Text(request.message), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('确定'), ), ], ), ); } Future _onJavaScriptConfirm( JavaScriptConfirmDialogRequest request, ) async { if (!mounted) return false; final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( content: Text(request.message), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('取消'), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: const Text('确定'), ), ], ), ); return confirmed ?? false; } Future _onPopInvoked(bool didPop, Object? result) async { if (didPop) return; // 先回退 WebView 历史(SPA 内部导航),没有历史再退出应用 if (await _controller.canGoBack()) { await _controller.goBack(); } else { await SystemNavigator.pop(); } } void _retry() { setState(() { _loadError = null; _loading = true; }); unawaited(_controller.reload()); } @override Widget build(BuildContext context) { return PopScope( canPop: false, onPopInvokedWithResult: _onPopInvoked, child: Scaffold( backgroundColor: const Color(kThemeColorValue), body: SafeArea( child: Stack( children: [ WebViewWidget(controller: _controller), if (_loadError != null) _ErrorView(message: _loadError!, onRetry: _retry) else if (_loading) const _LoadingView(), ], ), ), ), ); } } class _LoadingView extends StatelessWidget { const _LoadingView(); @override Widget build(BuildContext context) { return const ColoredBox( color: Color(kThemeColorValue), child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(color: Colors.white70), SizedBox(height: 16), Text('正在加载…', style: TextStyle(color: Colors.white70)), ], ), ), ); } } class _ErrorView extends StatelessWidget { const _ErrorView({required this.message, required this.onRetry}); final String message; final VoidCallback onRetry; @override Widget build(BuildContext context) { return ColoredBox( color: const Color(kThemeColorValue), child: Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.cloud_off, size: 56, color: Colors.white54), const SizedBox(height: 16), const Text( '无法连接到服务器', style: TextStyle(color: Colors.white, fontSize: 16), ), const SizedBox(height: 8), Text( message, textAlign: TextAlign.center, maxLines: 3, overflow: TextOverflow.ellipsis, style: const TextStyle(color: Colors.white38, fontSize: 12), ), const SizedBox(height: 20), FilledButton.icon( onPressed: onRetry, icon: const Icon(Icons.refresh), label: const Text('重试'), ), ], ), ), ), ); } }