import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:it0_app/l10n/app_localizations.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:xterm/xterm.dart'; import '../../../../core/config/api_endpoints.dart'; import '../../../../core/config/app_config.dart'; import '../../../../core/network/dio_client.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../auth/data/providers/auth_provider.dart'; // --------------------------------------------------------------------------- // TODO 2E – Remote Terminal page: xterm + WebSocket // --------------------------------------------------------------------------- /// Fetches the list of servers for the server-selector dropdown. final _terminalServersProvider = FutureProvider>>((ref) async { final dio = ref.watch(dioClientProvider); final response = await dio.get(ApiEndpoints.servers); final data = response.data; if (data is List) return data.cast>(); if (data is Map && data.containsKey('items')) { return (data['items'] as List).cast>(); } return []; }); // --------------------------------------------------------------------------- // Connection status enum // --------------------------------------------------------------------------- enum _ConnectionStatus { disconnected, connecting, connected } // --------------------------------------------------------------------------- // Terminal page – ConsumerStatefulWidget // --------------------------------------------------------------------------- class TerminalPage extends ConsumerStatefulWidget { const TerminalPage({super.key}); @override ConsumerState createState() => _TerminalPageState(); } class _TerminalPageState extends ConsumerState { late final Terminal _terminal; WebSocketChannel? _channel; StreamSubscription? _wsSubscription; _ConnectionStatus _status = _ConnectionStatus.disconnected; String? _selectedServerId; @override void initState() { super.initState(); _terminal = Terminal(maxLines: 10000); _terminal.onOutput = _onTerminalInput; _terminal.write('我智能体 远程终端\r\n'); _terminal.write('请选择服务器并点击连接。\r\n'); } @override void dispose() { _disconnect(); super.dispose(); } // ---------- WebSocket lifecycle ------------------------------------------ Future _connect() async { if (_selectedServerId == null || _selectedServerId!.isEmpty) { _terminal.write('\r\n\x1B[33m[!] 请先选择服务器\x1B[0m\r\n'); return; } setState(() => _status = _ConnectionStatus.connecting); _terminal.write('\r\n\x1B[33m[*] 正在连接服务器 $_selectedServerId...\x1B[0m\r\n'); try { final config = ref.read(appConfigProvider); final storage = ref.read(secureStorageProvider); final token = await storage.read(key: 'access_token'); final queryParams = { 'serverId': _selectedServerId!, }; if (token != null) { queryParams['token'] = token; } final uri = Uri.parse( '${config.wsBaseUrl}${ApiEndpoints.wsTerminal}', ).replace(queryParameters: queryParams); _channel = WebSocketChannel.connect(uri); // Wait for the connection to be ready await _channel!.ready; _wsSubscription = _channel!.stream.listen( _onWsMessage, onDone: _onWsDone, onError: _onWsError, ); if (mounted) { setState(() => _status = _ConnectionStatus.connected); _terminal.write('\x1B[32m[+] 已连接\x1B[0m\r\n'); } } catch (e) { if (mounted) { setState(() => _status = _ConnectionStatus.disconnected); _terminal.write('\x1B[31m[-] 连接失败: $e\x1B[0m\r\n'); } } } void _disconnect() { _wsSubscription?.cancel(); _wsSubscription = null; _channel?.sink.close(); _channel = null; if (mounted) { setState(() => _status = _ConnectionStatus.disconnected); } } // ---------- WebSocket message handlers ----------------------------------- void _onWsMessage(dynamic data) { if (data is String) { // Try decoding as JSON first (server may wrap output in a message // envelope like {"type":"output","data":"..."}). try { final decoded = jsonDecode(data) as Map; final output = decoded['data'] as String? ?? decoded['output'] as String? ?? ''; if (output.isNotEmpty) { _terminal.write(output); } } catch (_) { // Not JSON – treat as raw terminal output. _terminal.write(data); } } } void _onWsDone() { if (mounted) { setState(() => _status = _ConnectionStatus.disconnected); _terminal.write('\r\n\x1B[33m[*] 连接已关闭\x1B[0m\r\n'); } } void _onWsError(dynamic error) { if (mounted) { setState(() => _status = _ConnectionStatus.disconnected); _terminal.write('\r\n\x1B[31m[-] WebSocket 错误: $error\x1B[0m\r\n'); } } // ---------- Terminal input handler ---------------------------------------- void _onTerminalInput(String data) { if (_status != _ConnectionStatus.connected || _channel == null) { return; } // Send raw input to the server via WebSocket. _channel!.sink.add(jsonEncode({'type': 'input', 'data': data})); } // ---------- UI helpers --------------------------------------------------- Color get _statusColor { switch (_status) { case _ConnectionStatus.connected: return AppColors.success; case _ConnectionStatus.connecting: return AppColors.warning; case _ConnectionStatus.disconnected: return AppColors.error; } } String _statusLabel(BuildContext context) { switch (_status) { case _ConnectionStatus.connected: return AppLocalizations.of(context).terminalConnectedLabel; case _ConnectionStatus.connecting: return AppLocalizations.of(context).terminalConnectingLabel; case _ConnectionStatus.disconnected: return AppLocalizations.of(context).terminalDisconnectedLabel; } } // ---------- Build -------------------------------------------------------- @override Widget build(BuildContext context) { final serversAsync = ref.watch(_terminalServersProvider); return Scaffold( appBar: AppBar( title: Text(AppLocalizations.of(context).terminalTitle), actions: [ // Connection status indicator Padding( padding: const EdgeInsets.only(right: 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 10, height: 10, decoration: BoxDecoration( color: _statusColor, shape: BoxShape.circle, ), ), const SizedBox(width: 6), Text( _statusLabel(context), style: TextStyle( color: _statusColor, fontSize: 12, fontWeight: FontWeight.w500, ), ), ], ), ), // Reconnect / Disconnect button if (_status == _ConnectionStatus.disconnected) IconButton( icon: const Icon(Icons.refresh), tooltip: '重新连接', onPressed: _selectedServerId != null ? _connect : null, ) else if (_status == _ConnectionStatus.connected) IconButton( icon: const Icon(Icons.link_off), tooltip: '断开连接', onPressed: () { _disconnect(); _terminal.write('\r\n\x1B[33m[*] 已断开连接\x1B[0m\r\n'); }, ), ], ), body: Column( children: [ // ---- Server selector bar ---------------------------------------- Container( color: AppColors.surface, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( children: [ const Icon(Icons.dns, size: 20, color: AppColors.textSecondary), const SizedBox(width: 8), Expanded( child: serversAsync.when( data: (servers) { if (servers.isEmpty) { return Text( AppLocalizations.of(context).terminalNoAvailableServers, style: const TextStyle( color: AppColors.textMuted, fontSize: 14, ), ); } return DropdownButtonHideUnderline( child: DropdownButton( value: _selectedServerId, hint: Text( AppLocalizations.of(context).terminalSelectServerHint, style: const TextStyle( color: AppColors.textMuted, fontSize: 14, ), ), isExpanded: true, dropdownColor: AppColors.surface, style: const TextStyle( color: AppColors.textPrimary, fontSize: 14, ), items: servers.map((s) { final id = s['id']?.toString() ?? ''; final hostname = s['hostname'] as String? ?? s['name'] as String? ?? id; final ip = s['ip'] as String? ?? s['address'] as String? ?? ''; return DropdownMenuItem( value: id, child: Text( ip.isNotEmpty ? '$hostname ($ip)' : hostname, overflow: TextOverflow.ellipsis, ), ); }).toList(), onChanged: _status == _ConnectionStatus.connected ? null : (value) { setState(() => _selectedServerId = value); }, ), ); }, loading: () => const SizedBox( height: 20, child: LinearProgressIndicator(), ), error: (e, _) => Row( children: [ const Icon(Icons.error_outline, size: 16, color: AppColors.error), const SizedBox(width: 6), Expanded( child: Text( AppLocalizations.of(context).terminalLoadServersError, style: const TextStyle( color: AppColors.error, fontSize: 13, ), ), ), IconButton( icon: const Icon(Icons.refresh, size: 18), onPressed: () => ref.invalidate(_terminalServersProvider), ), ], ), ), ), const SizedBox(width: 8), // Connect button SizedBox( height: 36, child: FilledButton.icon( onPressed: _status == _ConnectionStatus.connecting ? null : _status == _ConnectionStatus.connected ? () { _disconnect(); _terminal.write( '\r\n\x1B[33m[*] 已断开连接\x1B[0m\r\n'); } : _connect, icon: Icon( _status == _ConnectionStatus.connected ? Icons.link_off : Icons.play_arrow, size: 18, ), label: Text( _status == _ConnectionStatus.connected ? AppLocalizations.of(context).terminalDisconnectButton : _status == _ConnectionStatus.connecting ? AppLocalizations.of(context).terminalConnectingMessage : AppLocalizations.of(context).terminalConnectButton, style: const TextStyle(fontSize: 13), ), ), ), ], ), ), // ---- xterm terminal view ---------------------------------------- Expanded( child: Container( color: Colors.black, child: TerminalView( _terminal, textStyle: const TerminalStyle( fontSize: 14, fontFamily: 'monospace', ), padding: const EdgeInsets.all(4), ), ), ), ], ), ); } }