feat: native端docker支持

This commit is contained in:
chaoszhu
2026-06-27 20:14:59 +08:00
parent fd3e9bd965
commit 53f4609679
13 changed files with 2509 additions and 66 deletions
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:serif="http://www.serif.com/"
width="38" height="30" viewBox="0 0 38 30" version="1.1" xml:space="preserve"
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g>
<clipPath id="_clip1">
<rect x="0" y="-0.018" width="140" height="30.018"></rect>
</clipPath>
<g clip-path="url(#_clip1)">
<path
d="M37.413,12.315c-0.933,-0.627 -3.382,-0.896 -5.163,-0.415c-0.097,-1.774 -1.012,-3.27 -2.685,-4.573l-0.619,-0.416l-0.414,0.624c-0.811,1.232 -1.153,2.873 -1.032,4.366c0.095,0.92 0.415,1.953 1.032,2.703c-2.318,1.345 -4.455,1.039 -13.919,1.039l-14.61,0c-0.042,2.137 0.302,6.248 2.915,9.593c0.289,0.37 0.604,0.727 0.948,1.072c2.126,2.128 5.336,3.689 10.138,3.693c7.325,0.007 13.601,-3.953 17.419,-13.527c1.257,0.02 4.572,0.225 6.195,-2.911c0.039,-0.052 0.413,-0.831 0.413,-0.831l-0.618,-0.415l0,-0.002Zm-27.875,-1.953l-4.108,0l-0,4.108l4.108,0l0,-4.108Zm5.308,0l-4.108,0l-0,4.108l4.108,0l0,-4.108Zm5.308,0l-4.108,0l-0,4.108l4.108,0l0,-4.108Zm5.308,0l-4.108,0l-0,4.108l4.108,0l0,-4.108Zm-21.232,0l-4.108,0l-0,4.108l4.108,0l0,-4.108Zm5.308,-5.19l-4.108,-0l-0,4.108l4.108,0l0,-4.108Zm5.308,-0l-4.108,-0l-0,4.108l4.108,0l0,-4.108Zm5.308,-0l-4.108,-0l-0,4.108l4.108,0l0,-4.108Zm0,-5.19l-4.108,0l-0,4.109l4.108,-0l0,-4.109Z"
style="fill:var(--iconColor, #00153c);fill-rule:nonzero;"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,57 @@
class DockerContainer {
const DockerContainer({
required this.id,
required this.name,
required this.image,
required this.status,
required this.ports,
required this.createdAt,
required this.uptime,
});
final String id;
final String name;
final String image;
final String status;
final List<String> ports;
final String createdAt;
final String uptime;
factory DockerContainer.fromJson(Map<String, dynamic> json) {
final rawPorts = json['ports'];
return DockerContainer(
id: (json['id'] ?? '').toString(),
name: (json['name'] ?? '').toString(),
image: (json['image'] ?? '').toString(),
status: (json['status'] ?? '').toString(),
ports: rawPorts is List
? rawPorts.map((port) => port.toString()).toList(growable: false)
: const [],
createdAt: (json['createdAt'] ?? '').toString(),
uptime: (json['uptime'] ?? '').toString(),
);
}
String get shortId => id.length <= 12 ? id : id.substring(0, 12);
bool get isRunning => status == 'running';
}
class DockerOperationResult {
const DockerOperationResult({required this.success, required this.message});
final bool success;
final String message;
factory DockerOperationResult.fromJson(dynamic json) {
if (json is Map) {
return DockerOperationResult(
success: json['success'] == true,
message: (json['message'] ?? '').toString(),
);
}
return const DockerOperationResult(
success: false,
message: 'Unknown error',
);
}
}
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
class DockerIcon extends StatelessWidget {
const DockerIcon({super.key, this.size, this.visualScale = 0.82});
final double? size;
final double visualScale;
@override
Widget build(BuildContext context) {
final iconTheme = IconTheme.of(context);
final iconSize = size ?? iconTheme.size ?? 24;
final imageSize = iconSize * visualScale;
return SizedBox.square(
dimension: iconSize,
child: Center(
child: SvgPicture.asset(
'assets/docker.svg',
width: imageSize,
height: imageSize,
fit: BoxFit.contain,
colorFilter: ColorFilter.mode(
iconTheme.color ?? Theme.of(context).colorScheme.onSurface,
BlendMode.srcIn,
),
),
),
);
}
}
@@ -0,0 +1,218 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show ChangeNotifier;
import '../../core/api/cookie_store.dart';
import '../auth/auth_session.dart';
import '../servers/server_model.dart';
import 'docker_container.dart';
import 'docker_socket_client.dart';
enum DockerConnectionStatus { connecting, connected, disconnected, error }
class DockerSessionState {
DockerSessionState({
required this.server,
required this.client,
this.status = DockerConnectionStatus.connecting,
this.containers = const [],
this.logs = '',
this.errorMessage,
this.loading = true,
this.refreshing = false,
});
final ServerModel server;
final DockerSocketClient client;
DockerConnectionStatus status;
List<DockerContainer> containers;
String logs;
String? errorMessage;
bool loading;
bool refreshing;
final Set<String> operatingIds = {};
}
class DockerSessionManager extends ChangeNotifier {
final Map<String, DockerSessionState> _sessions = {};
final Map<String, List<StreamSubscription<dynamic>>> _subscriptions = {};
String? _activeHostId;
List<DockerSessionState> get sessions =>
_sessions.values.toList(growable: false);
DockerSessionState? get activeSession {
final id = _activeHostId;
if (id == null) return null;
return _sessions[id];
}
String? get activeHostId => _activeHostId;
bool isConnected(String hostId) =>
_sessions[hostId]?.status == DockerConnectionStatus.connected;
Future<void> connect({
required ServerModel server,
required AuthSession authSession,
required SessionCookieStore cookieStore,
}) async {
final existing = _sessions[server.id];
if (existing != null) {
_activeHostId = server.id;
if (existing.status == DockerConnectionStatus.disconnected ||
existing.status == DockerConnectionStatus.error) {
existing.status = DockerConnectionStatus.connecting;
existing.loading = true;
existing.errorMessage = null;
notifyListeners();
await existing.client.connect();
} else {
notifyListeners();
}
return;
}
final client = DockerSocketClient(
authSession: authSession,
cookieStore: cookieStore,
hostId: server.id,
);
final session = DockerSessionState(server: server, client: client);
_sessions[server.id] = session;
_activeHostId = server.id;
_subscriptions[server.id] = [
client.connectedStream.listen((connected) {
if (connected) {
session.status = DockerConnectionStatus.connected;
} else {
if (session.status == DockerConnectionStatus.connecting) return;
session.status = DockerConnectionStatus.disconnected;
session.loading = false;
}
notifyListeners();
}),
client.containersStream.listen((containers) {
session.containers = containers;
session.loading = false;
session.refreshing = false;
session.operatingIds.clear();
session.errorMessage = null;
session.status = DockerConnectionStatus.connected;
notifyListeners();
}),
client.logsStream.listen((logs) {
session.logs = logs;
notifyListeners();
}),
client.operationStream.listen((result) {
if (!result.success) {
session.operatingIds.clear();
session.errorMessage = result.message;
}
session.loading = false;
notifyListeners();
if (result.success) client.refresh();
}),
client.errorStream.listen((message) {
session.status = DockerConnectionStatus.error;
session.loading = false;
session.refreshing = false;
session.errorMessage = message;
notifyListeners();
}),
];
notifyListeners();
await client.connect();
}
void activate(String hostId) {
if (!_sessions.containsKey(hostId)) return;
_activeHostId = hostId;
notifyListeners();
}
void refreshActive() {
final session = activeSession;
if (session == null) return;
session.refreshing = true;
session.errorMessage = null;
notifyListeners();
session.client.refresh();
}
void reconnectActive() {
final session = activeSession;
if (session == null) return;
session.status = DockerConnectionStatus.connecting;
session.loading = true;
session.errorMessage = null;
notifyListeners();
unawaited(session.client.connect());
}
void getLogs(DockerContainer container) {
final session = activeSession;
if (session == null) return;
session.logs = '';
notifyListeners();
session.client.getLogs(container.id);
}
void refreshLogs(DockerContainer container) {
activeSession?.client.getLogs(container.id);
}
void start(DockerContainer container) => _operate(container, (client) {
client.start(container.id);
});
void stop(DockerContainer container) => _operate(container, (client) {
client.stop(container.id);
});
void restart(DockerContainer container) => _operate(container, (client) {
client.restart(container.id);
});
void delete(DockerContainer container) => _operate(container, (client) {
client.delete(container.id);
});
void _operate(
DockerContainer container,
void Function(DockerSocketClient client) send,
) {
final session = activeSession;
if (session == null) return;
session.operatingIds.add(container.id);
session.errorMessage = null;
notifyListeners();
send(session.client);
}
void disconnectActive() {
final id = _activeHostId;
if (id == null) return;
_disconnect(id);
_activeHostId = _sessions.keys.isEmpty ? null : _sessions.keys.first;
notifyListeners();
}
void _disconnect(String hostId) {
final session = _sessions.remove(hostId);
if (session == null) return;
for (final sub in _subscriptions.remove(hostId) ?? const []) {
sub.cancel();
}
session.client.disconnect();
}
@override
void dispose() {
for (final hostId in _sessions.keys.toList(growable: false)) {
_disconnect(hostId);
}
super.dispose();
}
}
@@ -0,0 +1,205 @@
import 'dart:async';
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:socket_io_client/socket_io_client.dart' as sio;
import '../../core/api/cookie_store.dart';
import '../auth/auth_session.dart';
import 'docker_container.dart';
class DockerSocketClient {
DockerSocketClient({
required AuthSession authSession,
required SessionCookieStore cookieStore,
required this.hostId,
}) : _authSession = authSession,
_cookieStore = cookieStore;
final AuthSession _authSession;
final SessionCookieStore _cookieStore;
final String hostId;
final StreamController<List<DockerContainer>> _containersController =
StreamController<List<DockerContainer>>.broadcast();
final StreamController<String> _logsController =
StreamController<String>.broadcast();
final StreamController<DockerOperationResult> _operationController =
StreamController<DockerOperationResult>.broadcast();
final StreamController<String> _errorController =
StreamController<String>.broadcast();
final StreamController<bool> _connectedController =
StreamController<bool>.broadcast();
sio.Socket? _socket;
List<String> _serverCandidates = const [];
int _candidateIndex = 0;
bool _disposed = false;
Stream<List<DockerContainer>> get containersStream =>
_containersController.stream;
Stream<String> get logsStream => _logsController.stream;
Stream<DockerOperationResult> get operationStream =>
_operationController.stream;
Stream<String> get errorStream => _errorController.stream;
Stream<bool> get connectedStream => _connectedController.stream;
bool get connected => _socket?.connected ?? false;
Future<void> connect() async {
if (_disposed) return;
disconnect(closeStreams: false);
final cookie = await _cookieStore.readCookieHeader();
_serverCandidates = _buildServerCandidates(_authSession.serverAddress);
_candidateIndex = 0;
if (cookie == null || cookie.isEmpty) {
_emitError('No Cookie');
return;
}
_connectCandidate(cookie);
}
void _connectCandidate(String cookie) {
if (_disposed || _candidateIndex >= _serverCandidates.length) return;
final serverAddress = _serverCandidates[_candidateIndex];
final socket = sio.io(
serverAddress,
sio.OptionBuilder()
.setTransports(['websocket'])
.setPath('/docker/')
.setAuth({'token': _authSession.token})
.setExtraHeaders({'Cookie': cookie, 'Origin': _authSession.serverAddress})
.disableAutoConnect()
.disableReconnection()
.build(),
);
socket.onConnect((_) {
_connectedController.add(true);
socket.emit('ws_docker', {'hostId': hostId});
});
socket.onConnectError((error) {
final message = _stringifyError(error);
socket.dispose();
if (_tryNextCandidate(cookie)) return;
_emitError(message);
});
socket.onError((error) {
_emitError(_stringifyError(error));
});
socket.onDisconnect((_) {
if (!_disposed) _connectedController.add(false);
});
socket.on('docker_containers_data', (data) {
if (data is! List) return;
_containersController.add(
data
.whereType<Map>()
.map(
(item) => DockerContainer.fromJson(
item.map((key, value) => MapEntry(key.toString(), value)),
),
)
.toList(growable: false),
);
});
socket.on('docker_containers_logs', (data) {
_logsController.add(data?.toString() ?? '');
});
socket.on('docker_operation_result', (data) {
_operationController.add(DockerOperationResult.fromJson(data));
});
socket.on('docker_connect_fail', (_) {
_emitError('docker_connect_fail');
});
socket.on('docker_not_plus', (_) {
_emitError('docker_not_plus');
});
_socket = socket;
socket.connect();
}
bool _tryNextCandidate(String cookie) {
final hasNext = _candidateIndex + 1 < _serverCandidates.length;
if (!hasNext) return false;
_candidateIndex++;
_connectCandidate(cookie);
return true;
}
void refresh() {
_socket?.emit('docker_get_containers_data');
}
void getLogs(String containerId, {int tail = 2000}) {
_socket?.emit('docker_get_containers_logs', {
'containerId': containerId,
'tail': tail,
});
}
void start(String containerId) =>
_socket?.emit('docker_start_container', {'containerId': containerId});
void stop(String containerId) =>
_socket?.emit('docker_stop_container', {'containerId': containerId});
void restart(String containerId) =>
_socket?.emit('docker_restart_container', {'containerId': containerId});
void delete(String containerId) =>
_socket?.emit('docker_delete_container', {'containerId': containerId});
void _emitError(String message) {
if (!_disposed) _errorController.add(message);
}
String _stringifyError(Object? error) {
if (error == null) return 'Connection error';
if (error is Map) {
final message = error['message'] ?? error['error'] ?? error['data'];
return message?.toString() ?? error.toString();
}
return error.toString();
}
List<String> _buildServerCandidates(String serverAddress) {
if (!kDebugMode) return [serverAddress];
final parsed = Uri.tryParse(serverAddress);
if (parsed == null || parsed.host.isEmpty) return [serverAddress];
final candidates = <String>[];
if (parsed.scheme == 'http' && parsed.port != 8082) {
candidates.add(parsed.replace(port: 8082).toString());
}
if (parsed.scheme == 'https' && parsed.port != 8092) {
candidates.add(parsed.replace(port: 8092).toString());
}
candidates.add(serverAddress);
return LinkedHashSet<String>.from(candidates).toList(growable: false);
}
void disconnect({bool closeStreams = true}) {
final socket = _socket;
_socket = null;
socket?.dispose();
if (closeStreams) {
_disposed = true;
_containersController.close();
_logsController.close();
_operationController.close();
_errorController.close();
_connectedController.close();
}
}
}
File diff suppressed because it is too large Load Diff
+16 -7
View File
@@ -11,6 +11,8 @@ import '../../state/app_update_notifier.dart';
import '../../state/auth_notifier.dart';
import '../../state/plus_info_notifier.dart';
import '../../state/terminal_providers.dart';
import '../docker/docker_icon.dart';
import '../docker/docker_tab.dart';
import '../servers/servers_tab.dart';
import 'scripts_tab.dart';
import 'settings_tab.dart';
@@ -34,6 +36,7 @@ class _MainShellPageState extends ConsumerState<MainShellPage> {
static const _tabs = <Widget>[
ServersTab(),
SftpTab(),
DockerTab(),
ScriptsTab(),
SettingsTab(),
];
@@ -91,19 +94,23 @@ class _MainShellPageState extends ConsumerState<MainShellPage> {
onSelected: (i) => setState(() => _index = i),
items: [
_WarmBottomBarItem(
icon: Icons.monitor_outlined,
icon: const Icon(Icons.monitor_outlined),
label: l.tr('tabs.servers'),
),
_WarmBottomBarItem(
icon: Icons.folder_outlined,
icon: const Icon(Icons.folder_outlined),
label: l.tr('tabs.sftp'),
),
_WarmBottomBarItem(
icon: Icons.article_outlined,
icon: const DockerIcon(),
label: l.tr('tabs.docker'),
),
_WarmBottomBarItem(
icon: const Icon(Icons.article_outlined),
label: l.tr('tabs.scripts'),
),
_WarmBottomBarItem(
icon: Icons.settings_outlined,
icon: const Icon(Icons.settings_outlined),
label: l.tr('tabs.settings'),
),
],
@@ -276,7 +283,9 @@ class _WarmBottomBarButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final color = selected ? context.colors.fontOnPrimary : context.colors.softMuted;
final color = selected
? context.colors.fontOnPrimary
: context.colors.softMuted;
return Tooltip(
message: item.label,
child: Material(
@@ -287,7 +296,7 @@ class _WarmBottomBarButton extends StatelessWidget {
onTap: onTap,
child: IconTheme(
data: IconThemeData(size: 22, color: color),
child: Center(child: Icon(item.icon)),
child: Center(child: item.icon),
),
),
),
@@ -298,6 +307,6 @@ class _WarmBottomBarButton extends StatelessWidget {
class _WarmBottomBarItem {
const _WarmBottomBarItem({required this.icon, required this.label});
final IconData icon;
final Widget icon;
final String label;
}
+36 -59
View File
@@ -1332,11 +1332,7 @@ class _SftpDirectoryPickerSheetState extends State<_SftpDirectoryPickerSheet> {
),
Expanded(
child: _loading
? Center(
child: CircularProgressIndicator(
color: c.primary,
),
)
? Center(child: CircularProgressIndicator(color: c.primary))
: _error != null
? Center(
child: Padding(
@@ -1643,9 +1639,7 @@ class _SftpFileRow extends StatelessWidget {
? Icons.link_rounded
: Icons.insert_drive_file_outlined,
size: 20,
color: entry.isDirectory
? c.accent
: c.muted,
color: entry.isDirectory ? c.accent : c.muted,
),
const SizedBox(width: 8),
Expanded(
@@ -1667,10 +1661,7 @@ class _SftpFileRow extends StatelessWidget {
maxLines: 1,
textAlign: TextAlign.right,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: c.softMuted,
fontSize: 12,
),
style: TextStyle(color: c.softMuted, fontSize: 12),
),
),
const SizedBox(width: 8),
@@ -1681,10 +1672,7 @@ class _SftpFileRow extends StatelessWidget {
maxLines: 1,
textAlign: TextAlign.right,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: c.softMuted,
fontSize: 10,
),
style: TextStyle(color: c.softMuted, fontSize: 10),
),
),
],
@@ -1791,29 +1779,35 @@ class _SftpEmptyCard extends StatelessWidget {
children: [
const _SftpOrbitIcon(),
const SizedBox(height: 18),
Text(
l.tr('sftp.emptyTitle'),
textAlign: TextAlign.center,
style: TextStyle(
color: c.text,
fontSize: 20,
fontWeight: FontWeight.w800,
SizedBox(
height: 24,
child: Text(
l.tr('sftp.emptyTitle'),
textAlign: TextAlign.center,
style: TextStyle(
color: c.text,
fontSize: 20,
fontWeight: FontWeight.w800,
),
),
),
const SizedBox(height: 8),
Text(
l.tr('sftp.emptyBody'),
textAlign: TextAlign.center,
style: TextStyle(
color: c.softMuted,
fontSize: 13,
fontWeight: FontWeight.w600,
height: 1.5,
SizedBox(
height: 40,
child: Text(
l.tr('sftp.emptyBody'),
textAlign: TextAlign.center,
style: TextStyle(
color: c.softMuted,
fontSize: 13,
fontWeight: FontWeight.w600,
height: 1.5,
),
),
),
const SizedBox(height: 18),
_SftpPrimaryButton(
icon: Icons.dns_outlined,
icon: Icons.folder_outlined,
label: l.tr('sftp.chooseServer'),
trailing: Icons.keyboard_arrow_down_rounded,
onTap: onChooseServer,
@@ -1864,11 +1858,7 @@ class _SftpOrbitIcon extends StatelessWidget {
borderRadius: BorderRadius.circular(28),
border: Border.all(color: const Color(0x66E5B33A)),
),
child: Icon(
Icons.folder_rounded,
color: c.primary,
size: 42,
),
child: Icon(Icons.folder_rounded, color: c.primary, size: 42),
),
),
],
@@ -1986,7 +1976,9 @@ class _SftpServerPickerSheetState
return Container(
decoration: BoxDecoration(
color: c.canvas,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
borderRadius: const BorderRadius.vertical(
top: Radius.circular(20),
),
border: Border(top: BorderSide(color: c.border)),
),
child: Column(
@@ -2043,10 +2035,7 @@ class _SftpServerPickerSheetState
child: TextField(
controller: _searchCtrl,
cursorColor: c.primary,
style: TextStyle(
color: c.text,
fontSize: 14,
),
style: TextStyle(color: c.text, fontSize: 14),
decoration: _sftpSearchDecoration(
context,
hintText: l.tr('sftp.searchHint'),
@@ -2059,9 +2048,7 @@ class _SftpServerPickerSheetState
Expanded(
child: hostsAsync.when(
loading: () => Center(
child: CircularProgressIndicator(
color: c.primary,
),
child: CircularProgressIndicator(color: c.primary),
),
error: (error, _) => ListView(
controller: scrollController,
@@ -2230,11 +2217,9 @@ class _SftpServerRow extends StatelessWidget {
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.dns_outlined,
Icons.folder_outlined,
size: 21,
color: connected
? c.primary
: c.muted,
color: connected ? c.primary : c.muted,
),
),
const SizedBox(width: 12),
@@ -2269,11 +2254,7 @@ class _SftpServerRow extends StatelessWidget {
),
const SizedBox(width: 10),
if (active)
Icon(
Icons.check_rounded,
color: c.success,
size: 20,
)
Icon(Icons.check_rounded, color: c.success, size: 20)
else if (!connected)
_SftpStatusPill(
label: l.tr('sftp.statusDisconnected'),
@@ -2389,11 +2370,7 @@ InputDecoration _sftpSearchDecoration(
isDense: true,
filled: true,
fillColor: c.chip,
prefixIcon: Icon(
Icons.search_rounded,
size: 18,
color: c.softMuted,
),
prefixIcon: Icon(Icons.search_rounded, size: 18, color: c.softMuted),
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
hintStyle: TextStyle(color: c.softMuted, fontSize: 13),
enabledBorder: OutlineInputBorder(
+40
View File
@@ -56,6 +56,7 @@ const Map<String, String> stringsEn = {
// Main shell tabs
'tabs.servers': 'Servers',
'tabs.sftp': 'SFTP',
'tabs.docker': 'Docker',
'tabs.scripts': 'Scripts',
'tabs.settings': 'Settings',
@@ -191,6 +192,45 @@ const Map<String, String> stringsEn = {
'This system picker did not provide a readable folder path.',
'sftp.chooseTargetFolder': 'Choose target folder',
'sftp.selectThisFolder': 'Select this folder',
// Docker
'docker.emptyTitle': 'No server selected',
'docker.emptyBody': 'Choose a Linux server to manage Docker containers.',
'docker.chooseServer': 'Choose server',
'docker.switchServer': 'Switch server',
'docker.sheetTitle': 'Select server',
'docker.sheetSubtitle': 'Connected Docker sessions are shown first.',
'docker.searchHint': 'Search by name, host, or user',
'docker.connectedSection': 'Connected',
'docker.disconnectedSection': 'Not connected',
'docker.statusConnected': 'Connected',
'docker.statusDisconnected': 'Not connected',
'docker.connecting': 'Connecting Docker...',
'docker.noContainers': 'No containers found.',
'docker.plusRequired': 'Docker management requires server authorization.',
'docker.connectFailed': 'Docker service connection failed.',
'docker.notPlus': 'Docker management is not enabled on this server.',
'docker.refresh': 'Refresh',
'docker.logs': 'Logs',
'docker.start': 'Start',
'docker.stop': 'Stop',
'docker.restart': 'Restart',
'docker.delete': 'Delete',
'docker.deleteTitle': 'Delete container?',
'docker.deleteBody': 'Delete container "{0}"? This cannot be undone.',
'docker.operationFailed': 'Operation failed: {0}',
'docker.logsTitle': '{0} logs',
'docker.noLogs': 'No logs.',
'docker.openPortFailed': 'Unable to parse port mapping.',
'docker.selectAll': 'Select all',
'docker.selectedCount': '{0} selected',
'docker.batchStart': 'Start',
'docker.batchRestart': 'Restart',
'docker.batchStop': 'Stop',
'docker.batchDelete': 'Delete',
'docker.batchDeleteTitle': 'Delete {0} containers?',
'docker.batchDeleteBody':
'Are you sure you want to delete {0} selected containers? This cannot be undone.',
'scripts.placeholder': 'Coming soon: script library',
// Scripts tab
+39
View File
@@ -53,6 +53,7 @@ const Map<String, String> stringsZh = {
// Tabs
'tabs.servers': '服务器',
'tabs.sftp': '文件',
'tabs.docker': '容器',
'tabs.scripts': '脚本',
'tabs.settings': '设置',
@@ -179,6 +180,44 @@ const Map<String, String> stringsZh = {
'sftp.folderUploadUnsupported': '系统选择器没有返回可读取的文件夹路径。',
'sftp.chooseTargetFolder': '选择目标文件夹',
'sftp.selectThisFolder': '选择此文件夹',
// Docker
'docker.emptyTitle': '还没有选择服务器',
'docker.emptyBody': '请选择一台 Linux 服务器,开始管理 Docker 容器。',
'docker.chooseServer': '选择服务器',
'docker.switchServer': '切换服务器',
'docker.sheetTitle': '选择服务器',
'docker.sheetSubtitle': '已连接的 Docker 会话会优先显示。',
'docker.searchHint': '按名称 / 主机 / 用户名搜索',
'docker.connectedSection': '已连接',
'docker.disconnectedSection': '未连接',
'docker.statusConnected': '已连接',
'docker.statusDisconnected': '未连接',
'docker.connecting': '正在连接 Docker...',
'docker.noContainers': '暂无容器',
'docker.plusRequired': 'Docker 管理需要服务端授权。',
'docker.connectFailed': 'Docker 服务连接失败。',
'docker.notPlus': '当前服务端未启用 Docker 管理能力。',
'docker.refresh': '刷新',
'docker.logs': '日志',
'docker.start': '启动',
'docker.stop': '停止',
'docker.restart': '重启',
'docker.delete': '删除',
'docker.deleteTitle': '删除容器?',
'docker.deleteBody': '确认删除容器“{0}”?此操作无法撤销。',
'docker.operationFailed': '操作失败:{0}',
'docker.logsTitle': '{0} 日志',
'docker.noLogs': '暂无日志',
'docker.openPortFailed': '无法解析端口映射。',
'docker.selectAll': '全选',
'docker.selectedCount': '已选中 {0} 个',
'docker.batchStart': '启动',
'docker.batchRestart': '重启',
'docker.batchStop': '停止',
'docker.batchDelete': '删除',
'docker.batchDeleteTitle': '删除 {0} 个容器?',
'docker.batchDeleteBody': '确认删除 {0} 个已选容器?此操作无法撤销。',
'scripts.placeholder': '即将上线:脚本库',
// Scripts tab
+9
View File
@@ -0,0 +1,9 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../features/docker/docker_session_manager.dart';
final dockerSessionManagerProvider = Provider<DockerSessionManager>((ref) {
final manager = DockerSessionManager();
ref.onDispose(manager.dispose);
return manager;
});
+24
View File
@@ -900,6 +900,22 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
socket_io_client:
dependency: "direct main"
description:
name: socket_io_client
sha256: f5990ff303d385e7b2150f0e57cca097a8d20c6a2ae0a22cb2d496661ea8ed9b
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.6"
socket_io_common:
dependency: transitive
description:
name: socket_io_common
sha256: "162fbaecbf4bf9a9372a62a341b3550b51dcef2f02f3e5830a297fd48203d45b"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.1"
source_span:
dependency: transitive
description:
@@ -1102,6 +1118,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.1"
win32:
dependency: transitive
description:
+2
View File
@@ -42,6 +42,7 @@ dependencies:
photo_view: ^0.15.0
url_launcher: ^6.3.1
package_info_plus: ^8.1.0
socket_io_client: ^3.1.6
dev_dependencies:
flutter_test:
@@ -107,5 +108,6 @@ flutter:
assets:
- assets/logo_v2_01.png
- assets/plus.png
- assets/docker.svg
- assets/linux.svg
- assets/windows.svg