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

390 lines
14 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: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<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('我智能体 远程终端\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 _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<String>(
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),
),
),
),
],
),
);
}
}