import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:webview_windows/webview_windows.dart'; import 'package:window_manager/window_manager.dart'; const int kControlPort = 18961; const Size kWindowSize = Size(1280, 820); const String kWindowTitle = '浮动小游戏'; const String kDefaultServerBase = 'http://127.0.0.1:8080'; const String kAppFontFamily = 'Bansonic'; Future main(List args) async { WidgetsFlutterBinding.ensureInitialized(); await windowManager.ensureInitialized(); final config = _parseLaunchConfig(args); const windowOptions = WindowOptions( size: kWindowSize, center: true, backgroundColor: Colors.transparent, skipTaskbar: false, titleBarStyle: TitleBarStyle.normal, title: kWindowTitle, ); windowManager.waitUntilReadyToShow(windowOptions, () async { await windowManager.setTitle(kWindowTitle); await windowManager.show(); await windowManager.focus(); }); runApp(FloatingMiniGamesApp(config: config)); } AppLaunchConfig _parseLaunchConfig(List args) { String serverBase = kDefaultServerBase; String? initialUrl; for (final rawArg in args) { final arg = rawArg.trim(); if (arg.isEmpty) { continue; } if (arg.startsWith('--server-base=')) { final value = arg.substring('--server-base='.length).trim(); if (value.isNotEmpty) { serverBase = _normalizeServerBase(value); } continue; } if (arg.startsWith('--open-url=')) { final value = arg.substring('--open-url='.length).trim(); if (value.isNotEmpty) { initialUrl = _normalizeOpenTarget(value); } continue; } initialUrl = _normalizeOpenTarget(arg); } return AppLaunchConfig( serverBase: _normalizeServerBase(serverBase), initialUrl: initialUrl, ); } String _normalizeServerBase(String raw) { final uri = _parseServerBaseUri(raw); return '${uri.scheme}://${uri.authority}'; } Uri _parseServerBaseUri(String raw) { final trimmed = raw.trim().replaceAll('"', '').replaceAll("'", ''); if (trimmed.isEmpty) { return Uri.parse(kDefaultServerBase); } Uri? parsed = Uri.tryParse(trimmed); if (parsed != null && parsed.hasScheme && parsed.host.isNotEmpty) { return parsed.replace(path: '', query: '', fragment: ''); } parsed = Uri.tryParse('http://$trimmed'); if (parsed != null && parsed.host.isNotEmpty) { return parsed.replace(path: '', query: '', fragment: ''); } return Uri.parse(kDefaultServerBase); } Uri _buildServerUri(String serverBase, String path) { final base = _parseServerBaseUri(serverBase); final normalizedPath = path.startsWith('/') ? path : '/$path'; return base.replace(path: normalizedPath, query: null, fragment: null); } String _normalizeOpenTarget(String raw) { final trimmed = raw.trim(); if (trimmed.isEmpty) { return trimmed; } final uri = Uri.tryParse(trimmed); if (uri != null && uri.hasScheme) { return uri.toString(); } final file = File(trimmed); if (file.existsSync()) { return file.absolute.uri.toString(); } if (trimmed.startsWith(r'\\') || RegExp(r'^[A-Za-z]:[\\/]').hasMatch(trimmed)) { return File(trimmed).absolute.uri.toString(); } return trimmed; } class AppLaunchConfig { const AppLaunchConfig({required this.serverBase, this.initialUrl}); final String serverBase; final String? initialUrl; } class MiniGameRecord { const MiniGameRecord({ required this.id, required this.name, required this.gameType, required this.publisher, required this.publishDate, required this.description, required this.playUrl, }); final int id; final String name; final String gameType; final String publisher; final String publishDate; final String description; final String playUrl; factory MiniGameRecord.fromJson(Map json, String serverBase) { final rawPlayUrl = (json['play_url'] ?? '').toString().trim(); return MiniGameRecord( id: int.tryParse((json['id'] ?? '0').toString()) ?? 0, name: (json['name'] ?? '').toString(), gameType: ((json['game_type'] ?? 'official').toString().trim().toLowerCase() == 'community') ? 'community' : 'official', publisher: (json['publisher'] ?? '').toString(), publishDate: (json['publish_date'] ?? '').toString(), description: (json['description'] ?? '').toString(), playUrl: _absolutizePlayUrl(serverBase, rawPlayUrl), ); } } String _absolutizePlayUrl(String serverBase, String playUrl) { if (playUrl.isEmpty) { return playUrl; } final uri = Uri.tryParse(playUrl); if (uri != null && uri.hasScheme) { return uri.toString(); } final slashNormalized = playUrl.startsWith('/') ? playUrl : '/$playUrl'; return '${serverBase.replaceAll(RegExp(r'/+$'), '')}$slashNormalized'; } class FloatingMiniGamesApp extends StatelessWidget { const FloatingMiniGamesApp({super.key, required this.config}); final AppLaunchConfig config; @override Widget build(BuildContext context) { final baseTheme = ThemeData( useMaterial3: true, brightness: Brightness.dark, fontFamily: kAppFontFamily, colorScheme: ColorScheme.fromSeed( seedColor: const Color(0xFF59C6FF), brightness: Brightness.dark, ), scaffoldBackgroundColor: const Color(0xFF0B0D14), snackBarTheme: const SnackBarThemeData( behavior: SnackBarBehavior.floating, ), ); return MaterialApp( title: kWindowTitle, debugShowCheckedModeBanner: false, theme: baseTheme, home: MiniGamesShell(config: config), ); } } class MiniGamesShell extends StatefulWidget { const MiniGamesShell({super.key, required this.config}); final AppLaunchConfig config; @override State createState() => _MiniGamesShellState(); } class _MiniGamesShellState extends State { final WebviewController _controller = WebviewController(); final TextEditingController _searchController = TextEditingController(); HttpServer? _controlServer; StreamSubscription? _titleSubscription; StreamSubscription? _loadingSubscription; StreamSubscription? _urlSubscription; StreamSubscription? _historySubscription; StreamSubscription? _loadErrorSubscription; StreamSubscription? _webMessageSubscription; Timer? _latencyTimer; bool _initialized = false; bool _isLoading = false; bool _showHome = true; bool _gamesLoading = true; bool _canGoBack = false; bool _canGoForward = false; bool _disposed = false; String _statusText = '正在初始化浏览器'; String _currentLabel = '小游戏大厅'; String _currentUrl = ''; String _homeError = ''; String _searchQuery = ''; String _typeFilter = 'all'; String? _queuedOpenUrl; int? _latencyMs; List _games = const []; @override void initState() { super.initState(); _bootstrap(); } Future _bootstrap() async { try { _searchController.addListener(() { if (!mounted) { return; } setState(() { _searchQuery = _searchController.text.trim().toLowerCase(); }); }); await _controller.initialize(); await _controller.setBackgroundColor(const Color(0xFF0B0D14)); await _controller.setPopupWindowPolicy(WebviewPopupWindowPolicy.deny); await _installBrowserGuards(); _bindControllerStreams(); await _startControlServer(); await _refreshLatency(); _latencyTimer = Timer.periodic(const Duration(seconds: 15), (_) => _refreshLatency()); await _loadMiniGames(); if (widget.config.initialUrl != null && widget.config.initialUrl!.isNotEmpty) { await _openUrl(widget.config.initialUrl!, label: '外部页面'); } if (!mounted) { return; } setState(() { _initialized = true; _statusText = '网页已加载'; }); if (_queuedOpenUrl != null && _queuedOpenUrl!.isNotEmpty) { final pending = _queuedOpenUrl!; _queuedOpenUrl = null; await _openUrl(pending, label: '外部页面'); } } catch (error) { if (!mounted) { return; } setState(() { _homeError = '初始化失败:$error'; _gamesLoading = false; _statusText = '初始化失败'; }); } } void _bindControllerStreams() { _loadingSubscription = _controller.loadingState.listen((state) { if (!mounted) { return; } setState(() { _isLoading = state != LoadingState.navigationCompleted; if (!_showHome) { _statusText = _isLoading ? '页面加载中' : '页面已就绪'; } }); }); _urlSubscription = _controller.url.listen((url) { if (!mounted) { return; } setState(() { _currentUrl = url; }); }); _titleSubscription = _controller.title.listen((title) { if (!mounted || title.trim().isEmpty) { return; } setState(() { if (!_showHome) { _currentLabel = title.trim(); } }); }); _historySubscription = _controller.historyChanged.listen((history) { if (!mounted) { return; } setState(() { _canGoBack = history.canGoBack; _canGoForward = history.canGoForward; }); }); _loadErrorSubscription = _controller.onLoadError.listen((status) { _showNotice('页面加载失败:${_describeWebError(status)}'); }); _webMessageSubscription = _controller.webMessage.listen((dynamic message) { if (message is Map) { _handleBrowserMessage(message.map((key, value) => MapEntry(key.toString(), value))); } }); } Future _installBrowserGuards() async { await _controller.addScriptToExecuteOnDocumentCreated(''' (() => { const send = (payload) => { try { if (window.chrome && window.chrome.webview) { window.chrome.webview.postMessage(JSON.stringify(payload)); } } catch (_) {} }; const blockKeys = (event) => { const key = (event.key || '').toLowerCase(); const isF12 = key === 'f12'; const isDevToolsCombo = event.ctrlKey && event.shiftKey && ['i', 'j', 'c'].includes(key); const isSourceCombo = event.ctrlKey && key === 'u'; if (isF12 || isDevToolsCombo || isSourceCombo) { event.preventDefault(); event.stopPropagation(); send({ kind: 'blocked-shortcut', key: key }); return false; } return true; }; document.addEventListener('keydown', blockKeys, true); document.addEventListener('contextmenu', (event) => event.preventDefault(), true); document.addEventListener('wheel', (event) => { if (event.ctrlKey) { event.preventDefault(); } }, { passive: false, capture: true }); window.addEventListener('error', (event) => { send({ kind: 'js-error', message: event.message || '脚本异常', source: event.filename || '', line: event.lineno || 0, column: event.colno || 0 }); }, true); window.addEventListener('unhandledrejection', (event) => { const reason = event && event.reason ? (event.reason.message || String(event.reason)) : 'Promise 未处理异常'; send({ kind: 'promise-error', message: reason }); }); const originalFetch = window.fetch ? window.fetch.bind(window) : null; if (originalFetch) { window.fetch = async (...args) => { try { const response = await originalFetch(...args); if (!response.ok) { send({ kind: 'http-error', url: response.url || '', status: response.status, statusText: response.statusText || '' }); } return response; } catch (error) { send({ kind: 'request-error', url: String(args[0] || ''), message: error && error.message ? error.message : String(error) }); throw error; } }; } const originalOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function(method, url, ...rest) { this.__fdRequestedUrl = url; return originalOpen.call(this, method, url, ...rest); }; const originalSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send = function(...args) { this.addEventListener('error', () => { send({ kind: 'request-error', url: String(this.__fdRequestedUrl || ''), message: 'XMLHttpRequest 失败' }); }); this.addEventListener('loadend', () => { if (this.status >= 400) { send({ kind: 'http-error', url: String(this.responseURL || this.__fdRequestedUrl || ''), status: this.status, statusText: this.statusText || '' }); } }); return originalSend.apply(this, args); }; })(); '''); } Future _startControlServer() async { _controlServer = await HttpServer.bind(InternetAddress.loopbackIPv4, kControlPort); unawaited(_listenForControlRequests(_controlServer!)); } Future _listenForControlRequests(HttpServer server) async { await for (final request in server) { try { final path = request.uri.path; if (request.method == 'GET' && path == '/health') { await _writeJson(request.response, HttpStatus.ok, { 'ok': true, 'initialized': _initialized, 'showHome': _showHome, 'status': _statusText, 'latencyMs': _latencyMs, 'currentUrl': _currentUrl, 'serverBase': widget.config.serverBase, }); continue; } if (path == '/open' && (request.method == 'POST' || request.method == 'GET')) { final targetUrl = await _extractUrl(request); if (targetUrl == null || targetUrl.trim().isEmpty) { await _writeJson(request.response, HttpStatus.badRequest, {'ok': false, 'message': 'missing url'}); continue; } final normalized = _normalizeOpenTarget(targetUrl); if (!_initialized) { _queuedOpenUrl = normalized; await _writeJson(request.response, HttpStatus.accepted, {'ok': true, 'queued': true, 'url': normalized}); continue; } await _openUrl(normalized, label: '外部页面'); await _writeJson(request.response, HttpStatus.ok, {'ok': true, 'queued': false, 'url': normalized}); continue; } if (path == '/home' && (request.method == 'POST' || request.method == 'GET')) { await _goHome(); await _writeJson(request.response, HttpStatus.ok, {'ok': true}); continue; } if (path == '/back' && (request.method == 'POST' || request.method == 'GET')) { if (_canGoBack && !_showHome) { await _controller.goBack(); } await _writeJson(request.response, HttpStatus.ok, {'ok': true, 'canGoBack': _canGoBack}); continue; } if (path == '/forward' && (request.method == 'POST' || request.method == 'GET')) { if (_canGoForward && !_showHome) { await _controller.goForward(); } await _writeJson(request.response, HttpStatus.ok, {'ok': true, 'canGoForward': _canGoForward}); continue; } if (path == '/refresh' && (request.method == 'POST' || request.method == 'GET')) { await _refreshCurrent(); await _writeJson(request.response, HttpStatus.ok, {'ok': true}); continue; } await _writeJson(request.response, HttpStatus.notFound, {'ok': false, 'message': 'not found'}); } catch (error) { await _writeJson(request.response, HttpStatus.internalServerError, {'ok': false, 'message': error.toString()}); } } } Future _extractUrl(HttpRequest request) async { if (request.method == 'GET') { return request.uri.queryParameters['url']; } final body = await utf8.decoder.bind(request).join(); if (body.trim().isEmpty) { return null; } try { final decoded = jsonDecode(body); if (decoded is Map) { return decoded['url']?.toString(); } if (decoded is Map) { return decoded['url']?.toString(); } } catch (_) { return body.trim(); } return null; } Future _writeJson(HttpResponse response, int statusCode, Map payload) async { response.statusCode = statusCode; response.headers.contentType = ContentType.json; response.write(jsonEncode(payload)); await response.close(); } Future _refreshLatency() async { final stopwatch = Stopwatch()..start(); try { final uri = _buildServerUri(widget.config.serverBase, '/api/ping'); final client = HttpClient()..connectionTimeout = const Duration(seconds: 5); try { final request = await client.getUrl(uri); final response = await request.close().timeout(const Duration(seconds: 5)); await response.drain(); stopwatch.stop(); if (mounted) { setState(() { _latencyMs = stopwatch.elapsedMilliseconds; }); } } finally { client.close(force: true); } } catch (_) { if (mounted) { setState(() { _latencyMs = null; }); } } } Future _loadMiniGames() async { if (mounted) { setState(() { _gamesLoading = true; _homeError = ''; }); } try { final client = HttpClient()..connectionTimeout = const Duration(seconds: 8); try { final candidates = [ _buildServerUri(widget.config.serverBase, '/api/mini-games').toString(), _buildServerUri(widget.config.serverBase, '/api/minigames').toString(), _buildServerUri(widget.config.serverBase, '/api/easy-games').toString(), _buildServerUri(widget.config.serverBase, '/mini-games/list').toString(), _buildServerUri(widget.config.serverBase, '/minigames').toString(), _buildServerUri(widget.config.serverBase, '/easy-games/list').toString(), _buildServerUri(widget.config.serverBase, '/easygames').toString(), _buildServerUri(widget.config.serverBase, '/admin/api/mini-games').toString(), ]; HttpException? lastHttpError; String body = ''; int statusCode = 0; Map? decoded; for (final candidate in candidates) { final request = await client.getUrl(Uri.parse(candidate)); final response = await request.close().timeout(const Duration(seconds: 8)); body = await utf8.decoder.bind(response).join(); statusCode = response.statusCode; if (response.statusCode == HttpStatus.ok) { final rawDecoded = jsonDecode(body); if (rawDecoded is Map) { decoded = rawDecoded; break; } if (rawDecoded is Map) { decoded = rawDecoded.cast(); break; } throw HttpException('HTTP 200: invalid payload'); } lastHttpError = HttpException('HTTP ${response.statusCode}: $body'); if (response.statusCode != HttpStatus.notFound) { break; } } if (decoded == null) { throw lastHttpError ?? HttpException('HTTP $statusCode: $body'); } final list = decoded['games']; final items = []; if (list is List) { for (final entry in list) { if (entry is Map) { items.add(MiniGameRecord.fromJson(entry.cast(), widget.config.serverBase)); } } } if (!mounted) { return; } setState(() { _games = items; _gamesLoading = false; _statusText = '小游戏大厅已同步'; }); } finally { client.close(force: true); } } catch (error) { if (!mounted) { return; } setState(() { _gamesLoading = false; _homeError = '无法拉取小游戏列表:$error'; _statusText = '无法访问小游戏服务'; }); } } Future _openGame(MiniGameRecord game) async { await _openUrl(game.playUrl, label: game.name); } Future _openUrl(String rawUrl, {String? label}) async { final normalized = _normalizeOpenTarget(rawUrl); if (!_initialized) { _queuedOpenUrl = normalized; return; } await _controller.loadUrl(normalized); if (!mounted) { return; } setState(() { _showHome = false; _isLoading = true; _currentUrl = normalized; _currentLabel = (label == null || label.trim().isEmpty) ? '网页内容' : label.trim(); _statusText = '正在打开页面'; }); await windowManager.show(); await windowManager.focus(); } Future _goHome() async { if (!_showHome) { try { await _controller.stop(); } catch (_) {} } if (!mounted) { return; } setState(() { _showHome = true; _isLoading = false; _canGoBack = false; _canGoForward = false; _currentUrl = ''; _currentLabel = '小游戏大厅'; _statusText = '小游戏大厅已就绪'; }); await _loadMiniGames(); } Future _refreshCurrent() async { if (_showHome) { await _loadMiniGames(); return; } try { await _controller.reload(); } catch (_) {} } String _describeWebError(WebErrorStatus status) { switch (status) { case WebErrorStatus.WebErrorStatusTimeout: return '请求超时'; case WebErrorStatus.WebErrorStatusConnectionAborted: return '连接被中止'; case WebErrorStatus.WebErrorStatusConnectionReset: return '连接被重置'; case WebErrorStatus.WebErrorStatusCannotConnect: return '连接失败'; case WebErrorStatus.WebErrorStatusDisconnected: return '网络已断开'; case WebErrorStatus.WebErrorStatusHostNameNotResolved: return '无法解析主机名'; case WebErrorStatus.WebErrorStatusServerUnreachable: return '服务器不可达'; case WebErrorStatus.WebErrorStatusErrorHTTPInvalidServerResponse: return '服务器响应无效'; default: return status.name; } } void _handleBrowserMessage(Map message) { final kind = (message['kind'] ?? '').toString(); switch (kind) { case 'blocked-shortcut': _showNotice('已禁用开发者快捷键'); break; case 'js-error': _showNotice('页面脚本异常:${message['message'] ?? '未知错误'}'); break; case 'promise-error': _showNotice('页面 Promise 异常:${message['message'] ?? '未知错误'}'); break; case 'http-error': final status = message['status']?.toString() ?? '--'; final url = message['url']?.toString() ?? ''; _showNotice('页面请求失败:HTTP $status${url.isEmpty ? '' : ' · $url'}'); break; case 'request-error': final url = message['url']?.toString() ?? ''; final detail = message['message']?.toString() ?? '网络请求异常'; _showNotice('页面请求异常:$detail${url.isEmpty ? '' : ' · $url'}'); break; default: break; } } void _showNotice(String message) { if (!mounted || _disposed) { return; } final messenger = ScaffoldMessenger.maybeOf(context); messenger?.hideCurrentSnackBar(); messenger?.showSnackBar( SnackBar( content: Text(message), duration: const Duration(seconds: 3), ), ); } @override void dispose() { _disposed = true; _searchController.dispose(); _latencyTimer?.cancel(); _titleSubscription?.cancel(); _loadingSubscription?.cancel(); _urlSubscription?.cancel(); _historySubscription?.cancel(); _loadErrorSubscription?.cancel(); _webMessageSubscription?.cancel(); _controlServer?.close(force: true); unawaited(_controller.dispose()); super.dispose(); } @override Widget build(BuildContext context) { final latencyText = _latencyMs == null ? '延迟 --' : '延迟 $_latencyMs ms'; return Scaffold( body: Column( children: [ Container( height: 64, decoration: const BoxDecoration( color: Color(0xFF121826), border: Border(bottom: BorderSide(color: Color(0x222D4B73))), ), padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ ClipRRect( borderRadius: BorderRadius.circular(10), child: Image.asset( 'assets/bansonic icon.png', width: 36, height: 36, fit: BoxFit.cover, ), ), const SizedBox(width: 12), Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( kWindowTitle, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), ), Text( _currentLabel, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: Color(0xFFAFC2DE)), ), ], ), ), Text( latencyText, style: const TextStyle(fontSize: 12, color: Color(0xFF8FB3D9)), ), const SizedBox(width: 10), IconButton( tooltip: '刷新', onPressed: _refreshCurrent, icon: const Icon(Icons.refresh_rounded), ), IconButton( tooltip: '后退', onPressed: (!_showHome && _canGoBack) ? () => _controller.goBack() : null, icon: const Icon(Icons.arrow_back_rounded), ), IconButton( tooltip: '前进', onPressed: (!_showHome && _canGoForward) ? () => _controller.goForward() : null, icon: const Icon(Icons.arrow_forward_rounded), ), IconButton( tooltip: '回到主页', onPressed: _showHome ? null : _goHome, icon: const Icon(Icons.home_rounded), ), ], ), ), Expanded( child: Stack( children: [ Positioned.fill( child: AnimatedSwitcher( duration: const Duration(milliseconds: 180), child: _showHome ? _buildHomeView() : Webview(_controller), ), ), if (_isLoading && !_showHome) const Positioned.fill( child: IgnorePointer( child: Center(child: CircularProgressIndicator()), ), ), ], ), ), ], ), ); } Widget _buildHomeView() { final filteredGames = _games.where((game) { if (_typeFilter != 'all' && game.gameType != _typeFilter) { return false; } if (_searchQuery.isEmpty) { return true; } final haystack = '${game.id} ${game.name} ${game.publisher} ${game.description}'.toLowerCase(); return haystack.contains(_searchQuery); }).toList(); return Container( key: const ValueKey('home-view'), color: const Color(0xFF0B0D14), child: Column( children: [ Container( margin: const EdgeInsets.fromLTRB(16, 16, 16, 10), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration( color: const Color(0xFF151E2E), borderRadius: BorderRadius.circular(16), border: Border.all(color: const Color(0x223F628D)), ), child: Row( children: [ const Icon(Icons.sports_esports_rounded, color: Color(0xFF71D4FF)), const SizedBox(width: 10), const Expanded( child: Text( '已连接小游戏大厅。这里展示服务端已发布的 H5 小游戏。', style: TextStyle(fontSize: 13, color: Color(0xFFD6E5F7)), ), ), FilledButton.tonalIcon( onPressed: _gamesLoading ? null : _loadMiniGames, icon: const Icon(Icons.refresh_rounded), label: const Text('刷新'), ), ], ), ), Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Row( children: [ Expanded( child: TextField( controller: _searchController, decoration: InputDecoration( hintText: '搜索 ID / 名称 / 发布者', prefixIcon: const Icon(Icons.search_rounded), filled: true, fillColor: const Color(0xFF151E2E), border: OutlineInputBorder( borderRadius: BorderRadius.circular(14), borderSide: const BorderSide(color: Color(0x223F628D)), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(14), borderSide: const BorderSide(color: Color(0x223F628D)), ), ), ), ), const SizedBox(width: 12), Container( padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( color: const Color(0xFF151E2E), borderRadius: BorderRadius.circular(14), border: Border.all(color: const Color(0x223F628D)), ), child: DropdownButtonHideUnderline( child: DropdownButton( value: _typeFilter, dropdownColor: const Color(0xFF151E2E), items: const [ DropdownMenuItem(value: 'all', child: Text('全部类型')), DropdownMenuItem(value: 'official', child: Text('官方')), DropdownMenuItem(value: 'community', child: Text('社区')), ], onChanged: (value) { if (value == null) { return; } setState(() { _typeFilter = value; }); }, ), ), ), ], ), ), Expanded( child: Builder( builder: (context) { if (_gamesLoading) { return const Center(child: CircularProgressIndicator()); } if (_homeError.isNotEmpty) { return Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.cloud_off_rounded, size: 56, color: Color(0xFF6E8BA9)), const SizedBox(height: 16), Text(_homeError, textAlign: TextAlign.center, style: const TextStyle(color: Color(0xFFC7D6EA))), const SizedBox(height: 16), FilledButton(onPressed: _loadMiniGames, child: const Text('重新尝试')), ], ), ), ); } if (_games.isEmpty) { return const Center( child: Text('当前没有已发布的小游戏', style: TextStyle(color: Color(0xFFAFC2DE))), ); } if (filteredGames.isEmpty) { return const Center( child: Text('没有符合条件的小游戏', style: TextStyle(color: Color(0xFFAFC2DE))), ); } return ListView.separated( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), itemCount: filteredGames.length, separatorBuilder: (_, _) => const SizedBox(height: 12), itemBuilder: (context, index) { final game = filteredGames[index]; return _MiniGameCard( game: game, onPlay: () => _openGame(game), ); }, ); }, ), ), ], ), ); } } class _MiniGameCard extends StatelessWidget { const _MiniGameCard({required this.game, required this.onPlay}); final MiniGameRecord game; final VoidCallback onPlay; @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( color: const Color(0xFF151E2E), borderRadius: BorderRadius.circular(16), border: Border.all(color: const Color(0x223F628D)), ), child: Material( color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(16), onTap: onPlay, child: Padding( padding: const EdgeInsets.all(16), child: Row( children: [ Container( width: 68, height: 68, decoration: BoxDecoration( color: const Color(0xFF20314A), borderRadius: BorderRadius.circular(16), ), child: const Icon(Icons.extension_rounded, size: 34, color: Color(0xFF7ED3FF)), ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(game.name, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), const SizedBox(height: 6), Text( game.description.isEmpty ? '暂无简介' : game.description, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: Color(0xFFAFC2DE)), ), const SizedBox(height: 10), Wrap( spacing: 8, runSpacing: 8, children: [ _MetaChip(label: 'ID ${game.id}'), _MetaChip(label: game.gameType == 'community' ? '社区' : '官方'), _MetaChip(label: game.publisher.isEmpty ? '发布者未填写' : game.publisher), _MetaChip(label: game.publishDate.isEmpty ? '未设置发布时间' : game.publishDate), ], ), ], ), ), const SizedBox(width: 16), FilledButton.icon( onPressed: onPlay, icon: const Icon(Icons.play_arrow_rounded), label: const Text('开玩'), ), ], ), ), ), ), ); } } class _MetaChip extends StatelessWidget { const _MetaChip({required this.label}); final String label; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: const Color(0xFF20314A), borderRadius: BorderRadius.circular(999), ), child: Text(label, style: const TextStyle(fontSize: 11, color: Color(0xFFD6E5F7))), ); } }