chore: release 1.1.0 with password protection, built-in interceptor, and cURL support
This commit is contained in:
parent
095e9cc464
commit
af369c7622
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -5,6 +5,19 @@
|
||||||
格式基于 [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
格式基于 [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
此项目遵循 [语义化版本](https://semver.org/spec/v2.0.0.html) 规范。
|
此项目遵循 [语义化版本](https://semver.org/spec/v2.0.0.html) 规范。
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-01-20
|
||||||
|
|
||||||
|
### 新增 (New)
|
||||||
|
- **密码保护 (Password Protection)**: 支持设置 `password` 为面板添加安全锁,防止非授权查看。
|
||||||
|
- **内置拦截器 (Built-in Interceptor)**: 新增 `YxNetInspectorDioInterceptor`,一行代码集成 Dio。
|
||||||
|
- **cURL 支持**: 详情页新增 cURL 命令生成与复制功能。
|
||||||
|
- **Headers 详情**: 详情页新增独立的请求/响应 Headers 展示卡片。
|
||||||
|
- **API 增强**: `logResponse` 新增 `responseHeaders` 参数。
|
||||||
|
|
||||||
|
### 优化 (Improvements)
|
||||||
|
- 优化长文本展示,支持自动换行与滚动。
|
||||||
|
- 修复非全屏模式下 Header 溢出问题。
|
||||||
|
|
||||||
## [1.0.0] - 2024-12-20
|
## [1.0.0] - 2024-12-20
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,22 @@ packages:
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
|
dio:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dio
|
||||||
|
sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "5.9.0"
|
||||||
|
dio_web_adapter:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dio_web_adapter
|
||||||
|
sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.1"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -139,6 +155,14 @@ packages:
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.16.0"
|
||||||
|
mime:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: mime
|
||||||
|
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -238,7 +262,7 @@ packages:
|
||||||
path: ".."
|
path: ".."
|
||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "1.0.3"
|
version: "1.0.4"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.8.0-0 <4.0.0"
|
dart: ">=3.8.0-0 <4.0.0"
|
||||||
flutter: ">=3.18.0-18.0.pre.54"
|
flutter: ">=3.18.0-18.0.pre.54"
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,9 @@ class YxNetInspectorController extends ChangeNotifier {
|
||||||
|
|
||||||
/// 配置信息
|
/// 配置信息
|
||||||
late YxNetInspectorConfig _config;
|
late YxNetInspectorConfig _config;
|
||||||
YxNetInspectorConfig get config => _config;
|
|
||||||
|
|
||||||
/// 网络日志列表
|
/// 网络日志列表
|
||||||
final List<NetworkLogEntry> _logs = <NetworkLogEntry>[];
|
final List<NetworkLogEntry> _logs = <NetworkLogEntry>[];
|
||||||
List<NetworkLogEntry> get logs => List.unmodifiable(_logs);
|
|
||||||
|
|
||||||
/// 统计数据
|
/// 统计数据
|
||||||
int _requestCount = 0;
|
int _requestCount = 0;
|
||||||
|
|
@ -33,16 +31,54 @@ class YxNetInspectorController extends ChangeNotifier {
|
||||||
|
|
||||||
/// 悬浮球显示状态
|
/// 悬浮球显示状态
|
||||||
bool _showFloatingBall = true;
|
bool _showFloatingBall = true;
|
||||||
bool get showFloatingBall => _showFloatingBall && _config.isEnabled;
|
|
||||||
|
|
||||||
/// 面板显示状态
|
/// 面板显示状态
|
||||||
bool _isPanelVisible = false;
|
bool _isPanelVisible = false;
|
||||||
bool get isPanelVisible => _isPanelVisible && _config.isEnabled;
|
bool _isUnlocked = true; // 默认为已解锁
|
||||||
|
|
||||||
/// 初始化控制器配置
|
/// 初始化控制器
|
||||||
void initialize(YxNetInspectorConfig config) {
|
void initialize(YxNetInspectorConfig config) {
|
||||||
_config = config;
|
_config = config;
|
||||||
_showFloatingBall = config.showFloatingBall;
|
_showFloatingBall = config.showFloatingBall;
|
||||||
|
|
||||||
|
// 如果配置了密码,初始化为锁定状态
|
||||||
|
if (config.password != null && config.password!.isNotEmpty) {
|
||||||
|
_isUnlocked = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前配置
|
||||||
|
YxNetInspectorConfig get config => _config;
|
||||||
|
|
||||||
|
/// 获取日志列表
|
||||||
|
List<NetworkLogEntry> get logs => List.unmodifiable(_logs);
|
||||||
|
|
||||||
|
/// 获取悬浮球可见性
|
||||||
|
bool get showFloatingBall => _showFloatingBall;
|
||||||
|
|
||||||
|
/// 获取面板可见性
|
||||||
|
bool get isPanelVisible => _isPanelVisible;
|
||||||
|
|
||||||
|
/// 获取是否已解锁
|
||||||
|
bool get isUnlocked => _isUnlocked;
|
||||||
|
|
||||||
|
/// 尝试解锁
|
||||||
|
/// 返回是否成功
|
||||||
|
Future<bool> unlock(String password) async {
|
||||||
|
if (_config.password == password) {
|
||||||
|
_isUnlocked = true;
|
||||||
|
notifyListeners();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 锁定检查器
|
||||||
|
void lock() {
|
||||||
|
if (_config.password != null) {
|
||||||
|
_isUnlocked = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 显示面板
|
/// 显示面板
|
||||||
|
|
@ -97,6 +133,7 @@ class YxNetInspectorController extends ChangeNotifier {
|
||||||
int? statusCode,
|
int? statusCode,
|
||||||
dynamic responseData,
|
dynamic responseData,
|
||||||
Duration? duration,
|
Duration? duration,
|
||||||
|
Map<String, dynamic>? responseHeaders,
|
||||||
}) {
|
}) {
|
||||||
if (!_config.isEnabled) return;
|
if (!_config.isEnabled) return;
|
||||||
|
|
||||||
|
|
@ -110,6 +147,7 @@ class YxNetInspectorController extends ChangeNotifier {
|
||||||
final updatedLog = originalLog.copyWith(
|
final updatedLog = originalLog.copyWith(
|
||||||
statusCode: statusCode,
|
statusCode: statusCode,
|
||||||
responseData: responseData,
|
responseData: responseData,
|
||||||
|
responseHeaders: responseHeaders,
|
||||||
duration: duration,
|
duration: duration,
|
||||||
isSuccess: isSuccess,
|
isSuccess: isSuccess,
|
||||||
status: isSuccess
|
status: isSuccess
|
||||||
|
|
@ -235,6 +273,43 @@ class YxNetInspectorController extends ChangeNotifier {
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取所有日志的字符串表示 (用于导出/分享)
|
||||||
|
String getAllLogsAsString() {
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
buffer.writeln('YxNetInspector Exported Logs');
|
||||||
|
buffer.writeln('Generated at: ${DateTime.now()}');
|
||||||
|
buffer.writeln('Total Requests: $requestCount');
|
||||||
|
buffer.writeln('Success: $successCount, Failed: $_errorCount');
|
||||||
|
buffer.writeln('----------------------------------------');
|
||||||
|
|
||||||
|
for (final log in _logs) {
|
||||||
|
buffer.writeln('[${log.method}] ${log.url}');
|
||||||
|
buffer.writeln('Status: ${log.statusCode ?? "N/A"}');
|
||||||
|
buffer.writeln('Time: ${log.timestamp}');
|
||||||
|
buffer.writeln('Duration: ${log.formattedDuration}');
|
||||||
|
|
||||||
|
if (log.headers != null && log.headers!.isNotEmpty) {
|
||||||
|
buffer.writeln('Headers: ${log.headers}');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (log.requestData != null) {
|
||||||
|
buffer.writeln('Request Data: ${log.requestData}');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (log.responseData != null) {
|
||||||
|
buffer.writeln('Response Data: ${log.responseData}');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (log.errorMessage != null) {
|
||||||
|
buffer.writeln('Error: ${log.errorMessage}');
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.writeln('----------------------------------------');
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
/// 限制日志数量
|
/// 限制日志数量
|
||||||
void _trimLogs() {
|
void _trimLogs() {
|
||||||
if (_logs.length > _config.maxLogs) {
|
if (_logs.length > _config.maxLogs) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:yx_net_inspector/src/yx_net_inspector_app.dart';
|
||||||
|
|
||||||
|
/// 网络检查器 Dio 拦截器
|
||||||
|
///
|
||||||
|
/// 用于自动记录 Dio 请求和响应日志到 YxNetInspector
|
||||||
|
class YxNetInspectorDioInterceptor extends Interceptor {
|
||||||
|
@override
|
||||||
|
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||||
|
// 记录请求
|
||||||
|
YxNetInspectorGlobal.logRequest(
|
||||||
|
id: options.hashCode.toString(),
|
||||||
|
method: options.method,
|
||||||
|
url: options.uri.toString(),
|
||||||
|
headers: options.headers,
|
||||||
|
requestData: options.data,
|
||||||
|
queryParameters: options.queryParameters,
|
||||||
|
);
|
||||||
|
handler.next(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||||
|
// 记录响应
|
||||||
|
try {
|
||||||
|
// 计算请求耗时(如果可能)
|
||||||
|
// 注意:Dio 的 RequestOptions 不带时间戳,我们这里可能依赖 Controller 内部的计时,
|
||||||
|
// 或者我们可以扩展 RequestOptions 来携带时间戳 (extra)
|
||||||
|
final requestOptions = response.requestOptions;
|
||||||
|
|
||||||
|
YxNetInspectorGlobal.logResponse(
|
||||||
|
id: requestOptions.hashCode.toString(),
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
responseData: response.data,
|
||||||
|
responseHeaders: response.headers.map,
|
||||||
|
// 这里没有准确的耗时,因为 logRequest 记录了开始时间。
|
||||||
|
// Controller 会根据 ID 自动计算耗时:Duration = Now - StartTime
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略日志记录错误,避免影响业务
|
||||||
|
}
|
||||||
|
handler.next(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||||
|
// 记录错误
|
||||||
|
try {
|
||||||
|
final requestOptions = err.requestOptions;
|
||||||
|
|
||||||
|
YxNetInspectorGlobal.logError(
|
||||||
|
id: requestOptions.hashCode.toString(),
|
||||||
|
error: err.message ?? err.toString(),
|
||||||
|
statusCode: err.response?.statusCode,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略日志记录错误
|
||||||
|
}
|
||||||
|
handler.next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ class YxNetInspectorConfig {
|
||||||
this.draggable = true,
|
this.draggable = true,
|
||||||
this.showBadge = true,
|
this.showBadge = true,
|
||||||
this.autoHide = false,
|
this.autoHide = false,
|
||||||
|
this.password,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 是否显示悬浮球
|
/// 是否显示悬浮球
|
||||||
|
|
@ -47,6 +48,9 @@ class YxNetInspectorConfig {
|
||||||
/// 是否自动隐藏悬浮球
|
/// 是否自动隐藏悬浮球
|
||||||
final bool autoHide;
|
final bool autoHide;
|
||||||
|
|
||||||
|
/// 访问检查器面板所需的密码(如果为空则无需密码)
|
||||||
|
final String? password;
|
||||||
|
|
||||||
/// 根据当前模式判断检查器是否应该启用
|
/// 根据当前模式判断检查器是否应该启用
|
||||||
bool get isEnabled {
|
bool get isEnabled {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
|
|
@ -68,6 +72,7 @@ class YxNetInspectorConfig {
|
||||||
bool? draggable,
|
bool? draggable,
|
||||||
bool? showBadge,
|
bool? showBadge,
|
||||||
bool? autoHide,
|
bool? autoHide,
|
||||||
|
String? password,
|
||||||
}) {
|
}) {
|
||||||
return YxNetInspectorConfig(
|
return YxNetInspectorConfig(
|
||||||
showFloatingBall: showFloatingBall ?? this.showFloatingBall,
|
showFloatingBall: showFloatingBall ?? this.showFloatingBall,
|
||||||
|
|
@ -80,6 +85,7 @@ class YxNetInspectorConfig {
|
||||||
draggable: draggable ?? this.draggable,
|
draggable: draggable ?? this.draggable,
|
||||||
showBadge: showBadge ?? this.showBadge,
|
showBadge: showBadge ?? this.showBadge,
|
||||||
autoHide: autoHide ?? this.autoHide,
|
autoHide: autoHide ?? this.autoHide,
|
||||||
|
password: password ?? this.password,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,7 +101,8 @@ class YxNetInspectorConfig {
|
||||||
'initialPosition: $initialPosition, '
|
'initialPosition: $initialPosition, '
|
||||||
'draggable: $draggable, '
|
'draggable: $draggable, '
|
||||||
'showBadge: $showBadge, '
|
'showBadge: $showBadge, '
|
||||||
'autoHide: $autoHide'
|
'autoHide: $autoHide, '
|
||||||
|
'password: ${password != null ? "***" : "null"}'
|
||||||
')';
|
')';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,7 +119,8 @@ class YxNetInspectorConfig {
|
||||||
other.initialPosition == initialPosition &&
|
other.initialPosition == initialPosition &&
|
||||||
other.draggable == draggable &&
|
other.draggable == draggable &&
|
||||||
other.showBadge == showBadge &&
|
other.showBadge == showBadge &&
|
||||||
other.autoHide == autoHide;
|
other.autoHide == autoHide &&
|
||||||
|
other.password == password;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -128,6 +136,7 @@ class YxNetInspectorConfig {
|
||||||
draggable,
|
draggable,
|
||||||
showBadge,
|
showBadge,
|
||||||
autoHide,
|
autoHide,
|
||||||
|
password,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// 网络请求状态枚举
|
/// 网络请求状态枚举
|
||||||
|
|
@ -22,6 +24,7 @@ class NetworkLogEntry {
|
||||||
required this.timestamp,
|
required this.timestamp,
|
||||||
required this.isSuccess,
|
required this.isSuccess,
|
||||||
this.headers,
|
this.headers,
|
||||||
|
this.responseHeaders,
|
||||||
this.requestData,
|
this.requestData,
|
||||||
this.queryParameters,
|
this.queryParameters,
|
||||||
this.statusCode,
|
this.statusCode,
|
||||||
|
|
@ -34,6 +37,7 @@ class NetworkLogEntry {
|
||||||
final String method;
|
final String method;
|
||||||
final String url;
|
final String url;
|
||||||
final Map<String, dynamic>? headers;
|
final Map<String, dynamic>? headers;
|
||||||
|
final Map<String, dynamic>? responseHeaders;
|
||||||
final dynamic requestData;
|
final dynamic requestData;
|
||||||
final Map<String, dynamic>? queryParameters;
|
final Map<String, dynamic>? queryParameters;
|
||||||
final int? statusCode;
|
final int? statusCode;
|
||||||
|
|
@ -125,12 +129,39 @@ class NetworkLogEntry {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 生成 cURL 命令用于调试 (跨平台兼容: macOS/Linux/Windows)
|
||||||
|
String toCurlCommand() {
|
||||||
|
final buffer = StringBuffer('curl -X $method "$url"');
|
||||||
|
headers?.forEach((key, value) {
|
||||||
|
// 转义双引号以兼容所有平台
|
||||||
|
final escapedValue = value.toString().replaceAll('"', '\\"');
|
||||||
|
buffer.write(' -H "$key: $escapedValue"');
|
||||||
|
});
|
||||||
|
if (requestData != null) {
|
||||||
|
String body;
|
||||||
|
if (requestData is String) {
|
||||||
|
body = requestData as String;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
body = jsonEncode(requestData);
|
||||||
|
} catch (_) {
|
||||||
|
body = requestData.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 转义双引号和反斜杠
|
||||||
|
final escapedBody = body.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
|
||||||
|
buffer.write(' -d "$escapedBody"');
|
||||||
|
}
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
/// 创建副本并更新字段
|
/// 创建副本并更新字段
|
||||||
NetworkLogEntry copyWith({
|
NetworkLogEntry copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
String? method,
|
String? method,
|
||||||
String? url,
|
String? url,
|
||||||
Map<String, dynamic>? headers,
|
Map<String, dynamic>? headers,
|
||||||
|
Map<String, dynamic>? responseHeaders,
|
||||||
dynamic requestData,
|
dynamic requestData,
|
||||||
Map<String, dynamic>? queryParameters,
|
Map<String, dynamic>? queryParameters,
|
||||||
int? statusCode,
|
int? statusCode,
|
||||||
|
|
@ -146,6 +177,7 @@ class NetworkLogEntry {
|
||||||
method: method ?? this.method,
|
method: method ?? this.method,
|
||||||
url: url ?? this.url,
|
url: url ?? this.url,
|
||||||
headers: headers ?? this.headers,
|
headers: headers ?? this.headers,
|
||||||
|
responseHeaders: responseHeaders ?? this.responseHeaders,
|
||||||
requestData: requestData ?? this.requestData,
|
requestData: requestData ?? this.requestData,
|
||||||
queryParameters: queryParameters ?? this.queryParameters,
|
queryParameters: queryParameters ?? this.queryParameters,
|
||||||
statusCode: statusCode ?? this.statusCode,
|
statusCode: statusCode ?? this.statusCode,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import 'package:yx_net_inspector/src/widgets/panel/inspector_header.dart';
|
||||||
import 'package:yx_net_inspector/src/widgets/panel/inspector_log_list.dart';
|
import 'package:yx_net_inspector/src/widgets/panel/inspector_log_list.dart';
|
||||||
import 'package:yx_net_inspector/src/widgets/panel/inspector_search_bar.dart';
|
import 'package:yx_net_inspector/src/widgets/panel/inspector_search_bar.dart';
|
||||||
import 'package:yx_net_inspector/src/widgets/panel/inspector_statistics.dart';
|
import 'package:yx_net_inspector/src/widgets/panel/inspector_statistics.dart';
|
||||||
|
import 'package:yx_net_inspector/src/widgets/panel/password_dialog.dart';
|
||||||
|
|
||||||
/// 网络检查器面板组件
|
/// 网络检查器面板组件
|
||||||
class YxInspectorPanel extends StatefulWidget {
|
class YxInspectorPanel extends StatefulWidget {
|
||||||
|
|
@ -142,6 +143,16 @@ ${log.duration != null ? '- 结束时间: ${_formatDateTime(log.timestamp.add(lo
|
||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: widget.controller,
|
listenable: widget.controller,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
|
// 如果未解锁,显示密码解锁界面
|
||||||
|
if (!widget.controller.isUnlocked) {
|
||||||
|
return Center(
|
||||||
|
child: PasswordDialog(
|
||||||
|
theme: widget.theme,
|
||||||
|
onUnlock: widget.controller.unlock,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget content;
|
Widget content;
|
||||||
|
|
||||||
if (_showDetailPage && _selectedLog != null) {
|
if (_showDetailPage && _selectedLog != null) {
|
||||||
|
|
@ -174,6 +185,17 @@ ${log.duration != null ? '- 结束时间: ${_formatDateTime(log.timestamp.add(lo
|
||||||
isFullScreen: _isFullScreen,
|
isFullScreen: _isFullScreen,
|
||||||
onToggleFullScreen: _toggleFullScreen,
|
onToggleFullScreen: _toggleFullScreen,
|
||||||
onClearLogs: widget.controller.clearLogs,
|
onClearLogs: widget.controller.clearLogs,
|
||||||
|
onCopyLogs: () {
|
||||||
|
final logs = widget.controller.getAllLogsAsString();
|
||||||
|
Clipboard.setData(ClipboardData(text: logs));
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Logs copied to clipboard'),
|
||||||
|
duration: Duration(milliseconds: 1500),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
onClose: widget.onClose,
|
onClose: widget.onClose,
|
||||||
),
|
),
|
||||||
InspectorStatistics(
|
InspectorStatistics(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:yx_net_inspector/src/models/inspector_theme.dart';
|
import 'package:yx_net_inspector/src/models/inspector_theme.dart';
|
||||||
import 'package:yx_net_inspector/src/models/network_log_entry.dart';
|
import 'package:yx_net_inspector/src/models/network_log_entry.dart';
|
||||||
|
|
||||||
|
|
@ -21,10 +22,21 @@ class YxLogDetailPage extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
_buildBasicInfoCard(context),
|
_buildBasicInfoCard(context),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
_buildCurlCard(context),
|
||||||
|
const SizedBox(height: 16),
|
||||||
_buildRequestInfoCard(),
|
_buildRequestInfoCard(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
if (log.headers != null && log.headers!.isNotEmpty) ...[
|
||||||
|
_buildHeadersCard('请求头 (Request Headers)', log.headers!),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
_buildResponseInfoCard(),
|
_buildResponseInfoCard(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
if (log.responseHeaders != null &&
|
||||||
|
log.responseHeaders!.isNotEmpty) ...[
|
||||||
|
_buildHeadersCard('响应头 (Response Headers)', log.responseHeaders!),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
if (log.errorMessage != null) ...[
|
if (log.errorMessage != null) ...[
|
||||||
_buildErrorInfoCard(),
|
_buildErrorInfoCard(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
@ -387,4 +399,123 @@ class YxLogDetailPage extends StatelessWidget {
|
||||||
'${dateTime.minute.toString().padLeft(2, '0')}:'
|
'${dateTime.minute.toString().padLeft(2, '0')}:'
|
||||||
'${dateTime.second.toString().padLeft(2, '0')}';
|
'${dateTime.second.toString().padLeft(2, '0')}';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildHeadersCard(String title, Map<String, dynamic> headers) {
|
||||||
|
return Card(
|
||||||
|
color: theme.cardColor,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.list_alt, color: theme.primaryColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: theme.textColor,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
constraints: const BoxConstraints(maxHeight: 200),
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.primaryColor.withValues(alpha: 0.05),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: SelectableText(
|
||||||
|
headers.entries.map((e) => '${e.key}: ${e.value}').join('\n'),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: theme.textColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCurlCard(BuildContext context) {
|
||||||
|
final curlCommand = log.toCurlCommand();
|
||||||
|
return Card(
|
||||||
|
color: theme.cardColor,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.terminal, color: theme.warningColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'cURL 命令',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: theme.textColor,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.copy, color: theme.primaryColor, size: 20),
|
||||||
|
tooltip: '复制 cURL',
|
||||||
|
onPressed: () {
|
||||||
|
Clipboard.setData(ClipboardData(text: curlCommand));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('cURL 已复制到剪贴板'),
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
constraints: const BoxConstraints(maxHeight: 150),
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.warningColor.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: SelectableText(
|
||||||
|
curlCommand,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: theme.textColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,6 @@ class InspectorDetailHeader extends StatelessWidget {
|
||||||
|
|
||||||
return isFullScreen
|
return isFullScreen
|
||||||
? AppBar(
|
? AppBar(
|
||||||
title: const Text('请求详情'),
|
|
||||||
backgroundColor: theme.primaryColor,
|
backgroundColor: theme.primaryColor,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
// excludeHeaderSemantics: false
|
// excludeHeaderSemantics: false
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ class InspectorHeader extends StatelessWidget {
|
||||||
required this.onToggleFullScreen,
|
required this.onToggleFullScreen,
|
||||||
required this.onClearLogs,
|
required this.onClearLogs,
|
||||||
required this.onClose,
|
required this.onClose,
|
||||||
|
this.onCopyLogs,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -16,6 +17,7 @@ class InspectorHeader extends StatelessWidget {
|
||||||
final VoidCallback onToggleFullScreen;
|
final VoidCallback onToggleFullScreen;
|
||||||
final VoidCallback onClearLogs;
|
final VoidCallback onClearLogs;
|
||||||
final VoidCallback onClose;
|
final VoidCallback onClose;
|
||||||
|
final VoidCallback? onCopyLogs;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -33,6 +35,12 @@ class InspectorHeader extends StatelessWidget {
|
||||||
icon: const Icon(Icons.clear_all, color: Colors.white),
|
icon: const Icon(Icons.clear_all, color: Colors.white),
|
||||||
tooltip: '清空日志',
|
tooltip: '清空日志',
|
||||||
),
|
),
|
||||||
|
if (onCopyLogs != null)
|
||||||
|
IconButton(
|
||||||
|
onPressed: onCopyLogs,
|
||||||
|
icon: const Icon(Icons.copy, color: Colors.white),
|
||||||
|
tooltip: '复制所有日志',
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: onClose,
|
onPressed: onClose,
|
||||||
icon: const Icon(Icons.close, color: Colors.white),
|
icon: const Icon(Icons.close, color: Colors.white),
|
||||||
|
|
@ -60,17 +68,6 @@ class InspectorHeader extends StatelessWidget {
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.network_check, color: Colors.white, size: 24),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Text(
|
|
||||||
'网络检查器',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
...actions,
|
...actions,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:yx_net_inspector/src/models/inspector_theme.dart';
|
||||||
|
|
||||||
|
/// 密码输入对话框
|
||||||
|
class PasswordDialog extends StatefulWidget {
|
||||||
|
const PasswordDialog({
|
||||||
|
required this.onUnlock,
|
||||||
|
required this.theme,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 解锁回调
|
||||||
|
final Future<bool> Function(String password) onUnlock;
|
||||||
|
|
||||||
|
/// 主题
|
||||||
|
final YxNetInspectorTheme theme;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PasswordDialog> createState() => _PasswordDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PasswordDialogState extends State<PasswordDialog> {
|
||||||
|
final _passwordController = TextEditingController();
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _errorMessage;
|
||||||
|
bool _obscureText = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_passwordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleUnlock() async {
|
||||||
|
final password = _passwordController.text;
|
||||||
|
if (password.isEmpty) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final success = await widget.onUnlock(password);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
if (!success) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_errorMessage = '密码错误,请重试';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// If success, the dialog (or lock screen) will typically be removed by the parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: widget.theme.backgroundColor,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.1),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
width: 300,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.lock_outline,
|
||||||
|
size: 48,
|
||||||
|
color: widget.theme.primaryColor,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'安全访问',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: widget.theme.textColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'请输入密码以查看网络日志',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: widget.theme.secondaryTextColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
TextField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: _obscureText,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '输入密码',
|
||||||
|
errorText: _errorMessage,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
prefixIcon: const Icon(Icons.vpn_key),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_obscureText ? Icons.visibility : Icons.visibility_off,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_obscureText = !_obscureText;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _handleUnlock(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _isLoading ? null : _handleUnlock,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: widget.theme.primaryColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text('解锁'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,9 +7,9 @@ import 'package:yx_net_inspector/src/widgets/inspector_panel.dart';
|
||||||
|
|
||||||
/// 包装你的应用并提供网络检查功能的主要组件
|
/// 包装你的应用并提供网络检查功能的主要组件
|
||||||
class YxNetInspector extends StatefulWidget {
|
class YxNetInspector extends StatefulWidget {
|
||||||
|
|
||||||
const YxNetInspector({
|
const YxNetInspector({
|
||||||
required this.child, super.key,
|
required this.child,
|
||||||
|
super.key,
|
||||||
this.config = const YxNetInspectorConfig(),
|
this.config = const YxNetInspectorConfig(),
|
||||||
this.theme = const YxNetInspectorTheme(),
|
this.theme = const YxNetInspectorTheme(),
|
||||||
});
|
});
|
||||||
|
|
@ -42,6 +42,7 @@ class YxNetInspector extends StatefulWidget {
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 你的应用组件
|
/// 你的应用组件
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
|
|
@ -170,12 +171,14 @@ class YxNetInspectorGlobal {
|
||||||
int? statusCode,
|
int? statusCode,
|
||||||
dynamic responseData,
|
dynamic responseData,
|
||||||
Duration? duration,
|
Duration? duration,
|
||||||
|
Map<String, dynamic>? responseHeaders,
|
||||||
}) {
|
}) {
|
||||||
controller.logResponse(
|
controller.logResponse(
|
||||||
id: id,
|
id: id,
|
||||||
statusCode: statusCode,
|
statusCode: statusCode,
|
||||||
responseData: responseData,
|
responseData: responseData,
|
||||||
duration: duration,
|
duration: duration,
|
||||||
|
responseHeaders: responseHeaders,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
|
|
||||||
// 核心导出
|
// 核心导出
|
||||||
export 'src/controller/yx_net_inspector_controller.dart';
|
export 'src/controller/yx_net_inspector_controller.dart';
|
||||||
|
export 'src/interceptors/dio_interceptor.dart';
|
||||||
export 'src/models/inspector_config.dart';
|
export 'src/models/inspector_config.dart';
|
||||||
export 'src/models/inspector_theme.dart';
|
export 'src/models/inspector_theme.dart';
|
||||||
export 'src/models/network_log_entry.dart';
|
export 'src/models/network_log_entry.dart';
|
||||||
|
|
|
||||||
48
pubspec.lock
48
pubspec.lock
|
|
@ -41,6 +41,22 @@ packages:
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
|
dio:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: dio
|
||||||
|
sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "5.9.0"
|
||||||
|
dio_web_adapter:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dio_web_adapter
|
||||||
|
sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.1"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -59,6 +75,14 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
http_parser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_parser
|
||||||
|
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.2"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -107,6 +131,14 @@ packages:
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.16.0"
|
||||||
|
mime:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: mime
|
||||||
|
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -168,6 +200,14 @@ packages:
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.6"
|
version: "0.7.6"
|
||||||
|
typed_data:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: typed_data
|
||||||
|
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -192,6 +232,14 @@ packages:
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.0.0"
|
version: "15.0.0"
|
||||||
|
web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: web
|
||||||
|
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.8.0-0 <4.0.0"
|
dart: ">=3.8.0-0 <4.0.0"
|
||||||
flutter: ">=3.18.0-18.0.pre.54"
|
flutter: ">=3.18.0-18.0.pre.54"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
name: yx_net_inspector
|
name: yx_net_inspector
|
||||||
description: A powerful network inspector with floating debug ball for Flutter apps. Monitor HTTP requests, responses, and debug network issues in real-time.
|
description: A powerful network inspector with floating debug ball for Flutter apps. Monitor HTTP requests, responses, and debug network issues in real-time.
|
||||||
version: 1.0.4
|
version: 1.1.0
|
||||||
homepage: https://github.com/your-username/yx_net_inspector
|
homepage: https://github.com/your-username/yx_net_inspector
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -10,6 +10,7 @@ environment:
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
dio: ^5.9.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:yx_net_inspector/src/controller/yx_net_inspector_controller.dart';
|
||||||
|
import 'package:yx_net_inspector/src/interceptors/dio_interceptor.dart';
|
||||||
|
import 'package:yx_net_inspector/src/models/inspector_config.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('YxNetInspectorDioInterceptor Tests', () {
|
||||||
|
late YxNetInspectorController controller;
|
||||||
|
late Dio dio;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
controller = YxNetInspectorController.instance;
|
||||||
|
// Initialize controller with logging enabled (debug mode)
|
||||||
|
controller.initialize(const YxNetInspectorConfig(showInDebugMode: true));
|
||||||
|
|
||||||
|
dio = Dio();
|
||||||
|
// Add our inspector interceptor FIRST
|
||||||
|
dio.interceptors.add(YxNetInspectorDioInterceptor());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Should log request and response upon success', () async {
|
||||||
|
final initialLogCount = controller.logs.length;
|
||||||
|
|
||||||
|
// Add a mock interceptor to return success response immediately
|
||||||
|
dio.interceptors.add(InterceptorsWrapper(
|
||||||
|
onRequest: (options, handler) {
|
||||||
|
// Allow request to proceed (so our inspector sees it)
|
||||||
|
handler.next(options);
|
||||||
|
},
|
||||||
|
onError: (e, handler) => handler.next(e),
|
||||||
|
onResponse: (e, handler) => handler.next(e),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Mock Adapter to avoid real network
|
||||||
|
dio.httpClientAdapter = _MockAdapter((options) {
|
||||||
|
return ResponseBody.fromString(
|
||||||
|
'{"message": "success"}',
|
||||||
|
200,
|
||||||
|
headers: {
|
||||||
|
Headers.contentTypeHeader: [Headers.jsonContentType],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await dio.get('https://example.com/api/test');
|
||||||
|
|
||||||
|
expect(controller.logs.length, greaterThan(initialLogCount));
|
||||||
|
final latestLog = controller.logs.first;
|
||||||
|
|
||||||
|
expect(latestLog.url, 'https://example.com/api/test');
|
||||||
|
expect(latestLog.method, 'GET');
|
||||||
|
expect(latestLog.statusCode, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Should log error upon failure', () async {
|
||||||
|
final initialLogCount = controller.logs.length;
|
||||||
|
|
||||||
|
// Mock Adapter to throw error
|
||||||
|
dio.httpClientAdapter = _MockAdapter((options) {
|
||||||
|
throw DioException(
|
||||||
|
requestOptions: options,
|
||||||
|
error: 'Network Error',
|
||||||
|
type: DioExceptionType.connectionError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dio.post('https://example.com/api/fail', data: {'foo': 'bar'});
|
||||||
|
} catch (e) {
|
||||||
|
// Expected
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(controller.logs.length, greaterThan(initialLogCount));
|
||||||
|
final latestLog = controller.logs.first;
|
||||||
|
|
||||||
|
expect(latestLog.url, 'https://example.com/api/fail');
|
||||||
|
expect(latestLog.method, 'POST');
|
||||||
|
expect(latestLog.isSuccess, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MockAdapter implements HttpClientAdapter {
|
||||||
|
final ResponseBody Function(RequestOptions options) _mockResponse;
|
||||||
|
|
||||||
|
_MockAdapter(this._mockResponse);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ResponseBody> fetch(RequestOptions options,
|
||||||
|
Stream<List<int>>? requestStream, Future<void>? cancelFuture) async {
|
||||||
|
return _mockResponse(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void close({bool force = false}) {}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:yx_net_inspector/src/controller/yx_net_inspector_controller.dart
|
||||||
import 'package:yx_net_inspector/src/models/inspector_config.dart';
|
import 'package:yx_net_inspector/src/models/inspector_config.dart';
|
||||||
import 'package:yx_net_inspector/src/models/inspector_theme.dart';
|
import 'package:yx_net_inspector/src/models/inspector_theme.dart';
|
||||||
import 'package:yx_net_inspector/src/widgets/inspector_panel.dart';
|
import 'package:yx_net_inspector/src/widgets/inspector_panel.dart';
|
||||||
|
import 'package:yx_net_inspector/src/widgets/panel/inspector_log_list.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('YxInspectorPanel Widget Tests', () {
|
group('YxInspectorPanel Widget Tests', () {
|
||||||
|
|
@ -34,20 +35,25 @@ void main() {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 验证面板基本组件
|
// 验证面板基本组件 (标题在非全屏模式已移除以节省空间)
|
||||||
expect(find.text('网络检查器'), findsOneWidget);
|
// Title removed from non-fullscreen mode to save space
|
||||||
expect(find.byIcon(Icons.network_check), findsOneWidget);
|
|
||||||
expect(find.byIcon(Icons.close), findsOneWidget);
|
expect(find.byIcon(Icons.close), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('应该显示统计信息', (WidgetTester tester) async {
|
testWidgets('应该显示统计信息', (WidgetTester tester) async {
|
||||||
// 添加一些测试数据
|
// 添加一些测试数据
|
||||||
controller.logRequest(
|
controller.logRequest(
|
||||||
id: 'test-1', method: 'GET', url: 'https://example.com',);
|
id: 'test-1',
|
||||||
|
method: 'GET',
|
||||||
|
url: 'https://example.com',
|
||||||
|
);
|
||||||
controller.logResponse(id: 'test-1', statusCode: 200);
|
controller.logResponse(id: 'test-1', statusCode: 200);
|
||||||
|
|
||||||
controller.logRequest(
|
controller.logRequest(
|
||||||
id: 'test-2', method: 'POST', url: 'https://example.com',);
|
id: 'test-2',
|
||||||
|
method: 'POST',
|
||||||
|
url: 'https://example.com',
|
||||||
|
);
|
||||||
controller.logError(id: 'test-2', error: '网络错误');
|
controller.logError(id: 'test-2', error: '网络错误');
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
|
|
@ -65,12 +71,15 @@ void main() {
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// 验证统计信息
|
// 验证统计信息
|
||||||
expect(find.text('总计'), findsOneWidget);
|
// 验证统计信息
|
||||||
expect(find.text('成功'), findsOneWidget);
|
expect(find.text('总请求: 2'), findsOneWidget);
|
||||||
expect(find.text('失败'), findsOneWidget);
|
expect(find.text('错误: 1'), findsOneWidget);
|
||||||
expect(find.text('成功率'), findsOneWidget);
|
|
||||||
expect(find.text('2'), findsOneWidget); // 总请求数
|
// Removed old fields
|
||||||
expect(find.text('1'), findsWidgets); // 成功和失败各1个
|
// expect(find.text('总计'), findsOneWidget);
|
||||||
|
// expect(find.text('成功'), findsOneWidget);
|
||||||
|
// expect(find.text('失败'), findsOneWidget);
|
||||||
|
// expect(find.text('成功率'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('应该显示搜索栏', (WidgetTester tester) async {
|
testWidgets('应该显示搜索栏', (WidgetTester tester) async {
|
||||||
|
|
@ -89,16 +98,24 @@ void main() {
|
||||||
// 验证搜索相关组件
|
// 验证搜索相关组件
|
||||||
expect(find.byType(TextField), findsOneWidget);
|
expect(find.byType(TextField), findsOneWidget);
|
||||||
expect(find.text('搜索请求...'), findsOneWidget);
|
expect(find.text('搜索请求...'), findsOneWidget);
|
||||||
expect(find.byType(FilterChip), findsOneWidget);
|
expect(find.text('搜索请求...'), findsOneWidget);
|
||||||
expect(find.text('仅显示错误'), findsOneWidget);
|
// FilterChip was removed/moved
|
||||||
|
// expect(find.byType(FilterChip), findsOneWidget);
|
||||||
|
// expect(find.text('仅显示错误'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('搜索功能应该正常工作', (WidgetTester tester) async {
|
testWidgets('搜索功能应该正常工作', (WidgetTester tester) async {
|
||||||
// 添加测试数据
|
// 添加测试数据
|
||||||
controller.logRequest(
|
controller.logRequest(
|
||||||
id: 'user-1', method: 'GET', url: 'https://api.example.com/users',);
|
id: 'user-1',
|
||||||
|
method: 'GET',
|
||||||
|
url: 'https://api.example.com/users',
|
||||||
|
);
|
||||||
controller.logRequest(
|
controller.logRequest(
|
||||||
id: 'post-1', method: 'POST', url: 'https://api.example.com/posts',);
|
id: 'post-1',
|
||||||
|
method: 'POST',
|
||||||
|
url: 'https://api.example.com/posts',
|
||||||
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
|
|
@ -125,11 +142,17 @@ void main() {
|
||||||
testWidgets('错误过滤器应该正常工作', (WidgetTester tester) async {
|
testWidgets('错误过滤器应该正常工作', (WidgetTester tester) async {
|
||||||
// 添加成功和失败的请求
|
// 添加成功和失败的请求
|
||||||
controller.logRequest(
|
controller.logRequest(
|
||||||
id: 'success', method: 'GET', url: 'https://example.com',);
|
id: 'success',
|
||||||
|
method: 'GET',
|
||||||
|
url: 'https://example.com',
|
||||||
|
);
|
||||||
controller.logResponse(id: 'success', statusCode: 200);
|
controller.logResponse(id: 'success', statusCode: 200);
|
||||||
|
|
||||||
controller.logRequest(
|
controller.logRequest(
|
||||||
id: 'error', method: 'POST', url: 'https://example.com',);
|
id: 'error',
|
||||||
|
method: 'POST',
|
||||||
|
url: 'https://example.com',
|
||||||
|
);
|
||||||
controller.logError(id: 'error', error: '网络错误');
|
controller.logError(id: 'error', error: '网络错误');
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
|
|
@ -146,19 +169,21 @@ void main() {
|
||||||
|
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// 点击错误过滤器
|
// 点击错误过滤器 (It's a TextButton now)
|
||||||
await tester.tap(find.byType(FilterChip));
|
await tester.tap(find.text('仅错误'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// 验证过滤器被激活
|
// 验证过滤器被激活 - Text changes to "显示全部"
|
||||||
final filterChip = tester.widget<FilterChip>(find.byType(FilterChip));
|
expect(find.text('显示全部'), findsOneWidget);
|
||||||
expect(filterChip.selected, isTrue);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('清空日志按钮应该正常工作', (WidgetTester tester) async {
|
testWidgets('清空日志按钮应该正常工作', (WidgetTester tester) async {
|
||||||
// 添加一些日志
|
// 添加一些日志
|
||||||
controller.logRequest(
|
controller.logRequest(
|
||||||
id: 'test', method: 'GET', url: 'https://example.com',);
|
id: 'test',
|
||||||
|
method: 'GET',
|
||||||
|
url: 'https://example.com',
|
||||||
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
|
|
@ -272,7 +297,7 @@ void main() {
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// 验证日志条目显示
|
// 验证日志条目显示
|
||||||
expect(find.byType(Card), findsWidgets);
|
expect(find.byType(InspectorLogItem), findsWidgets);
|
||||||
expect(find.text('GET'), findsOneWidget);
|
expect(find.text('GET'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -300,7 +325,7 @@ void main() {
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// 点击日志条目
|
// 点击日志条目
|
||||||
await tester.tap(find.byType(InkWell).first);
|
await tester.tap(find.byType(InspectorLogItem).first);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// 验证导航到详情页(这里主要验证没有报错)
|
// 验证导航到详情页(这里主要验证没有报错)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:yx_net_inspector/src/controller/yx_net_inspector_controller.dart';
|
||||||
|
import 'package:yx_net_inspector/src/models/inspector_config.dart';
|
||||||
|
import 'package:yx_net_inspector/src/models/inspector_theme.dart';
|
||||||
|
import 'package:yx_net_inspector/src/widgets/inspector_panel.dart';
|
||||||
|
import 'package:yx_net_inspector/src/widgets/panel/password_dialog.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('Password Protection Tests', () {
|
||||||
|
late YxNetInspectorController controller;
|
||||||
|
const theme = YxNetInspectorTheme();
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
controller = YxNetInspectorController.instance;
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Panel should be unlocked by default (no password)',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
controller.initialize(const YxNetInspectorConfig(showInDebugMode: true));
|
||||||
|
|
||||||
|
expect(controller.isUnlocked, isTrue);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: YxInspectorPanel(
|
||||||
|
theme: theme,
|
||||||
|
controller: controller,
|
||||||
|
onClose: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byType(PasswordDialog), findsNothing);
|
||||||
|
expect(find.byIcon(Icons.close), findsOneWidget); // Normal panel content
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Panel should be locked if password is set',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
controller.initialize(const YxNetInspectorConfig(
|
||||||
|
showInDebugMode: true,
|
||||||
|
password: '123',
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(controller.isUnlocked, isFalse);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: YxInspectorPanel(
|
||||||
|
theme: theme,
|
||||||
|
controller: controller,
|
||||||
|
onClose: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byType(PasswordDialog), findsOneWidget);
|
||||||
|
expect(find.text('安全访问'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.byIcon(Icons.close), findsNothing); // Should hide panel content
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Entering correct password should unlock panel',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
controller.initialize(const YxNetInspectorConfig(
|
||||||
|
showInDebugMode: true,
|
||||||
|
password: '123',
|
||||||
|
));
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: YxInspectorPanel(
|
||||||
|
theme: theme,
|
||||||
|
controller: controller,
|
||||||
|
onClose: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enter correct password
|
||||||
|
await tester.enterText(find.byType(TextField), '123');
|
||||||
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||||
|
await tester.pump(); // Start async unlock
|
||||||
|
await tester.pump(); // Rebuild with unlocked state
|
||||||
|
|
||||||
|
expect(controller.isUnlocked, isTrue);
|
||||||
|
expect(find.byType(PasswordDialog), findsNothing);
|
||||||
|
expect(find.byIcon(Icons.close), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Entering wrong password should show error',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
controller.initialize(const YxNetInspectorConfig(
|
||||||
|
showInDebugMode: true,
|
||||||
|
password: '123',
|
||||||
|
));
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: YxInspectorPanel(
|
||||||
|
theme: theme,
|
||||||
|
controller: controller,
|
||||||
|
onClose: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enter wrong password
|
||||||
|
await tester.enterText(find.byType(TextField), '000');
|
||||||
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||||
|
await tester.pump(); // Start async unlock
|
||||||
|
await tester.pump(); // Rebuild
|
||||||
|
|
||||||
|
expect(controller.isUnlocked, isFalse);
|
||||||
|
expect(find.byType(PasswordDialog), findsOneWidget);
|
||||||
|
expect(find.text('密码错误,请重试'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue