530 lines
17 KiB
Dart
530 lines
17 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:archive/archive.dart';
|
|
import 'package:ef/ef.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:shelf/shelf.dart';
|
|
import 'package:shelf/shelf_io.dart' as io;
|
|
import 'package:shelf_static/shelf_static.dart';
|
|
import 'package:vbvs_app/common/color/appConstants.dart';
|
|
import 'package:vbvs_app/common/color/app_uri_status.dart';
|
|
import 'package:vbvs_app/common/util/FitTool.dart';
|
|
import 'package:vbvs_app/component/tool/NewTopSlideNotification.dart';
|
|
import 'package:vbvs_app/controller/device/blueteeth_bind_controller.dart';
|
|
import 'package:vbvs_app/controller/device/body_device_controller.dart';
|
|
import 'package:vbvs_app/controller/device/device_type_controller.dart';
|
|
import 'package:vbvs_app/controller/main_bottom/global_controller.dart';
|
|
import 'package:vbvs_app/controller/theme_controller/ThemeController.dart';
|
|
import 'package:vbvs_app/controller/user_info_controller.dart';
|
|
import 'package:vbvs_app/enum/APPPackageType.dart';
|
|
import 'package:vbvs_app/enum/LoginStatus.dart';
|
|
import 'package:vbvs_app/model/api_response.dart';
|
|
|
|
//本地使用
|
|
class EPage extends StatefulWidget {
|
|
final String sleepUri;
|
|
const EPage({super.key, required this.sleepUri});
|
|
|
|
@override
|
|
State<EPage> createState() => _EPageState();
|
|
}
|
|
|
|
class _EPageState extends State<EPage> with AutomaticKeepAliveClientMixin {
|
|
GlobalController globalController = Get.find();
|
|
UserInfoController userInfoController = Get.find();
|
|
BlueteethBindController blueteethBindController = Get.find();
|
|
ThemeController themeController = Get.find();
|
|
DeviceTypeController deviceTypeController = Get.find();
|
|
|
|
ValueNotifier<bool> isPageLoading = ValueNotifier<bool>(true);
|
|
ValueNotifier<bool> isServerStarting = ValueNotifier<bool>(true);
|
|
ValueNotifier<String> serverError = ValueNotifier<String>('');
|
|
RxList deviceList = [].obs;
|
|
RxString finalUri = RxString('');
|
|
|
|
// 本地服务器相关
|
|
HttpServer? _localServer;
|
|
int _serverPort = 0;
|
|
String _localServerUrl = '';
|
|
Directory? _tempExtractDir;
|
|
|
|
@override
|
|
bool get wantKeepAlive => true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// 启动本地服务器,成功后获取设备列表
|
|
_startLocalWebServer().then((success) {
|
|
if (success) {
|
|
getDeviceList();
|
|
} else {
|
|
isPageLoading.value = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
// 清理资源
|
|
_cleanupResources();
|
|
isPageLoading.dispose();
|
|
isServerStarting.dispose();
|
|
serverError.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<bool> _startLocalWebServer() async {
|
|
try {
|
|
isServerStarting.value = true;
|
|
serverError.value = '';
|
|
|
|
// 1. 从 assets 加载 ZIP 文件
|
|
final ByteData zipData;
|
|
try {
|
|
zipData = await rootBundle.load('assets/xiaoe/xiaoe_1.0.0.zip');
|
|
} catch (e) {
|
|
serverError.value = '找不到小e资源文件: $e';
|
|
return false;
|
|
}
|
|
|
|
final Uint8List zipBytes = zipData.buffer.asUint8List();
|
|
|
|
// 2. 创建临时目录并解压 ZIP
|
|
_tempExtractDir = await Directory.systemTemp.createTemp('xiaoe_web_');
|
|
final bool extractSuccess =
|
|
await _extractZipToDirectory(zipBytes, _tempExtractDir!);
|
|
|
|
if (!extractSuccess) {
|
|
serverError.value = '解压小e资源文件失败';
|
|
return false;
|
|
}
|
|
|
|
// 3. 查找入口文件 (index.html)
|
|
final File? entryFile = await _findEntryFile(_tempExtractDir!);
|
|
if (entryFile == null) {
|
|
serverError.value = '找不到入口文件 (index.html)';
|
|
return false;
|
|
}
|
|
|
|
// 4. 创建静态文件处理器
|
|
final staticHandler = createStaticHandler(
|
|
_tempExtractDir!.path,
|
|
defaultDocument: 'index.html',
|
|
serveFilesOutsidePath: false,
|
|
);
|
|
|
|
// 5. 添加日志中间件(可选)
|
|
final handler =
|
|
Pipeline().addMiddleware(logRequests()).addHandler(staticHandler);
|
|
|
|
// 6. 启动服务器(端口 0 表示自动分配)
|
|
_localServer = await io.serve(handler, InternetAddress.loopbackIPv4, 0);
|
|
_serverPort = _localServer!.port;
|
|
_localServerUrl = 'http://127.0.0.1:$_serverPort';
|
|
|
|
print('小e本地服务启动成功: $_localServerUrl');
|
|
print('资源目录: ${_tempExtractDir!.path}');
|
|
|
|
isServerStarting.value = false;
|
|
return true;
|
|
} catch (e, stackTrace) {
|
|
serverError.value = '启动本地服务器失败: $e';
|
|
print('服务器启动错误: $e\n$stackTrace');
|
|
isServerStarting.value = false;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<bool> _extractZipToDirectory(
|
|
Uint8List zipBytes, Directory targetDir) async {
|
|
try {
|
|
// 解码 ZIP 文件
|
|
final Archive archive = ZipDecoder().decodeBytes(zipBytes);
|
|
|
|
for (final ArchiveFile file in archive) {
|
|
final String filename = file.name;
|
|
|
|
// 跳过目录条目(它们会通过文件创建自动生成)
|
|
if (file.isFile) {
|
|
// 确保目录存在
|
|
final String filePath = p.join(targetDir.path, filename);
|
|
final File outputFile = File(filePath);
|
|
await outputFile.parent.create(recursive: true);
|
|
|
|
// 写入文件内容
|
|
final List<int> data = file.content as List<int>;
|
|
await outputFile.writeAsBytes(data, flush: true);
|
|
}
|
|
}
|
|
|
|
print('解压完成,文件数量: ${archive.files.length}');
|
|
return true;
|
|
} catch (e, stackTrace) {
|
|
print('解压失败: $e\n$stackTrace');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<File?> _findEntryFile(Directory dir) async {
|
|
// 优先查找 index.html
|
|
final List<String> possibleEntries = [
|
|
'index.html',
|
|
'Index.html',
|
|
'INDEX.HTML',
|
|
'main.html',
|
|
'default.html',
|
|
];
|
|
|
|
for (final entry in possibleEntries) {
|
|
final File file = File(p.join(dir.path, entry));
|
|
if (await file.exists()) {
|
|
return file;
|
|
}
|
|
}
|
|
|
|
// 如果没有找到标准入口,查找任何 .html 文件
|
|
try {
|
|
final List<FileSystemEntity> entities =
|
|
await dir.list(recursive: false).toList();
|
|
for (final entity in entities) {
|
|
if (entity is File && entity.path.toLowerCase().endsWith('.html')) {
|
|
return entity;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
print('查找入口文件失败: $e');
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
void _cleanupResources() {
|
|
// 关闭服务器
|
|
if (_localServer != null) {
|
|
_localServer!.close();
|
|
_localServer = null;
|
|
print('本地服务器已关闭');
|
|
}
|
|
|
|
// 清理临时目录
|
|
if (_tempExtractDir != null && _tempExtractDir!.existsSync()) {
|
|
try {
|
|
_tempExtractDir!.deleteSync(recursive: true);
|
|
print('临时目录已清理: ${_tempExtractDir!.path}');
|
|
} catch (e) {
|
|
print('清理临时目录失败: $e');
|
|
}
|
|
_tempExtractDir = null;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
super.build(context);
|
|
bool isLoggedIn = userInfoController.model.login == LoginStatus.LOGIN.code;
|
|
|
|
return LayoutBuilder(
|
|
builder: (context, bodySize) => GestureDetector(
|
|
child: Container(
|
|
decoration: const BoxDecoration(
|
|
image: DecorationImage(
|
|
image: AssetImage('assets/img/bgNoImg.png'),
|
|
fit: BoxFit.fill,
|
|
),
|
|
),
|
|
child: Scaffold(
|
|
backgroundColor: Colors.transparent,
|
|
appBar: AppBar(
|
|
backgroundColor: themeController.currentColor.sc17,
|
|
automaticallyImplyLeading: false,
|
|
iconTheme: IconThemeData(color: themeController.currentColor.sc3),
|
|
titleSpacing: 0,
|
|
title: Container(
|
|
width: double.infinity,
|
|
height: 180.rpx,
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
Text(
|
|
'菜单.小e'.tr,
|
|
style: TextStyle(
|
|
fontFamily: 'Readex Pro',
|
|
color: themeController.currentColor.sc3,
|
|
fontSize: 30.rpx,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
body: SafeArea(
|
|
top: true,
|
|
child: isLoggedIn
|
|
? _buildLoggedInContent()
|
|
: _buildLoggedOutContent(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildLoggedInContent() {
|
|
return ValueListenableBuilder<bool>(
|
|
valueListenable: isServerStarting,
|
|
builder: (context, isStarting, child) {
|
|
if (isStarting) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
valueColor: AlwaysStoppedAnimation<Color>(
|
|
themeController.currentColor.sc1,
|
|
),
|
|
),
|
|
SizedBox(height: 16.rpx),
|
|
Text(
|
|
'正在启动小e服务...'.tr,
|
|
style: TextStyle(
|
|
color: themeController.currentColor.sc3,
|
|
fontSize: 28.rpx,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return ValueListenableBuilder<String>(
|
|
valueListenable: serverError,
|
|
builder: (context, error, child) {
|
|
if (error.isNotEmpty) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(40.rpx),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.error_outline,
|
|
color: Colors.red,
|
|
size: 60.rpx,
|
|
),
|
|
SizedBox(height: 20.rpx),
|
|
Text(
|
|
'服务启动失败'.tr,
|
|
style: TextStyle(
|
|
color: Colors.red,
|
|
fontSize: 32.rpx,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
SizedBox(height: 12.rpx),
|
|
Text(
|
|
error,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: themeController.currentColor.sc3,
|
|
fontSize: 26.rpx,
|
|
),
|
|
),
|
|
SizedBox(height: 24.rpx),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
serverError.value = '';
|
|
_startLocalWebServer().then((success) {
|
|
if (success) {
|
|
getDeviceList();
|
|
}
|
|
});
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: themeController.currentColor.sc1,
|
|
foregroundColor: Colors.white,
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: 32.rpx,
|
|
vertical: 16.rpx,
|
|
),
|
|
),
|
|
child: Text('重试'.tr),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Obx(() {
|
|
if (finalUri.isEmpty) {
|
|
return Center(
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
valueColor: AlwaysStoppedAnimation<Color>(
|
|
themeController.currentColor.sc1,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (deviceList.isEmpty) {
|
|
return GestureDetector(
|
|
onTap: () {
|
|
NewTopSlideNotification.show(
|
|
text: "请先绑定设备".tr,
|
|
textColor: themeController.currentColor.sc9,
|
|
);
|
|
},
|
|
child: Center(
|
|
child: Image.asset(
|
|
"assets/img/xiaoe.png",
|
|
fit: BoxFit.contain,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Stack(
|
|
children: [
|
|
InAppWebView(
|
|
initialUrlRequest: URLRequest(
|
|
url: WebUri(finalUri.value),
|
|
),
|
|
onLoadStart: (controller, url) {
|
|
isPageLoading.value = true;
|
|
print('WebView 开始加载: $url');
|
|
},
|
|
onLoadStop: (controller, url) {
|
|
isPageLoading.value = false;
|
|
print('WebView 加载完成: $url');
|
|
},
|
|
onLoadError: (controller, url, code, message) {
|
|
isPageLoading.value = false;
|
|
print('WebView 加载错误: $message (code: $code)');
|
|
serverError.value = '页面加载失败: $message';
|
|
},
|
|
onWebViewCreated: (controller) {
|
|
// 启用调试(仅在开发模式下)
|
|
// if (!kReleaseMode) {
|
|
// controller.setWebContentsDebuggingEnabled(true);
|
|
// }
|
|
},
|
|
),
|
|
ValueListenableBuilder<bool>(
|
|
valueListenable: isPageLoading,
|
|
builder: (context, isLoading, child) {
|
|
return isLoading
|
|
? Center(
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
valueColor: AlwaysStoppedAnimation<Color>(
|
|
themeController.currentColor.sc1,
|
|
),
|
|
),
|
|
)
|
|
: const SizedBox.shrink();
|
|
},
|
|
),
|
|
],
|
|
);
|
|
});
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildLoggedOutContent() {
|
|
return GestureDetector(
|
|
onTap: () {
|
|
NewTopSlideNotification.show(
|
|
text: "必须登录提示".tr,
|
|
textColor: themeController.currentColor.sc9,
|
|
);
|
|
Get.toNamed("/otherLoginPage");
|
|
},
|
|
child: Center(
|
|
child: Image.asset(
|
|
"assets/img/xiaoe.png",
|
|
fit: BoxFit.contain,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> getDeviceList() async {
|
|
try {
|
|
BodyDeviceController bodyDeviceController = Get.find();
|
|
ApiResponse apiResponse =
|
|
await bodyDeviceController.getDeviceList(isAllDevice: true);
|
|
|
|
if (apiResponse.code == HttpStatusCodes.ok) {
|
|
List<dynamic> rawList = apiResponse.data;
|
|
|
|
List<Map<String, dynamic>> newList = rawList.map((item) {
|
|
String mac = item['mac'] ?? '';
|
|
String name = (item['person'] != null &&
|
|
item['person']['name'] != null &&
|
|
item['person']['name'].toString().trim().isNotEmpty)
|
|
? item['person']['name'] + "_${mac}"
|
|
: '体征检测设备'.tr + "_${mac}";
|
|
return {
|
|
'mac': mac,
|
|
'name': name,
|
|
};
|
|
}).toList();
|
|
|
|
deviceList.value = newList;
|
|
|
|
// 构建最终 URL
|
|
String baseUrl = _localServerUrl;
|
|
String queryParams = '?t=${DateTime.now().millisecondsSinceEpoch}';
|
|
|
|
if (deviceList.isNotEmpty) {
|
|
String personParam = Uri.encodeComponent(jsonEncode(deviceList));
|
|
queryParams += '&person=$personParam';
|
|
}
|
|
|
|
// 添加语言参数
|
|
String? language = "";
|
|
// 这里保持你原来的语言逻辑
|
|
if (AppConstants().ent_type == APPPackageType.MHT.code) {
|
|
// 假设你有 mhLanguageController
|
|
// if (mhLanguageController.selectLanguage != null) {
|
|
// language = mhLanguageController.selectLanguage.value!.language_code;
|
|
// }
|
|
} else {
|
|
// 假设你有 languageController
|
|
// if (languageController.selectLanguage != null) {
|
|
// language = languageController.selectLanguage.value!.language_code;
|
|
// }
|
|
}
|
|
|
|
if (language != null && language.isNotEmpty) {
|
|
queryParams += '&lang=$language';
|
|
}
|
|
|
|
finalUri.value = baseUrl + queryParams;
|
|
print('最终加载 URL: ${finalUri.value}');
|
|
} else {
|
|
// API 调用失败,使用基础 URL
|
|
finalUri.value =
|
|
'$_localServerUrl?t=${DateTime.now().millisecondsSinceEpoch}';
|
|
}
|
|
} catch (e) {
|
|
print('获取设备列表失败: $e');
|
|
// 出错时仍然加载基础页面
|
|
finalUri.value =
|
|
'$_localServerUrl?t=${DateTime.now().millisecondsSinceEpoch}';
|
|
}
|
|
}
|
|
}
|