it0/it0_app/lib/features/terminal/presentation/pages/terminal_page.dart

389 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:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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<List<Map<String, dynamic>>>((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<Map<String, dynamic>>();
if (data is Map && data.containsKey('items')) {
return (data['items'] as List).cast<Map<String, dynamic>>();
}
return [];
});
// ---------------------------------------------------------------------------
// Connection status enum
// ---------------------------------------------------------------------------
enum _ConnectionStatus { disconnected, connecting, connected }
// ---------------------------------------------------------------------------
// Terminal page ConsumerStatefulWidget
// ---------------------------------------------------------------------------
class TerminalPage extends ConsumerStatefulWidget {
const TerminalPage({super.key});
@override
ConsumerState<TerminalPage> createState() => _TerminalPageState();
}
class _TerminalPageState extends ConsumerState<TerminalPage> {
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('iAgent 远程终端\r\n');
_terminal.write('请选择服务器并点击连接。\r\n');
}
@override
void dispose() {
_disconnect();
super.dispose();
}
// ---------- WebSocket lifecycle ------------------------------------------
Future<void> _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 = <String, String>{
'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<String, dynamic>;
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 get _statusLabel {
switch (_status) {
case _ConnectionStatus.connected:
return '已连接';
case _ConnectionStatus.connecting:
return '连接中...';
case _ConnectionStatus.disconnected:
return '未连接';
}
}
// ---------- Build --------------------------------------------------------
@override
Widget build(BuildContext context) {
final serversAsync = ref.watch(_terminalServersProvider);
return Scaffold(
appBar: AppBar(
title: const Text('远程终端'),
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,
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 const Text(
'暂无可用服务器',
style: TextStyle(
color: AppColors.textMuted,
fontSize: 14,
),
);
}
return DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedServerId,
hint: const Text(
'选择服务器...',
style: 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(
'加载服务器失败',
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
? '断开'
: _status == _ConnectionStatus.connecting
? '连接中...'
: '连接',
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),
),
),
),
],
),
);
}
}