Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/core/config/api_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class ApiConfig {
final String apiKey;
final int tokenValidity;
final bool isDefault;
final bool ignoreTls;
final DateTime lastUsed;

ApiConfig({
Expand All @@ -17,6 +18,7 @@ class ApiConfig {
required this.apiKey,
this.tokenValidity = 0,
this.isDefault = false,
this.ignoreTls = false,
DateTime? lastUsed,
}) : lastUsed = lastUsed ?? DateTime.now();

Expand All @@ -28,6 +30,7 @@ class ApiConfig {
'apiKey': apiKey,
'tokenValidity': tokenValidity,
'isDefault': isDefault,
'ignoreTls': ignoreTls,
'lastUsed': lastUsed.toIso8601String(),
};
}
Expand All @@ -40,6 +43,7 @@ class ApiConfig {
apiKey: json['apiKey'],
tokenValidity: json['tokenValidity'] as int? ?? 0,
isDefault: json['isDefault'] ?? false,
ignoreTls: json['ignoreTls'] as bool? ?? false,
lastUsed: DateTime.parse(json['lastUsed']),
);
}
Expand Down
17 changes: 12 additions & 5 deletions lib/core/network/api_client_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,10 @@ class ApiClientManager {
}

/// 获取指定服务器的API客户端
DioClient getClient(String serverId, String serverUrl, String apiKey) {
final nextMeta = _ClientConfigMeta(url: serverUrl, apiKey: apiKey);
DioClient getClient(String serverId, String serverUrl, String apiKey,
{bool ignoreTls = false}) {
final nextMeta =
_ClientConfigMeta(url: serverUrl, apiKey: apiKey, ignoreTls: ignoreTls);
final currentMeta = _clientMeta[serverId];

if (currentMeta == nextMeta && _clients.containsKey(serverId)) {
Expand All @@ -52,6 +54,7 @@ class ApiClientManager {
final client = DioClient(
baseUrl: serverUrl,
apiKey: apiKey,
ignoreTls: ignoreTls,
);
_clients[serverId] = client;
_clientMeta[serverId] = nextMeta;
Expand All @@ -60,7 +63,8 @@ class ApiClientManager {

Future<DioClient> getCurrentClient() async {
final config = await _getCurrentConfig();
return getClient(config.id, config.url, config.apiKey);
return getClient(config.id, config.url, config.apiKey,
ignoreTls: config.ignoreTls);
}

Future<AppV2Api> getAppApi() async {
Expand Down Expand Up @@ -160,10 +164,12 @@ class _ClientConfigMeta {
const _ClientConfigMeta({
required this.url,
required this.apiKey,
this.ignoreTls = false,
});

final String url;
final String apiKey;
final bool ignoreTls;

@override
bool operator ==(Object other) {
Expand All @@ -172,9 +178,10 @@ class _ClientConfigMeta {
}
return other is _ClientConfigMeta &&
other.url == url &&
other.apiKey == apiKey;
other.apiKey == apiKey &&
other.ignoreTls == ignoreTls;
}

@override
int get hashCode => Object.hash(url, apiKey);
int get hashCode => Object.hash(url, apiKey, ignoreTls);
}
6 changes: 5 additions & 1 deletion lib/core/network/dio_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,18 @@ import 'interceptors/auth_interceptor.dart';
import 'interceptors/logging_interceptor.dart';
import 'interceptors/retry_interceptor.dart';
import 'interceptors/business_response_interceptor.dart';
import 'tls_bypass.dart';

/// 基于Dio的HTTP客户端 - 支持1Panel API认证
class DioClient {
final Dio _dio;
late AuthInterceptor _authInterceptor;

DioClient({String? baseUrl, String? apiKey})
DioClient({String? baseUrl, String? apiKey, bool ignoreTls = false})
: _dio = Dio(_createBaseOptionsStatic(baseUrl)) {
if (ignoreTls) {
configureTlsBypass(_dio);
}
_authInterceptor = AuthInterceptor(apiKey);
_addInterceptors();
}
Expand Down
7 changes: 7 additions & 0 deletions lib/core/network/tls_bypass.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/// Conditional export of TLS bypass implementation.
///
/// On mobile/desktop platforms (dart:io), configuring TLS bypass disables
/// certificate verification so that servers with self-signed or invalid
/// certificates can be reached. On web, the function is a no-op.
export 'tls_bypass_stub.dart'
if (dart.library.io) 'tls_bypass_io.dart';
13 changes: 13 additions & 0 deletions lib/core/network/tls_bypass_io.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';

/// IO (mobile/desktop) implementation - bypasses TLS certificate validation.
void configureTlsBypass(Dio dio) {
(dio.httpClientAdapter as IOHttpClientAdapter).onHttpClientCreate =
(HttpClient client) {
client.badCertificateCallback =
(X509Certificate cert, String host, int port) => true;
return client;
};
}
6 changes: 6 additions & 0 deletions lib/core/network/tls_bypass_stub.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import 'package:dio/dio.dart';

/// Stub implementation for web platform - TLS bypass is not applicable on web.
void configureTlsBypass(Dio dio) {
// No-op on web platform
}
135 changes: 90 additions & 45 deletions lib/features/containers/containers_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ class _ErrorView extends StatelessWidget {
}

/// 容器标签页
class _ContainersTab extends StatelessWidget {
class _ContainersTab extends StatefulWidget {
final List<dynamic> containers;
final ContainerStats stats;
final bool isLoading;
Expand All @@ -299,62 +299,105 @@ class _ContainersTab extends StatelessWidget {
required this.onDelete,
});

@override
State<_ContainersTab> createState() => _ContainersTabState();
}

class _ContainersTabState extends State<_ContainersTab> {
bool _statsExpanded = false;

@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;

return RefreshIndicator(
onRefresh: onRefresh,
onRefresh: widget.onRefresh,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 容器统计卡片
_StatsCard(
title: '容器统计',
stats: [
_StatItem(
title: '总数',
value: stats.total.toString(),
color: colorScheme.primary,
icon: Icons.inventory_2,
),
_StatItem(
title: '运行中',
value: stats.running.toString(),
color: Colors.green,
icon: Icons.play_circle,
),
_StatItem(
title: '已停止',
value: stats.stopped.toString(),
color: Colors.orange,
icon: Icons.stop_circle,
// 容器统计卡片 (可折叠)
Card(
margin: EdgeInsets.zero,
child: Theme(
data: Theme.of(context)
.copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
initiallyExpanded: _statsExpanded,
onExpansionChanged: (v) =>
setState(() => _statsExpanded = v),
leading: Icon(Icons.bar_chart,
color: colorScheme.primary),
title: Row(
children: [
Text('容器统计'),
const SizedBox(width: 12),
_StatsBadge(
value: widget.stats.total.toString(),
color: colorScheme.primary),
const SizedBox(width: 6),
_StatsBadge(
value: '▶ ${widget.stats.running}',
color: Colors.green),
const SizedBox(width: 6),
_StatsBadge(
value: '■ ${widget.stats.stopped}',
color: Colors.orange),
],
),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_StatItem(
title: '总数',
value: widget.stats.total.toString(),
color: colorScheme.primary,
icon: Icons.inventory_2,
),
_StatItem(
title: '运行中',
value: widget.stats.running.toString(),
color: Colors.green,
icon: Icons.play_circle,
),
_StatItem(
title: '已停止',
value: widget.stats.stopped.toString(),
color: Colors.orange,
icon: Icons.stop_circle,
),
],
),
),
],
),
],
),
),
const SizedBox(height: 16),

// 容器列表
if (containers.isEmpty && !isLoading)
if (widget.containers.isEmpty && !widget.isLoading)
const _EmptyView(
icon: Icons.inventory_2_outlined,
title: '暂无容器',
subtitle: '点击右下角按钮创建容器',
)
else
...containers.map((container) {
...widget.containers.map((container) {
final containerInfo = container as ContainerInfo;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: ContainerCard(
container: containerInfo,
onStart: () => onStart(containerInfo.name),
onStop: () => onStop(containerInfo.name),
onRestart: () => onRestart(containerInfo.name),
onDelete: () => onDelete(containerInfo.name),
onStart: () => widget.onStart(containerInfo.name),
onStop: () => widget.onStop(containerInfo.name),
onRestart: () => widget.onRestart(containerInfo.name),
onDelete: () => widget.onDelete(containerInfo.name),
onTap: () {
Navigator.pushNamed(
context,
Expand Down Expand Up @@ -386,23 +429,25 @@ class _ContainersTab extends StatelessWidget {
}
}

/// 统计卡片
class _StatsCard extends StatelessWidget {
final String title;
final List<_StatItem> stats;
/// 统计徽章(用于折叠时的摘要显示)
class _StatsBadge extends StatelessWidget {
final String value;
final Color color;

const _StatsCard({
required this.title,
required this.stats,
});
const _StatsBadge({required this.value, required this.color});

@override
Widget build(BuildContext context) {
return AppCard(
title: title,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: stats,
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Text(
value,
style: TextStyle(
fontSize: 12, color: color, fontWeight: FontWeight.w600),
),
);
}
Expand Down
Loading