From 6ef0b6320f68ce8f93019e76596dcfa323e95c13 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Mon, 27 Jul 2026 13:28:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=97=A5=E5=BF=97=E6=9F=A5=E7=9C=8B=20fix?= =?UTF-8?q?:=E7=9C=9F=E6=9C=BAbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 4 +- .../pj_supermarket_cloud/MainActivity.kt | 3 + android/app/src/main/res/values/attrs.xml | 2 +- .../customer_welcome/abstract-care.webp | Bin 0 -> 16204 bytes .../customer_welcome/abstract-flow.webp | Bin 0 -> 14882 bytes .../abstract-welcome-hero.webp | Bin 0 -> 33328 bytes lib/app.dart | 7 +- lib/common/api/user_api.dart | 6 +- .../models/encryption/encryption_tool.dart | 29 + .../models/page/setting_page_enum_models.dart | 1 + lib/common/routers/names.dart | 3 + lib/common/routers/pages.dart | 5 + lib/common/utils/app/app_face_tool.dart | 3 - lib/common/utils/app/app_runtime_tool.dart | 9 + lib/common/utils/app/app_sub_screen_tool.dart | 41 +- lib/common/utils/app/control/app_control.dart | 3 + .../utils/app/terminal_navigation_tool.dart | 2 +- lib/common/utils/logger/logger_tool.dart | 283 ++++- lib/common/utils/manage/tts_manage.dart | 71 +- lib/common/values/constants.dart | 9 +- lib/pages/root/login/controller.dart | 20 +- lib/pages/root/login/view.dart | 354 ++++--- lib/pages/terminal/setting/view.dart | 4 + lib/pages/terminal/setting/widgets/index.dart | 1 + .../setting/widgets/setting_log_preview.dart | 970 ++++++++++++++++++ .../widgets/setting_sub_screen_routes.dart | 39 +- .../utils/customer_display_coordinator.dart | 9 + .../common/utils/sub_display_coordinator.dart | 91 +- lib/sub_screen/page/customer_display.dart | 11 +- lib/sub_screen/page/customer_welcome.dart | 659 ++++++++++++ .../page/customer_welcome_preview.dart | 16 + lib/sub_screen/page/index.dart | 2 + lib/sub_screen/page/sub_app.dart | 2 + pubspec.lock | 8 + pubspec.yaml | 3 + .../customer_display_coordinator_test.dart | 23 + .../customer_display_widget_test.dart | 11 +- .../customer_welcome_widget_test.dart | 103 ++ .../reservation_query_coordinator_test.dart | 50 +- .../sub_display_coordinator_test.dart | 114 ++ 40 files changed, 2773 insertions(+), 198 deletions(-) create mode 100644 assets/images/customer_welcome/abstract-care.webp create mode 100644 assets/images/customer_welcome/abstract-flow.webp create mode 100644 assets/images/customer_welcome/abstract-welcome-hero.webp create mode 100644 lib/pages/terminal/setting/widgets/setting_log_preview.dart create mode 100644 lib/sub_screen/page/customer_welcome.dart create mode 100644 lib/sub_screen/page/customer_welcome_preview.dart create mode 100644 test/sub_screen/customer_welcome_widget_test.dart create mode 100644 test/sub_screen/sub_display_coordinator_test.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 26a1735..76ac970 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -33,12 +33,14 @@ android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize" /> + + android:theme="@style/LaunchTheme" + android:windowSoftInputMode="adjustResize"> diff --git a/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt index 8339553..8c03e5c 100644 --- a/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt @@ -48,6 +48,9 @@ class MainActivity : FlutterActivity() { ) } "getNetworkSnapshot" -> result.success(getNetworkSnapshot()) + "isSubScreenShowing" -> result.success( + FlutterSubScreenProvider.instance.presentation?.isShowing == true + ) "getKioskStatus" -> result.success(KioskModeManager.status(this)) "setKioskAutoEnterEnabled" -> { val enabled = call.argument("enabled") diff --git a/android/app/src/main/res/values/attrs.xml b/android/app/src/main/res/values/attrs.xml index a3133ba..a501606 100644 --- a/android/app/src/main/res/values/attrs.xml +++ b/android/app/src/main/res/values/attrs.xml @@ -1,5 +1,5 @@ - false + true diff --git a/assets/images/customer_welcome/abstract-care.webp b/assets/images/customer_welcome/abstract-care.webp new file mode 100644 index 0000000000000000000000000000000000000000..dc2b3fa0facecb308a4f077b1aecd591efe2f711 GIT binary patch literal 16204 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 literal 0 HcmV?d00001 diff --git a/assets/images/customer_welcome/abstract-flow.webp b/assets/images/customer_welcome/abstract-flow.webp new file mode 100644 index 0000000000000000000000000000000000000000..1b6829660a1f5e3c6056cde9f000943761a8059c GIT binary patch literal 14882 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 U&`_Evo)t;2h3@`!EaFH204JC>jQ{`u literal 0 HcmV?d00001 diff --git a/assets/images/customer_welcome/abstract-welcome-hero.webp b/assets/images/customer_welcome/abstract-welcome-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..a53316d5946136245ff2d88646ce7445edcd26d1 GIT binary patch literal 33328 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

;X|F4`E)lqgdB0y$U6V8(2skLuwx5 z2V#I3Z7@;{h$Jkg81$TegzJS^HGzAValhMt!00HKS z<~&DEqSd-502delKT)Ju000CA7)ZU+j%dm{jKB%QwN?iW9%Y+f^mIClf0S}VOME^A zv81AvCgP%s^?UoI6IY1KaP|lD8S(?(NSP@lC zu&{)UZtUHj@PMPcsB{?1yg(z#G;8=KC^TVKcd5Vt02;e-!bT~KFDSrp9QO z!@YUW->A9Vw`n~*0nh<2{;%Z>BerU}q69_dCboxt7=YS8O8^r>B;C)A<6A9&j>uoo z(dqAxA(zfl+czLtO|Hf`=e-6;y0B};V%-tbPAsqFFEBe55Yeds#vKZ-+yMGj_!d83 zOV@8idv^+9>ilR)^Y;j)3T{aevGuwzyMV7ZqLF7gy2uSd&*Ou`#Dn0_)0k%84}EhT>}48_Ag{fIS;R}u^RxT zkYhj(C=0=drN9Ch@Bi|T(d)eu6ZaQ|RhR=7Y8hG-$!N^V-R* with WidgetsBindingObserver { await Get.find().suspend(); } catch (_) {} } + return; + } + if (state == AppLifecycleState.resumed) { + // 前台恢复需要重新确认原生 Presentation;若系统回收了副屏窗口才重新创建, + // 正常常驻时不会触发插件内部的“先关闭再打开”。 try { - AppSubScreenTool.hide(); + await AppSubScreenTool.show(verifyPresentation: true); } catch (_) {} } } diff --git a/lib/common/api/user_api.dart b/lib/common/api/user_api.dart index f29f277..cb11147 100644 --- a/lib/common/api/user_api.dart +++ b/lib/common/api/user_api.dart @@ -5,6 +5,10 @@ import 'package:pj_supermarket_cloud/common/index.dart'; abstract class UserApi { /// 硬件管理登录 static Future login(dynamic pa) async { + final password = pa["password"] as String; + final loginPassword = Constants.loginPasswordEncryptionEnabled + ? EncryptionTool.encryptLoginPassword(password) + : password; final res = await HttpManage().dio.post( "/auth/oauth2/token", queryParameters: { @@ -15,7 +19,7 @@ abstract class UserApi { "scope": pa["scope"], }, data: { - "password": pa["password"], + "password": loginPassword, }, options: dio.Options( headers: { diff --git a/lib/common/models/encryption/encryption_tool.dart b/lib/common/models/encryption/encryption_tool.dart index 29a2d2e..6ded758 100644 --- a/lib/common/models/encryption/encryption_tool.dart +++ b/lib/common/models/encryption/encryption_tool.dart @@ -1,4 +1,8 @@ +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:common_utils/common_utils.dart'; +import 'package:pointycastle/export.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; @@ -7,4 +11,29 @@ class EncryptionTool { static String getLoginAuthorization() { return "Basic ${EncryptUtil.encodeBase64(Constants.oauth2AppClient)}"; } + + /// 按 Web 端约定使用 AES-CFB、Key 同值 IV、NoPadding 加密登录密码。 + static String encryptLoginPassword(String password) { + final keyBytes = Uint8List.fromList(utf8.encode(Constants.pwdEncKey)); + final passwordBytes = Uint8List.fromList(utf8.encode(password)); + final cipher = AESEngine()..init(true, KeyParameter(keyBytes)); + final encrypted = Uint8List(passwordBytes.length); + var feedback = Uint8List.fromList(keyBytes); + + for (var offset = 0; offset < passwordBytes.length; offset += 16) { + final streamBlock = Uint8List(16); + cipher.processBlock(feedback, 0, streamBlock, 0); + final blockLength = (passwordBytes.length - offset).clamp(0, 16).toInt(); + for (var index = 0; index < blockLength; index++) { + encrypted[offset + index] = + passwordBytes[offset + index] ^ streamBlock[index]; + } + if (blockLength == 16) { + feedback = Uint8List.fromList( + encrypted.sublist(offset, offset + blockLength), + ); + } + } + return base64Encode(encrypted); + } } diff --git a/lib/common/models/page/setting_page_enum_models.dart b/lib/common/models/page/setting_page_enum_models.dart index af55595..fb4ce70 100644 --- a/lib/common/models/page/setting_page_enum_models.dart +++ b/lib/common/models/page/setting_page_enum_models.dart @@ -10,6 +10,7 @@ enum SettingMenuType implements BaseEnum { faceSetting("50", "人脸管理"), facePluginSetting("55", "人脸设置"), productSetting("60", "商品管理"), + logPreview("62", "日志预览"), subScreenRoutes("65", "副屏路由"), versionSetting("70", "版本管理"), ; diff --git a/lib/common/routers/names.dart b/lib/common/routers/names.dart index 9024fc6..b548242 100644 --- a/lib/common/routers/names.dart +++ b/lib/common/routers/names.dart @@ -8,6 +8,9 @@ class RouteNames { /// 顾客屏全窗口预览,不装载终端侧栏和顶栏。 static const customer = '/customer'; + /// 欢迎副屏全窗口预览。 + static const customerWelcome = '/customer-welcome'; + /// 预约查询副屏全窗口预览。 static const menuQueryCustomer = '/menu-query-customer'; diff --git a/lib/common/routers/pages.dart b/lib/common/routers/pages.dart index c9e2d0d..5d140c7 100644 --- a/lib/common/routers/pages.dart +++ b/lib/common/routers/pages.dart @@ -3,6 +3,7 @@ import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; import 'package:pj_supermarket_cloud/pages/index.dart'; import 'package:pj_supermarket_cloud/sub_screen/page/customer_preview.dart'; +import 'package:pj_supermarket_cloud/sub_screen/page/customer_welcome_preview.dart'; import 'package:pj_supermarket_cloud/sub_screen/page/reservation_query_preview.dart'; class RoutePages { @@ -23,6 +24,10 @@ class RoutePages { name: RouteNames.customer, page: () => CustomerDisplayPreviewPage(arguments: Get.arguments), ), + GetPage( + name: RouteNames.customerWelcome, + page: () => const CustomerWelcomePreviewPage(), + ), GetPage( name: RouteNames.menuQueryCustomer, page: () => ReservationQueryPreviewPage(arguments: Get.arguments), diff --git a/lib/common/utils/app/app_face_tool.dart b/lib/common/utils/app/app_face_tool.dart index cfa19bb..c059625 100644 --- a/lib/common/utils/app/app_face_tool.dart +++ b/lib/common/utils/app/app_face_tool.dart @@ -11,9 +11,6 @@ abstract class AppFaceTool { if (AppRuntimeTool.useManualHardwareInput) return false; final isJump = await FaceDiscernPlugs.jumpFaceDiscernPage(); if (!isJump) return false; - Future.delayed(const Duration(seconds: 2), () { - AppSubScreenTool.hide(); - }); return true; } diff --git a/lib/common/utils/app/app_runtime_tool.dart b/lib/common/utils/app/app_runtime_tool.dart index 1e4d9a8..0fb8315 100644 --- a/lib/common/utils/app/app_runtime_tool.dart +++ b/lib/common/utils/app/app_runtime_tool.dart @@ -208,6 +208,15 @@ abstract class AppRuntimeTool { } } + /// 查询插件当前 Presentation 是否仍在展示,用于避免 showSubDisplay() 先关后开造成闪屏。 + static Future isSubScreenShowing() async { + try { + return await _channel.invokeMethod('isSubScreenShowing') ?? false; + } on PlatformException { + return false; + } + } + static void applyManualHardwareInput(bool enabled) { _manualHardwareInput = enabled; } diff --git a/lib/common/utils/app/app_sub_screen_tool.dart b/lib/common/utils/app/app_sub_screen_tool.dart index 45050be..aae948a 100644 --- a/lib/common/utils/app/app_sub_screen_tool.dart +++ b/lib/common/utils/app/app_sub_screen_tool.dart @@ -2,9 +2,11 @@ import 'package:flutter_subscreen_plugin/flutter_subscreen_plugin.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; -/// 统一控制副屏,确保虚拟机和单屏设备不会创建副屏 Presentation。 +/// 统一维护副屏常驻状态,确保业务切换只发送页面消息,不重复重建 Presentation。 abstract class AppSubScreenTool { static bool _available = false; + static bool _presentationRequested = false; + static Future? _showFuture; static bool get isAvailable => _available; @@ -12,6 +14,7 @@ abstract class AppSubScreenTool { await AppRuntimeTool.initializePlatform(); if (AppRuntimeTool.isEmulator || !AppRuntimeTool.hasMultipleDisplays) { _available = false; + _presentationRequested = false; if (reportStatus) AppDeviceStatusTool.updateSubScreen(false); return false; } @@ -19,32 +22,56 @@ abstract class AppSubScreenTool { final hasViceScreen = await SubScreenPlugin.isMultipleScreen; final hasOverlayPermission = await SubScreenPlugin.checkOverlayPermission; _available = hasViceScreen && hasOverlayPermission; + if (!_available) _presentationRequested = false; } catch (e) { _available = false; + _presentationRequested = false; logger.w("|副屏工具|检测副屏失败:$e"); } if (reportStatus) AppDeviceStatusTool.updateSubScreen(_available); return _available; } - static Future show() async { + static Future show({bool verifyPresentation = false}) async { + if (_presentationRequested && !verifyPresentation) return true; + final pending = _showFuture; + if (pending != null) return pending; + final future = _showOnce(verifyPresentation: verifyPresentation); + _showFuture = future; + try { + return await future; + } finally { + if (identical(_showFuture, future)) _showFuture = null; + } + } + + static Future _showOnce({required bool verifyPresentation}) async { final available = await refreshAvailability(); if (!available) return false; + if (_presentationRequested && !verifyPresentation) return true; try { + if (await AppRuntimeTool.isSubScreenShowing()) { + _presentationRequested = true; + return true; + } + // 插件 showSubDisplay() 会先关闭旧 Presentation;仅首次确认可用时调用, + // 后续业务入口只复用常驻副屏,避免顾客屏反复黑屏或闪烁。 SubScreenPlugin.doubleScreenShow(); + _presentationRequested = true; return true; } catch (e) { + _presentationRequested = false; + _available = false; + AppDeviceStatusTool.updateSubScreen(false); logger.w("|副屏工具|打开副屏失败:$e"); return false; } } + /// 兼容旧调用入口。应用运行期间禁止业务代码关闭 Presentation, + /// Android Activity 真正销毁时仍由原生层统一释放副屏窗口和 Engine。 static void hide() { if (!_available) return; - try { - SubScreenPlugin.doubleScreenCancel(); - } catch (e) { - logger.w("|副屏工具|关闭副屏失败:$e"); - } + logger.i("|副屏工具|忽略业务关闭请求,副屏保持常驻"); } } diff --git a/lib/common/utils/app/control/app_control.dart b/lib/common/utils/app/control/app_control.dart index ed63112..c27ac5f 100644 --- a/lib/common/utils/app/control/app_control.dart +++ b/lib/common/utils/app/control/app_control.dart @@ -153,6 +153,9 @@ class AppControl extends GetxController { detail: '正在读取运行环境与本地终端配置', ); await AppRuntimeTool.initializePlatform(); + // 插件会在 Android 注册阶段自动创建副屏;这里统一确认可用状态并记录常驻标记, + // 即使无副屏或权限不足也只影响设备状态,不阻塞后续启动流程。 + unawaited(AppSubScreenTool.show()); await AppSettingTool.removeSettingCache(); _setStartupStep( diff --git a/lib/common/utils/app/terminal_navigation_tool.dart b/lib/common/utils/app/terminal_navigation_tool.dart index 7e29bcd..e06032f 100644 --- a/lib/common/utils/app/terminal_navigation_tool.dart +++ b/lib/common/utils/app/terminal_navigation_tool.dart @@ -40,7 +40,7 @@ abstract final class TerminalNavigationTool { if (!await _verifyAdminSession(actionLabel: '退出登录')) return; Get.find().lock(); if (AppNetworkTool.isAutoNetwork()) { - // await AppSettingTool.setUserSetting(UserSetting()); + await AppSettingTool.setUserSetting(UserSetting()); } Get.offAllNamed(RouteNames.login); } diff --git a/lib/common/utils/logger/logger_tool.dart b/lib/common/utils/logger/logger_tool.dart index b1e55a3..497a438 100644 --- a/lib/common/utils/logger/logger_tool.dart +++ b/lib/common/utils/logger/logger_tool.dart @@ -7,10 +7,11 @@ import 'package:logger/logger.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; +final _consoleOutput = ConsoleOutput(); final logger = Logger( filter: AppLogFilter(), printer: customPrinter, - output: ConsoleOutput(), + output: _consoleOutput, ); final customPrinter = PrettyPrinter( lineLength: 0, @@ -22,6 +23,76 @@ final customPrinter = PrettyPrinter( noBoxingByDefault: true, ); +/// 设置页使用的日志尾部快照,只返回脱敏后的有限内容,避免大文件阻塞收银主线程。 +Future readAppLogPreview({ + String? path, + int maxBytes = 256 * 1024, + int maxLines = 1000, +}) async { + await logger.init; + return _consoleOutput.readPreview( + path: path, + maxBytes: maxBytes, + maxLines: maxLines, + ); +} + +/// 列出日志根目录中的文件,设置页据此构建可切换的日志目录树。 +Future listAppLogFiles() async { + await logger.init; + return _consoleOutput.listLogFiles(); +} + +class AppLogCatalog { + const AppLogCatalog({ + required this.rootPath, + required this.currentPath, + required this.files, + }); + + final String rootPath; + final String currentPath; + final List files; +} + +class AppLogFile { + const AppLogFile({ + required this.path, + required this.relativePath, + required this.name, + required this.fileSize, + required this.modifiedAt, + required this.isCurrent, + required this.exists, + }); + + final String path; + final String relativePath; + final String name; + final int fileSize; + final DateTime? modifiedAt; + final bool isCurrent; + final bool exists; +} + +class AppLogPreview { + const AppLogPreview({ + required this.path, + required this.content, + required this.fileSize, + required this.modifiedAt, + required this.lineCount, + required this.truncated, + }); + + final String path; + final String content; + final int fileSize; + final DateTime? modifiedAt; + final int lineCount; + final bool truncated; +} + class AppLogFilter extends LogFilter { @override bool shouldLog(LogEvent event) { @@ -42,6 +113,151 @@ class ConsoleOutput extends LogOutput { return _queue; } + /// 读取当前日志文件尾部,并在返回 UI 前移除终端颜色码和常见敏感字段。 + Future readPreview({ + String? path, + required int maxBytes, + required int maxLines, + }) async { + if (maxBytes <= 0 || maxLines <= 0) { + throw ArgumentError('日志预览范围必须大于 0'); + } + final currentPath = await _resolveCurrentLogPath(); + final resolvedPath = path ?? currentPath; + await _validatePreviewPath(resolvedPath, currentPath); + if (_samePath(resolvedPath, currentPath)) { + await _enqueue(() async => _sink?.flush()); + } + final file = File(resolvedPath); + if (!await file.exists()) { + return AppLogPreview( + path: resolvedPath, + content: '', + fileSize: 0, + modifiedAt: null, + lineCount: 0, + truncated: false, + ); + } + + final stat = await file.stat(); + final start = stat.size > maxBytes ? stat.size - maxBytes : 0; + final handle = await file.open(); + late final List bytes; + try { + await handle.setPosition(start); + bytes = await handle.read(stat.size - start); + } finally { + await handle.close(); + } + + var content = utf8.decode(bytes, allowMalformed: true); + content = _sanitizePreviewContent(content); + if (start > 0) content = _removePartialLeadingEntry(content); + + var lines = const LineSplitter().convert(content); + var truncated = start > 0; + if (lines.length > maxLines) { + lines = lines.sublist(lines.length - maxLines); + truncated = true; + } + return AppLogPreview( + path: resolvedPath, + content: lines.join('\n').trimRight(), + fileSize: stat.size, + modifiedAt: stat.modified, + lineCount: lines.length, + truncated: truncated, + ); + } + + /// 只扫描应用日志根目录中的 `.log` 文件,避免设置页变成任意文件浏览器。 + Future listLogFiles() async { + final currentPath = await _resolveCurrentLogPath(); + await _enqueue(() async => _sink?.flush()); + final root = Directory(currentPath).parent.parent; + final files = []; + if (await root.exists()) { + await for (final entity + in root.list(recursive: true, followLinks: false)) { + if (entity is! File || !entity.path.toLowerCase().endsWith('.log')) { + continue; + } + try { + final stat = await entity.stat(); + files.add( + AppLogFile( + path: entity.path, + relativePath: _relativePath(root.path, entity.path), + name: _fileName(entity.path), + fileSize: stat.size, + modifiedAt: stat.modified, + isCurrent: _samePath(entity.path, currentPath), + exists: true, + ), + ); + } on FileSystemException { + // 扫描期间文件可能被外部清理,跳过失效项即可由下一次刷新恢复。 + } + } + } + + if (!files.any((file) => file.isCurrent)) { + files.add( + AppLogFile( + path: currentPath, + relativePath: _relativePath(root.path, currentPath), + name: _fileName(currentPath), + fileSize: 0, + modifiedAt: null, + isCurrent: true, + exists: false, + ), + ); + } + files.sort((a, b) { + if (a.isCurrent != b.isCurrent) return a.isCurrent ? -1 : 1; + final timeCompare = (b.modifiedAt ?? + DateTime.fromMillisecondsSinceEpoch(0)) + .compareTo(a.modifiedAt ?? DateTime.fromMillisecondsSinceEpoch(0)); + if (timeCompare != 0) return timeCompare; + return b.relativePath.compareTo(a.relativePath); + }); + return AppLogCatalog( + rootPath: root.path, + currentPath: currentPath, + files: List.unmodifiable(files), + ); + } + + Future _resolveCurrentLogPath() async { + final path = _filePath ?? await AppFileTool.getLoggerBackupPath(); + if (ObjectUtil.isEmpty(path)) { + throw const FileSystemException('无法获取日志文件路径'); + } + return path!; + } + + Future _validatePreviewPath( + String path, + String currentPath, + ) async { + if (!path.toLowerCase().endsWith('.log')) { + throw const FileSystemException('仅支持预览日志文件'); + } + final root = Directory(currentPath).parent.parent; + final candidate = File(path); + final rootPath = await root.exists() + ? await root.resolveSymbolicLinks() + : root.absolute.path; + final candidatePath = await candidate.exists() + ? await candidate.resolveSymbolicLinks() + : candidate.absolute.path; + if (!_isPathInside(rootPath, candidatePath)) { + throw const FileSystemException('日志文件不在允许的目录中'); + } + } + @override Future init() async { try { @@ -108,3 +324,68 @@ class ConsoleOutput extends LogOutput { } } } + +String _removePartialLeadingEntry(String content) { + final entryEnd = content.indexOf('\n\n'); + if (entryEnd >= 0) return content.substring(entryEnd + 2); + final lineEnd = content.indexOf('\n'); + return lineEnd >= 0 ? content.substring(lineEnd + 1) : ''; +} + +bool _samePath(String first, String second) { + return File(first).absolute.path == File(second).absolute.path; +} + +bool _isPathInside(String rootPath, String candidatePath) { + final root = Directory(rootPath).absolute.path; + final candidate = File(candidatePath).absolute.path; + return candidate == root || + candidate.startsWith('$root${Platform.pathSeparator}'); +} + +String _relativePath(String rootPath, String filePath) { + final root = Directory(rootPath).absolute.path.replaceAll('\\', '/'); + final file = File(filePath).absolute.path.replaceAll('\\', '/'); + final prefix = '$root/'; + return file.startsWith(prefix) + ? file.substring(prefix.length) + : _fileName(file); +} + +String _fileName(String path) { + return path.replaceAll('\\', '/').split('/').last; +} + +String _sanitizePreviewContent(String content) { + var value = content.replaceAll( + RegExp(r'\x1B\[[0-?]*[ -/]*[@-~]'), + '', + ); + value = value.replaceAllMapped( + RegExp( + r'^.*(?:用户信息|下单参数|存储用户|同步参数|同步设备信息\s+sysInfo|二维码识别.*json|绑定设备信息pa:|添加到.*记录.*json).*$', + caseSensitive: false, + multiLine: true, + ), + (_) => '[包含敏感业务数据的日志已隐藏]', + ); + value = value.replaceAllMapped( + RegExp(r'Bearer\s+[A-Za-z0-9._~+/=-]+', caseSensitive: false), + (_) => 'Bearer ***', + ); + value = value.replaceAllMapped( + RegExp( + r'((?:"|\b)?(?:token|accessToken|refreshToken|password|passwd|secret|authorization|paymentCode|payCode|authCode|qrCode|serviceCode|shortCode|cardId|cardNo|cardNumber|phone|mobile|idCard|certNo|realName|userName|nickName|address|email|userId|orderId|serviceOrderId)(?:"|\b)?\s*(?:[:=]|\|)\s*)("[^"]*"|[^,\s;|}\]]+)', + caseSensitive: false, + ), + (match) { + final source = match.group(2)!; + return '${match.group(1)}${source.startsWith('"') ? '"***"' : '***'}'; + }, + ); + // 未带字段名的长编号也可能是手机号、饭卡号或付款码,仅保留定位所需的首尾片段。 + return value.replaceAllMapped(RegExp(r'\d{8,}'), (match) { + final source = match.group(0)!; + return '${source.substring(0, 3)}****${source.substring(source.length - 4)}'; + }); +} diff --git a/lib/common/utils/manage/tts_manage.dart b/lib/common/utils/manage/tts_manage.dart index 0b670f4..c83b861 100644 --- a/lib/common/utils/manage/tts_manage.dart +++ b/lib/common/utils/manage/tts_manage.dart @@ -1,51 +1,84 @@ import 'package:flutter_tts/flutter_tts.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; -// 语音转文字 +// 文字转语音 class TtsManage { - static const _tag = "语音转文字"; + static const _tag = "文字转语音"; + static const _iflytekEngine = "com.iflytek.speechcloud"; static final FlutterTts _flutterTts = FlutterTts(); static bool _isInitialized = false; + static bool _isAvailable = false; + static Future? _initializing; - // 初始化TTS + /// 初始化 TTS。 + /// + /// 收银终端只使用讯飞引擎;未安装或初始化失败时直接禁用语音, + /// 避免 TTS 插件异常阻断应用启动和收银流程。 static Future initialize() async { - if (!_isInitialized) { + if (_isInitialized) return; + if (_initializing != null) { + await _initializing; + return; + } + + final initializing = _initialize(); + _initializing = initializing; + try { + await initializing; + } finally { + _initializing = null; + } + } + + static Future _initialize() async { + try { final engines = await _flutterTts.getEngines; - if (engines.isEmpty) { - logger.e("|$_tag|没有可用的TTS引擎"); + final hasIflytekEngine = engines is List && + engines.any((engine) => engine.toString() == _iflytekEngine); + if (!hasIflytekEngine) { + logger.w("|$_tag|未安装讯飞TTS引擎,已禁用语音播报"); + return; } - if (engines.contains("com.iflytek.speechcloud")) { - await _flutterTts.setEngine("com.iflytek.speechcloud"); - } - // 设置语言为中文 + + await _flutterTts.setEngine(_iflytekEngine); await _flutterTts.setLanguage("zh-CN"); - // 设置音量 await _flutterTts.setVolume(1.0); - // 设置语速 await _flutterTts.setSpeechRate(0.95); - // 设置音调 await _flutterTts.setPitch(2.0); - // await _flutterTts.setQueueMode(1); + _isAvailable = true; + } catch (e, stack) { + logger.w("|$_tag|讯飞TTS初始化失败,已禁用语音播报error:|$e|stack:|$stack|"); + } finally { _isInitialized = true; } } - // 播放文本 + /// 播放文本;讯飞 TTS 不可用时静默跳过。 static Future speak(String text) async { await initialize(); - // await _flutterTts.awaitSpeakCompletion(true); - await _flutterTts.speak(text, focus: true); + if (!_isAvailable) return; + + try { + await _flutterTts.speak(text, focus: true); + } catch (e, stack) { + logger.w("|$_tag|语音播放失败error:|$e|stack:|$stack|"); + } } // 停止播放 static Future stop() async { - await _flutterTts.stop(); + if (!_isAvailable) return; + try { + await _flutterTts.stop(); + } catch (e, stack) { + logger.w("|$_tag|停止语音播放失败error:|$e|stack:|$stack|"); + } } // 释放资源 static Future dispose() async { - await _flutterTts.stop(); + await stop(); } /// 将数字转换为中文读法(手机号码格式) diff --git a/lib/common/values/constants.dart b/lib/common/values/constants.dart index 98270b1..775d2ce 100644 --- a/lib/common/values/constants.dart +++ b/lib/common/values/constants.dart @@ -1,8 +1,11 @@ /// 常量 class Constants { - // oauth2 APP模式客户端信息 + // mini 客户端免验证码,且服务端不会解密登录密码。 static const oauth2AppClient = 'mini:mini'; + /// 登录密码加密开关,必须与 OAuth2 客户端的密码加密配置保持一致。 + static const loginPasswordEncryptionEnabled = false; + // oauth2 登录的url static const oauth2AppApiUrl = 'auth/oauth2/token'; @@ -10,8 +13,8 @@ class Constants { static const pwdEncKey = 'pigxpigxpigxpigx'; /// 服务 url 线上 - // static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; - static const apiUrl = 'https://www.zianxin.com/api'; + static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; + // static const apiUrl = 'https://www.zianxin.com/api'; // static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; diff --git a/lib/pages/root/login/controller.dart b/lib/pages/root/login/controller.dart index 12088ce..39ab696 100644 --- a/lib/pages/root/login/controller.dart +++ b/lib/pages/root/login/controller.dart @@ -27,6 +27,8 @@ class LoginController extends GetxController { // 密码 final passwordC = TextEditingController(); + bool _manualInputStarted = false; + _initData() { update(["login"]); } @@ -38,7 +40,7 @@ class LoginController extends GetxController { userSetting = await AppSettingTool.getUserSetting(); sysType.value = sysSetting.sysType; await getTenantList(); - await autoLogin(); + if (!_manualInputStarted) await autoLogin(); tenantC.addValueListener((v) async { final target = tenantList.firstWhereOrNull((i) => i.code == v.first); if (target == null) return; @@ -59,9 +61,12 @@ class LoginController extends GetxController { _initData(); } - Future doLogin(bool visibleMsg) async { + Future doLogin(bool visibleMsg, {bool automatic = false}) async { + if (automatic && _manualInputStarted) return; if (!_validateForm()) return; SysSetting sysSetting = await AppSettingTool.getSysSetting(); + // 自动登录等待本地配置期间,用户可能已经点入输入框;此时人工输入优先,不能再用 Loading 抢走焦点。 + if (automatic && _manualInputStarted) return; final pa = { 'username': usernameC.value.text, 'password': passwordC.value.text, @@ -108,12 +113,14 @@ class LoginController extends GetxController { } Future autoLogin() async { - if (ObjectUtil.isEmpty(userSetting.username) || + if (_manualInputStarted || + ObjectUtil.isEmpty(userSetting.username) || ObjectUtil.isEmpty(userSetting.password) || ObjectUtil.isEmpty(userSetting.sysType?.name)) { return; } final sysSetting = await AppSettingTool.getSysSetting(); + if (_manualInputStarted) return; usernameC.text = userSetting.username!; passwordC.text = userSetting.password!; // 无网络并且绑定过直接使用 @@ -130,7 +137,12 @@ class LoginController extends GetxController { Toast.error(msg: "未绑定机器,不能开启离线模式"); return; } - await doLogin(true); + await doLogin(true, automatic: true); + } + + /// 用户主动编辑账号或密码后,本轮初始化只保留人工登录,避免延迟的自动登录弹层关闭软键盘。 + void markManualInputStarted() { + _manualInputStarted = true; } bool _validateForm() { diff --git a/lib/pages/root/login/view.dart b/lib/pages/root/login/view.dart index 9318016..9d11a02 100644 --- a/lib/pages/root/login/view.dart +++ b/lib/pages/root/login/view.dart @@ -10,7 +10,237 @@ import 'controller.dart'; class LoginPage extends GetView { const LoginPage({super.key}); - Widget _buildBrandArea(BuildContext context) { + @override + Widget build(BuildContext context) { + return GetBuilder( + init: LoginController(), + id: 'login', + builder: (_) => FScaffold( + contentPad: false, + resizeToAvoidBottomInset: true, + content: ColoredBox( + color: XfjDesignTokens.canvas, + child: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final keyboardVisible = + MediaQuery.viewInsetsOf(context).bottom > 0; + return Padding( + padding: keyboardVisible + ? EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h) + : EdgeInsets.all(36.w), + child: Row( + children: [ + if (!keyboardVisible) ...[ + const Expanded( + key: ValueKey('login-brand-panel'), + flex: 11, + child: _LoginBrandPanel(), + ), + SizedBox(width: XfjDesignTokens.cardGap.w), + ], + Expanded( + key: const ValueKey('login-form-panel'), + flex: keyboardVisible ? 1 : 9, + child: _LoginFormPanel( + controller: controller, + compact: keyboardVisible, + ), + ), + ], + ), + ); + }, + ), + ), + ), + ), + ); + } +} + +/// 键盘弹出时保留表单子树并切换为内部滚动,避免横屏真机重建输入框后丢失焦点。 +class _LoginFormPanel extends StatefulWidget { + const _LoginFormPanel({required this.controller, required this.compact}); + + final LoginController controller; + final bool compact; + + @override + State<_LoginFormPanel> createState() => _LoginFormPanelState(); +} + +class _LoginFormPanelState extends State<_LoginFormPanel> { + bool _obscurePassword = true; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final content = Padding( + padding: widget.compact + ? EdgeInsets.symmetric(horizontal: 36.w, vertical: 20.h) + : EdgeInsets.symmetric(horizontal: 54.w, vertical: 42.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: + widget.compact ? MainAxisAlignment.start : MainAxisAlignment.center, + children: [ + Container( + width: widget.compact ? 48.w : 58.w, + height: widget.compact ? 48.w : 58.w, + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: FIcon( + FAssets.icons.lockKeyhole, + color: XfjDesignTokens.primary, + ), + ), + SizedBox(height: widget.compact ? 12.h : 22.h), + Text( + '登录收银终端', + style: theme.typography.xl4.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: widget.compact ? 6.h : 10.h), + Text( + '选择所属单位,并使用终端账号继续', + style: theme.typography.base.copyWith(color: XfjDesignTokens.muted), + ), + SizedBox(height: widget.compact ? 16.h : 30.h), + Obx( + () => appTenantDialog( + context, + widget.controller.tenantC, + list: widget.controller.tenantList, + ), + ), + SizedBox(height: widget.compact ? 12.h : 20.h), + FTextField( + key: const ValueKey('login-username-field'), + textInputAction: TextInputAction.next, + keyboardType: TextInputType.text, + label: const Text('用户名'), + controller: widget.controller.usernameC, + hint: '请输入用户名', + maxLines: 1, + autocorrect: false, + enableSuggestions: false, + autofillHints: const [AutofillHints.username], + onTap: widget.controller.markManualInputStarted, + onChange: (_) => widget.controller.markManualInputStarted(), + onSubmit: (_) => FocusScope.of(context).nextFocus(), + ), + SizedBox(height: widget.compact ? 12.h : 20.h), + Builder( + builder: (context) { + final passwordActionLabel = _obscurePassword ? '显示密码' : '隐藏密码'; + return FTextField.password( + key: const ValueKey('login-password-field'), + textInputAction: TextInputAction.done, + keyboardType: TextInputType.visiblePassword, + label: const Text('密码'), + controller: widget.controller.passwordC, + hint: '请输入密码', + obscureText: _obscurePassword, + enableSuggestions: false, + autofillHints: const [AutofillHints.password], + onTap: widget.controller.markManualInputStarted, + onChange: (_) => widget.controller.markManualInputStarted(), + onSubmit: (_) => widget.controller.doLogin(true), + suffixBuilder: (_, __, ___) => Semantics( + button: true, + label: passwordActionLabel, + child: Tooltip( + message: passwordActionLabel, + child: InkWell( + onTap: () => setState( + () => _obscurePassword = !_obscurePassword, + ), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + child: SizedBox( + width: 48.w, + height: 48.h, + child: FIcon( + _obscurePassword + ? FAssets.icons.eye + : FAssets.icons.eyeOff, + color: XfjDesignTokens.muted, + size: 22.sp, + ).center(), + ), + ), + ), + ), + ); + }, + ), + SizedBox(height: widget.compact ? 16.h : 30.h), + SizedBox( + width: double.infinity, + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: 60.h), + child: FButton( + label: Text( + '进入终端', + style: theme.typography.lg.copyWith(color: Colors.white), + ), + onPress: () => widget.controller.doLogin(true), + ), + ), + ), + SizedBox(height: widget.compact ? 12.h : 18.h), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FIcon(FAssets.icons.shieldCheck, color: XfjDesignTokens.success), + SizedBox(width: 8.w), + Flexible( + child: Text( + '登录信息受本地安全策略保护', + textAlign: TextAlign.center, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ), + ], + ), + ], + ), + ); + + return TerminalSurface( + padding: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + physics: widget.compact + ? const ClampingScrollPhysics() + : const NeverScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: content, + ), + ), + ), + ); + } +} + +class _LoginBrandPanel extends StatelessWidget { + const _LoginBrandPanel(); + + @override + Widget build(BuildContext context) { final theme = FTheme.of(context); return Container( padding: EdgeInsets.all(48.w), @@ -50,9 +280,9 @@ class LoginPage extends GetView { Row( children: [ _Capability(icon: FAssets.icons.monitor, title: '双屏协作'), - SizedBox(width: 12), + SizedBox(width: 12.w), _Capability(icon: FAssets.icons.creditCard, title: '多种支付'), - SizedBox(width: 12), + SizedBox(width: 12.w), _Capability(icon: FAssets.icons.printer, title: '设备协同'), ], ), @@ -60,124 +290,6 @@ class LoginPage extends GetView { ), ); } - - Widget _buildLoginForm(BuildContext context) { - final theme = FTheme.of(context); - return TerminalSurface( - padding: EdgeInsets.symmetric(horizontal: 54.w, vertical: 42.h), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 58.w, - height: 58.w, - decoration: BoxDecoration( - color: XfjDesignTokens.primarySoft, - borderRadius: BorderRadius.circular(16), - ), - child: FIcon(FAssets.icons.lockKeyhole, - color: XfjDesignTokens.primary), - ), - SizedBox(height: 22.h), - Text( - '登录收银终端', - style: theme.typography.xl4.copyWith( - color: XfjDesignTokens.ink, - fontWeight: FontWeight.w800, - ), - ), - SizedBox(height: 10.h), - Text( - '选择所属单位,并使用终端账号继续', - style: theme.typography.base.copyWith(color: XfjDesignTokens.muted), - ), - SizedBox(height: 30.h), - Obx( - () => appTenantDialog( - context, - controller.tenantC, - list: controller.tenantList, - ), - ), - SizedBox(height: 20.h), - Obx( - () => FTextField( - textInputAction: controller.sysType.value == SysType.SUPERMARKET - ? TextInputAction.none - : TextInputAction.newline, - keyboardType: controller.sysType.value == SysType.SUPERMARKET - ? TextInputType.none - : TextInputType.multiline, - label: const Text('用户名'), - controller: controller.usernameC, - hint: '请输入用户名', - onSubmit: (_) => controller.doLogin(true), - ), - ), - SizedBox(height: 20.h), - Obx( - () => FTextField.password( - textInputAction: controller.sysType.value == SysType.SUPERMARKET - ? TextInputAction.none - : TextInputAction.newline, - keyboardType: controller.sysType.value == SysType.SUPERMARKET - ? TextInputType.none - : TextInputType.multiline, - label: const Text('密码'), - controller: controller.passwordC, - hint: '请输入密码', - onSubmit: (_) => controller.doLogin(true), - ), - ), - SizedBox(height: 30.h), - FButton( - label: Text( - '进入终端', - style: theme.typography.lg.copyWith(color: Colors.white), - ), - onPress: () => controller.doLogin(true), - ).height(60.h).width(double.infinity), - SizedBox(height: 18.h), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - FIcon(FAssets.icons.shieldCheck, color: XfjDesignTokens.success), - SizedBox(width: 8.w), - Text( - '登录信息受本地安全策略保护', - style: - theme.typography.sm.copyWith(color: XfjDesignTokens.muted), - ), - ], - ), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - return GetBuilder( - init: LoginController(), - id: 'login', - builder: (_) => ColoredBox( - color: XfjDesignTokens.canvas, - child: SafeArea( - child: Padding( - padding: EdgeInsets.all(36.w), - child: Row( - children: [ - _buildBrandArea(context).expanded(flex: 11), - SizedBox(width: XfjDesignTokens.cardGap.w), - _buildLoginForm(context).expanded(flex: 9), - ], - ), - ), - ), - ), - ); - } } /// 登录品牌区的能力标签仅表达终端能力,避免在页面内重复堆叠相同布局。 diff --git a/lib/pages/terminal/setting/view.dart b/lib/pages/terminal/setting/view.dart index a9bd763..58839d6 100644 --- a/lib/pages/terminal/setting/view.dart +++ b/lib/pages/terminal/setting/view.dart @@ -241,6 +241,7 @@ class _SettingContent extends StatelessWidget { if (!controller.isBound.value && type != SettingMenuType.baseInfo && type != SettingMenuType.backup && + type != SettingMenuType.logPreview && type != SettingMenuType.subScreenRoutes) { return const SettingEmpty( message: '设备尚未绑定,请先在“基本信息”中完成绑定', @@ -264,6 +265,7 @@ class _SettingContent extends StatelessWidget { SettingMenuType.faceSetting => settingFace(context), SettingMenuType.facePluginSetting => settingFacePlugin(context), SettingMenuType.productSetting => settingProduct(context), + SettingMenuType.logPreview => const SettingLogPreview(), SettingMenuType.subScreenRoutes => const SettingSubScreenRoutes(), SettingMenuType.versionSetting => settingVersionSetting(context), }; @@ -279,6 +281,7 @@ SvgAsset _menuIcon(SettingMenuType type) { SettingMenuType.faceSetting => FAssets.icons.scanFace, SettingMenuType.facePluginSetting => FAssets.icons.shieldCheck, SettingMenuType.productSetting => FAssets.icons.package, + SettingMenuType.logPreview => FAssets.icons.logs, SettingMenuType.subScreenRoutes => FAssets.icons.monitor, SettingMenuType.versionSetting => FAssets.icons.rocket, }; @@ -293,6 +296,7 @@ String _menuDescription(SettingMenuType type) { SettingMenuType.faceSetting => '查询、同步和维护本地人脸样本', SettingMenuType.facePluginSetting => '查看插件授权、引擎状态和内存人脸库注册进度', SettingMenuType.productSetting => '查询本地商品、同步分类商品和维护默认菜品', + SettingMenuType.logPreview => '按目录切换历史日志,查看脱敏后的最近内容', SettingMenuType.subScreenRoutes => '选择顾客屏演示状态并在当前应用窗口中全屏检查', SettingMenuType.versionSetting => '查看可用终端版本、更新内容和下载状态', }; diff --git a/lib/pages/terminal/setting/widgets/index.dart b/lib/pages/terminal/setting/widgets/index.dart index 47de730..4373d9f 100644 --- a/lib/pages/terminal/setting/widgets/index.dart +++ b/lib/pages/terminal/setting/widgets/index.dart @@ -5,6 +5,7 @@ export './setting_baseInfo.dart'; export './setting_face.dart'; export './setting_face_plugin.dart'; export './setting_kiosk_exit_button.dart'; +export './setting_log_preview.dart'; export './setting_pay.dart'; export './setting_pay_type_editor.dart'; export './setting_product.dart'; diff --git a/lib/pages/terminal/setting/widgets/setting_log_preview.dart b/lib/pages/terminal/setting/widgets/setting_log_preview.dart new file mode 100644 index 0000000..def0ca9 --- /dev/null +++ b/lib/pages/terminal/setting/widgets/setting_log_preview.dart @@ -0,0 +1,970 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:common_utils/common_utils.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +import 'setting_components.dart'; + +/// 日志预览采用独立生命周期,离开设置分区后立即停止轮询和输入监听。 +class SettingLogPreview extends StatefulWidget { + const SettingLogPreview({super.key}); + + @override + State createState() => _SettingLogPreviewState(); +} + +class _SettingLogPreviewState extends State { + final TextEditingController _keywordController = TextEditingController(); + final ScrollController _catalogScrollController = ScrollController(); + final ScrollController _previewScrollController = ScrollController(); + Timer? _autoRefreshTimer; + AppLogCatalog? _catalog; + AppLogPreview? _preview; + String? _selectedPath; + bool _loadingCatalog = true; + bool _loadingPreview = true; + bool _refreshing = false; + bool _autoRefresh = false; + String _keyword = ''; + String _errorMessage = ''; + int _previewRequestId = 0; + + @override + void initState() { + super.initState(); + _keywordController.addListener(_onKeywordChanged); + unawaited(_reload()); + } + + @override + void dispose() { + _autoRefreshTimer?.cancel(); + _keywordController + ..removeListener(_onKeywordChanged) + ..dispose(); + _catalogScrollController.dispose(); + _previewScrollController.dispose(); + super.dispose(); + } + + void _onKeywordChanged() { + final value = _keywordController.text.trim(); + if (value == _keyword || !mounted) return; + setState(() => _keyword = value); + } + + Future _reload({bool quiet = false}) async { + if (_refreshing) return; + _refreshing = true; + if (mounted) { + setState(() { + if (!quiet) _loadingCatalog = true; + }); + } + try { + final catalog = await listAppLogFiles(); + final selectedPath = catalog.files.any( + (file) => file.path == _selectedPath, + ) + ? _selectedPath! + : catalog.currentPath; + if (!mounted) return; + setState(() { + _catalog = catalog; + _selectedPath = selectedPath; + _loadingCatalog = false; + _errorMessage = ''; + }); + await _loadPreview(selectedPath, quiet: quiet); + } catch (e, stack) { + logger.e('|日志预览|读取失败error:|$e|stack:|$stack|'); + if (!mounted) return; + setState(() { + _loadingCatalog = false; + _loadingPreview = false; + _errorMessage = '日志目录读取失败,请检查存储权限后重试'; + }); + } finally { + if (mounted) { + setState(() => _refreshing = false); + } else { + _refreshing = false; + } + } + } + + Future _loadPreview(String path, {bool quiet = false}) async { + final requestId = ++_previewRequestId; + if (mounted) { + setState(() { + _selectedPath = path; + if (!quiet) { + _loadingPreview = true; + _preview = null; + } + _errorMessage = ''; + }); + } + try { + final preview = await readAppLogPreview(path: path); + if (!mounted || requestId != _previewRequestId) return; + setState(() { + _preview = preview; + _loadingPreview = false; + }); + } catch (e, stack) { + logger.e('|日志预览|读取文件失败error:|$e|stack:|$stack|'); + if (!mounted || requestId != _previewRequestId) return; + setState(() { + _loadingPreview = false; + _errorMessage = '日志文件读取失败,文件可能已被移动或清理'; + }); + } + } + + void _selectFile(AppLogFile file) { + if (_selectedPath == file.path) return; + if (_autoRefresh && !file.isCurrent) _toggleAutoRefresh(false); + unawaited(_loadPreview(file.path)); + } + + void _toggleAutoRefresh(bool value) { + _autoRefreshTimer?.cancel(); + _autoRefreshTimer = null; + setState(() => _autoRefresh = value); + if (value) { + _autoRefreshTimer = Timer.periodic( + const Duration(seconds: 3), + (_) { + final path = _selectedPath; + if (path != null) unawaited(_loadPreview(path, quiet: true)); + }, + ); + final path = _selectedPath; + if (path != null) unawaited(_loadPreview(path, quiet: true)); + } + } + + List get _visibleLines { + final content = _preview?.content ?? ''; + final lines = const LineSplitter().convert(content); + if (_keyword.isEmpty) return lines; + final keyword = _keyword.toLowerCase(); + return lines + .where((line) => line.toLowerCase().contains(keyword)) + .toList(growable: false); + } + + @override + Widget build(BuildContext context) { + final lines = _visibleLines; + return Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + ), + child: Row( + children: [ + SizedBox(width: 278.w, child: _buildLogTree(context)), + const VerticalDivider( + width: 1, + thickness: 1, + color: XfjDesignTokens.line, + ), + Expanded( + child: Column( + children: [ + _buildToolbar(context, lines), + Expanded(child: _buildPreview(context, lines)), + ], + ), + ), + ], + ), + ); + } + + Widget _buildLogTree(BuildContext context) { + final theme = FTheme.of(context); + final catalog = _catalog; + final groupedFiles = _groupedFiles; + return ColoredBox( + color: const Color(0xFFF8F9FB), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(14.w, 12.h, 10.w, 10.h), + child: Row( + children: [ + FIcon( + FAssets.icons.folderTree, + color: XfjDesignTokens.primary, + size: 20.sp, + ), + SizedBox(width: 8.w), + Expanded( + child: Text( + '日志目录', + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + ), + _CompactIconButton( + label: '刷新日志目录', + icon: FAssets.icons.refreshCw, + onTap: _refreshing ? null : () => unawaited(_reload()), + ), + ], + ), + ), + if (catalog != null) + Padding( + padding: EdgeInsets.fromLTRB(14.w, 0, 14.w, 10.h), + child: Text( + '${catalog.files.length} 个日志文件', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ), + const Divider(height: 1, thickness: 1), + Expanded( + child: _loadingCatalog && catalog == null + ? const Center(child: CircularProgressIndicator()) + : groupedFiles.isEmpty + ? SettingEmpty( + message: '暂无日志文件', + icon: FAssets.icons.logs, + ) + : Scrollbar( + controller: _catalogScrollController, + thumbVisibility: true, + trackVisibility: true, + interactive: true, + child: ListView( + controller: _catalogScrollController, + padding: EdgeInsets.fromLTRB(0, 8.h, 10.w, 8.h), + children: groupedFiles.entries + .map( + (entry) => _LogTreeGroup( + label: entry.key, + files: entry.value, + selectedPath: _selectedPath, + onSelected: _selectFile, + ), + ) + .toList(growable: false), + ), + ), + ), + if (catalog != null) + Semantics( + button: true, + label: '复制日志目录路径', + child: InkWell( + onTap: () => KeywordTool.copy(catalog.rootPath), + child: Container( + constraints: BoxConstraints(minHeight: 48.h), + padding: EdgeInsets.symmetric(horizontal: 12.w), + child: Row( + children: [ + FIcon( + FAssets.icons.folderOpen, + color: XfjDesignTokens.muted, + size: 18.sp, + ), + SizedBox(width: 8.w), + Expanded( + child: Text( + catalog.rootPath, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ), + SizedBox(width: 8.w), + FIcon( + FAssets.icons.copy, + color: XfjDesignTokens.primary, + size: 17.sp, + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + Map> get _groupedFiles { + final groups = >{}; + for (final file in _catalog?.files ?? const []) { + final separator = file.relativePath.indexOf('/'); + final label = + separator < 0 ? '日志文件' : file.relativePath.substring(0, separator); + groups.putIfAbsent(label, () => []).add(file); + } + return groups; + } + + Widget _buildToolbar(BuildContext context, List lines) { + final theme = FTheme.of(context); + final preview = _preview; + final selectedFile = _selectedFile; + return Container( + padding: EdgeInsets.all(8.w), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: XfjDesignTokens.line)), + ), + child: Row( + children: [ + SizedBox( + width: 300.w, + child: Row( + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + selectedFile?.name ?? '正在获取日志文件', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + ), + if (selectedFile?.isCurrent ?? false) ...[ + SizedBox(width: 8.w), + _CurrentLogBadge(), + ], + ], + ), + SizedBox(height: 2.h), + Text( + preview == null + ? '正在读取文件信息' + : '${_formatBytes(preview.fileSize)} · ${preview.lineCount} 行 · ${_formatTime(preview.modifiedAt)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + _CompactIconButton( + label: '复制当前日志路径', + icon: FAssets.icons.copy, + onTap: preview == null + ? null + : () => KeywordTool.copy(preview.path), + ), + ], + ), + ), + SizedBox(width: 8.w), + Container(width: 1, height: 36.h, color: XfjDesignTokens.line), + SizedBox(width: 10.w), + Expanded( + child: Semantics( + textField: true, + label: '筛选日志关键词', + // Forui 会按运行时字号和主题内边距计算实际高度,只设最小触控高度, + // 避免固定 48.h 在字号缩放后裁掉输入框底部并触发 RenderFlex 溢出。 + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: 48.h), + child: FTextField( + controller: _keywordController, + hint: '筛选关键词,如 error、支付、MQTT', + maxLines: 1, + textInputAction: TextInputAction.search, + prefixBuilder: (_, __, ___) => Padding( + padding: EdgeInsets.only(left: 12.w, right: 8.w), + child: FIcon( + FAssets.icons.search, + color: XfjDesignTokens.muted, + size: 19.sp, + ), + ), + ), + ), + ), + ), + SizedBox(width: 8.w), + _CompactActionButton( + label: '刷新', + icon: FAssets.icons.refreshCw, + onTap: _refreshing ? null : () => unawaited(_reload()), + ), + SizedBox(width: 8.w), + _CompactActionButton( + label: '复制结果', + icon: FAssets.icons.copy, + onTap: lines.isEmpty + ? null + : () async { + await Clipboard.setData( + ClipboardData(text: lines.join('\n')), + ); + Toast.success(msg: '已复制当前筛选结果'); + }, + ), + SizedBox(width: 8.w), + Container( + height: 48.h, + padding: EdgeInsets.symmetric(horizontal: 12.w), + decoration: BoxDecoration( + color: XfjDesignTokens.control, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: Row( + children: [ + Text( + '自动刷新', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(width: 8.w), + FSwitch( + semanticLabel: '每 3 秒自动刷新当前日志', + value: _autoRefresh, + enabled: selectedFile?.isCurrent ?? false, + onChange: _toggleAutoRefresh, + ), + ], + ), + ), + ], + ), + ); + } + + AppLogFile? get _selectedFile { + for (final file in _catalog?.files ?? const []) { + if (file.path == _selectedPath) return file; + } + return null; + } + + Widget _buildPreview(BuildContext context, List lines) { + if (_loadingPreview && _preview == null) { + return const Center(child: CircularProgressIndicator()); + } + if (_errorMessage.isNotEmpty) { + return _LogPreviewState( + icon: FAssets.icons.circleAlert, + message: _errorMessage, + actionLabel: '重新读取', + onTap: _selectedPath == null + ? () => unawaited(_reload()) + : () => unawaited(_loadPreview(_selectedPath!)), + ); + } + if ((_preview?.content ?? '').isEmpty) { + return _LogPreviewState( + icon: FAssets.icons.logs, + message: '当前还没有文件日志,发生运行事件后可手动刷新查看', + ); + } + if (lines.isEmpty) { + return _LogPreviewState( + icon: FAssets.icons.search, + message: '没有匹配当前关键词的日志,请调整筛选条件', + ); + } + + final theme = FTheme.of(context); + final preview = _preview!; + return Container( + color: const Color(0xFFF8F9FB), + child: Column( + children: [ + if (preview.truncated) + Container( + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 6.h), + color: const Color(0xFFFFF9E8), + child: Text( + '文件较大,仅显示最近 ${preview.lineCount} 行;完整文件可在左侧切换或通过“资源备份”导出。', + style: theme.typography.sm.copyWith( + color: const Color(0xFF9A6700), + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded( + child: Scrollbar( + controller: _previewScrollController, + thumbVisibility: true, + trackVisibility: true, + interactive: true, + child: SelectionArea( + child: ListView.builder( + controller: _previewScrollController, + padding: EdgeInsets.fromLTRB(0, 8.h, 10.w, 8.h), + itemCount: lines.length, + itemBuilder: (context, index) { + final line = lines[index]; + return Container( + padding: EdgeInsets.symmetric( + horizontal: 14.w, + vertical: 3.h, + ), + color: _lineBackground(line), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 58.w, + child: Text( + '${index + 1}', + textAlign: TextAlign.right, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), + ), + ), + SizedBox(width: 12.w), + Expanded( + child: Text( + line.isEmpty ? ' ' : line, + style: theme.typography.sm.copyWith( + color: _lineColor(line), + height: 1.55, + fontFamily: 'monospace', + ), + ), + ), + ], + ), + ); + }, + ), + ), + ), + ), + ], + ), + ); + } +} + +class _LogTreeGroup extends StatelessWidget { + const _LogTreeGroup({ + required this.label, + required this.files, + required this.selectedPath, + required this.onSelected, + }); + + final String label; + final List files; + final String? selectedPath; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Padding( + padding: EdgeInsets.only(bottom: 6.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h), + child: Row( + children: [ + FIcon( + label == 'manual' + ? FAssets.icons.folderArchive + : FAssets.icons.folderOpen, + color: XfjDesignTokens.muted, + size: 18.sp, + ), + SizedBox(width: 8.w), + Expanded( + child: Text( + label == 'manual' ? '手动快照' : label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ), + ), + ), + Text( + '${files.length}', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + ...files.map( + (file) => _LogTreeFileTile( + file: file, + selected: selectedPath == file.path, + onTap: () => onSelected(file), + ), + ), + ], + ), + ); + } +} + +class _LogTreeFileTile extends StatelessWidget { + const _LogTreeFileTile({ + required this.file, + required this.selected, + required this.onTap, + }); + + final AppLogFile file; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Semantics( + button: true, + selected: selected, + label: '${file.isCurrent ? '当前日志,' : ''}${file.name}', + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 2.h), + child: Material( + color: selected ? XfjDesignTokens.primarySoft : Colors.transparent, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + child: Container( + constraints: BoxConstraints(minHeight: 56.h), + padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 7.h), + child: Row( + children: [ + FIcon( + FAssets.icons.fileText, + color: selected + ? XfjDesignTokens.primary + : XfjDesignTokens.muted, + size: 19.sp, + ), + SizedBox(width: 9.w), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + file.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: selected + ? XfjDesignTokens.primary + : XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + ), + if (file.isCurrent) ...[ + SizedBox(width: 6.w), + Container( + padding: EdgeInsets.symmetric( + horizontal: 5.w, + vertical: 1.h, + ), + decoration: const BoxDecoration( + color: Color(0xFFECFBF3), + borderRadius: BorderRadius.all( + Radius.circular(999), + ), + ), + child: Text( + '当前', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.success, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ], + ), + SizedBox(height: 2.h), + Text( + file.exists + ? '${_formatBytes(file.fileSize)} · ${_formatTreeTime(file.modifiedAt)}' + : '等待生成', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _CompactIconButton extends StatelessWidget { + const _CompactIconButton({ + required this.label, + required this.icon, + required this.onTap, + }); + + final String label; + final SvgAsset icon; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final enabled = onTap != null; + return Semantics( + button: true, + enabled: enabled, + label: label, + child: Opacity( + opacity: enabled ? 1 : 0.45, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + child: SizedBox( + width: 48.w, + height: 48.h, + child: Center( + child: FIcon( + icon, + color: XfjDesignTokens.primary, + size: 19.sp, + ), + ), + ), + ), + ), + ), + ); + } +} + +class _CompactActionButton extends StatelessWidget { + const _CompactActionButton({ + required this.label, + required this.icon, + required this.onTap, + }); + + final String label; + final SvgAsset icon; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final enabled = onTap != null; + return Semantics( + button: true, + enabled: enabled, + label: label, + child: Opacity( + opacity: enabled ? 1 : 0.45, + child: Material( + color: XfjDesignTokens.surface, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + child: Container( + height: 48.h, + padding: EdgeInsets.symmetric(horizontal: 13.w), + decoration: BoxDecoration( + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FIcon(icon, color: XfjDesignTokens.ink, size: 18.sp), + SizedBox(width: 7.w), + Text( + label, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _CurrentLogBadge extends StatelessWidget { + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Container( + padding: EdgeInsets.symmetric(horizontal: 7.w, vertical: 2.h), + decoration: BoxDecoration( + color: const Color(0xFFECFBF3), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '当前', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.success, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _LogPreviewState extends StatelessWidget { + const _LogPreviewState({ + required this.icon, + required this.message, + this.actionLabel, + this.onTap, + }); + + final SvgAsset icon; + final String message; + final String? actionLabel; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FIcon(icon, color: XfjDesignTokens.muted, size: 44.sp), + SizedBox(height: 12.h), + Text( + message, + textAlign: TextAlign.center, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.muted, + ), + ), + if (actionLabel != null && onTap != null) ...[ + SizedBox(height: 16.h), + SettingActionButton( + label: actionLabel!, + icon: FAssets.icons.refreshCw, + onTap: onTap!, + ), + ], + ], + ), + ); + } +} + +String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB'; +} + +String _formatTime(DateTime? time) { + if (time == null) return '尚未生成'; + return DateUtil.formatDate(time, format: DateFormats.full); +} + +String _formatTreeTime(DateTime? time) { + if (time == null) return '未知时间'; + return DateUtil.formatDate(time, format: DateFormats.mo_d_h_m); +} + +Color _lineColor(String line) { + final value = line.toLowerCase(); + if (value.contains('error') || value.contains('失败')) { + return XfjDesignTokens.danger; + } + if (value.contains('warning') || value.contains('警告')) { + return const Color(0xFF9A6700); + } + return XfjDesignTokens.ink; +} + +Color _lineBackground(String line) { + final value = line.toLowerCase(); + if (value.contains('error') || value.contains('失败')) { + return const Color(0xFFFFF5F4); + } + if (value.contains('warning') || value.contains('警告')) { + return const Color(0xFFFFFBEB); + } + return Colors.transparent; +} diff --git a/lib/pages/terminal/setting/widgets/setting_sub_screen_routes.dart b/lib/pages/terminal/setting/widgets/setting_sub_screen_routes.dart index cedecdd..3029a6b 100644 --- a/lib/pages/terminal/setting/widgets/setting_sub_screen_routes.dart +++ b/lib/pages/terminal/setting/widgets/setting_sub_screen_routes.dart @@ -7,6 +7,7 @@ import 'package:pj_supermarket_cloud/common/index.dart'; import 'package:pj_supermarket_cloud/sub_screen/common/models/customer_display_models.dart'; import 'package:pj_supermarket_cloud/sub_screen/common/models/reservation_query_display_models.dart'; import 'package:pj_supermarket_cloud/sub_screen/page/customer_preview.dart'; +import 'package:pj_supermarket_cloud/sub_screen/page/customer_welcome_preview.dart'; import 'package:pj_supermarket_cloud/sub_screen/page/reservation_query_preview.dart'; import 'setting_components.dart'; @@ -24,6 +25,12 @@ class _SettingSubScreenRoutesState extends State { ReservationQueryDisplayScenario.success; static final _routes = [ + _SubScreenRouteDefinition( + title: '欢迎副屏', + route: RouteNames.customerWelcome, + icon: FAssets.icons.sparkles, + type: _SubScreenPreviewType.welcome, + ), _SubScreenRouteDefinition( title: '顾客结算屏', route: RouteNames.customer, @@ -73,6 +80,7 @@ class _SettingSubScreenRoutesState extends State { _ScenarioGroup( title: '顾客结算屏演示状态', scenarios: CustomerDisplayScenario.values + .where((scenario) => scenario != CustomerDisplayScenario.idle) .map( (scenario) => _PreviewScenario( title: scenario.title, @@ -109,16 +117,21 @@ class _SettingSubScreenRoutesState extends State { } String _scenarioTitle(_SubScreenPreviewType type) { - return type == _SubScreenPreviewType.settlement - ? _scenario.title - : _reservationScenario.title; + return switch (type) { + _SubScreenPreviewType.welcome => '待机欢迎', + _SubScreenPreviewType.settlement => _scenario.title, + _SubScreenPreviewType.reservationQuery => _reservationScenario.title, + }; } void _openPreview(_SubScreenRouteDefinition definition) { - final scenarioName = definition.type == _SubScreenPreviewType.settlement - ? _scenario.name - : _reservationScenario.name; - final arguments = {'scenario': scenarioName}; + final arguments = switch (definition.type) { + _SubScreenPreviewType.welcome => const {}, + _SubScreenPreviewType.settlement => {'scenario': _scenario.name}, + _SubScreenPreviewType.reservationQuery => { + 'scenario': _reservationScenario.name, + }, + }; Navigator.of(context, rootNavigator: true).push( GetPageRoute( routeName: definition.route, @@ -126,9 +139,13 @@ class _SettingSubScreenRoutesState extends State { name: definition.route, arguments: arguments, ), - page: () => definition.type == _SubScreenPreviewType.settlement - ? CustomerDisplayPreviewPage(arguments: arguments) - : ReservationQueryPreviewPage(arguments: arguments), + page: () => switch (definition.type) { + _SubScreenPreviewType.welcome => const CustomerWelcomePreviewPage(), + _SubScreenPreviewType.settlement => + CustomerDisplayPreviewPage(arguments: arguments), + _SubScreenPreviewType.reservationQuery => + ReservationQueryPreviewPage(arguments: arguments), + }, transition: Transition.noTransition, transitionDuration: Duration.zero, ), @@ -150,7 +167,7 @@ class _SubScreenRouteDefinition { final _SubScreenPreviewType type; } -enum _SubScreenPreviewType { settlement, reservationQuery } +enum _SubScreenPreviewType { welcome, settlement, reservationQuery } class _PreviewScenario { const _PreviewScenario({ diff --git a/lib/sub_screen/common/utils/customer_display_coordinator.dart b/lib/sub_screen/common/utils/customer_display_coordinator.dart index 3d73f9f..6c864c4 100644 --- a/lib/sub_screen/common/utils/customer_display_coordinator.dart +++ b/lib/sub_screen/common/utils/customer_display_coordinator.dart @@ -53,6 +53,9 @@ class CustomerDisplayCoordinator extends ChangeNotifier { } if (action == _SubScreenProtocol.jumpSubPage) { _stopCountdown(); + // 业务入口会在购物车创建前发送跳页消息;没有商品上下文时继续待机, + // 避免副屏提前露出空的结算框架。 + if (_state.products.isEmpty) return false; return _replace(_state.withScenario(CustomerDisplayScenario.review)); } if (action == _SubScreenProtocol.updateCard) { @@ -62,6 +65,9 @@ class CustomerDisplayCoordinator extends ChangeNotifier { return _replace(next); } if (action == _SubScreenProtocol.qrCodeScan) { + // 清屏后的迟到支付消息没有订单上下文,必须继续展示欢迎页, + // 不能重新构造一个无商品、无金额的支付框架。 + if (_state.products.isEmpty) return false; final qrCodeData = _parseQrCode(arguments); if (qrCodeData == null) return false; // 插件可能重复投递同一支付消息,不能因此重置顾客正在观察的倒计时。 @@ -117,6 +123,9 @@ class CustomerDisplayCoordinator extends ChangeNotifier { products.add(product); } + // 空购物车的有效快照等价于业务清屏,欢迎页不保留上一位顾客的信息。 + if (products.isEmpty) return CustomerDisplayState.idle(); + String? customerName; final rawPayUser = data['payUser']; if (rawPayUser != null) { diff --git a/lib/sub_screen/common/utils/sub_display_coordinator.dart b/lib/sub_screen/common/utils/sub_display_coordinator.dart index 7f9a465..b9de692 100644 --- a/lib/sub_screen/common/utils/sub_display_coordinator.dart +++ b/lib/sub_screen/common/utils/sub_display_coordinator.dart @@ -8,9 +8,9 @@ import 'customer_display_coordinator.dart'; import 'face_recognition_coordinator.dart'; import 'reservation_query_coordinator.dart'; -enum SubDisplayMode { settlement, reservationQuery, faceRecognition } +enum SubDisplayMode { welcome, settlement, reservationQuery, faceRecognition } -/// 副屏唯一消息入口,负责在既有结算屏和预约查询屏之间切换。 +/// 副屏唯一消息入口,负责在欢迎、结算、预约查询和人脸页面之间切换。 class SubDisplayCoordinator extends ChangeNotifier { SubDisplayCoordinator({ CustomerDisplayCoordinator? customerCoordinator, @@ -22,18 +22,19 @@ class SubDisplayCoordinator extends ChangeNotifier { reservationCoordinator ?? ReservationQueryCoordinator(), faceRecognitionCoordinator = faceRecognitionCoordinator ?? FaceRecognitionCoordinator() { + _lastReservationScenario = this.reservationCoordinator.state.scenario; this.customerCoordinator.addListener(_notifyFromChild); - this.reservationCoordinator.addListener(_notifyFromChild); + this.reservationCoordinator.addListener(_notifyFromReservation); this.faceRecognitionCoordinator.addListener(_notifyFromChild); } static const _reservationUpdate = 'reservationQueryUpdate'; static const _reservationClear = 'reservationQueryClear'; static const _faceStart = 'faceRecognitionStart'; - static const _settlementActions = { - 'updateCard', - 'jumpSubPage', - 'jumpSubBack', + static const _updateCard = 'updateCard'; + static const _jumpSubPage = 'jumpSubPage'; + static const _jumpSubBack = 'jumpSubBack'; + static const _paymentActions = { 'qrCodeScan', 'qrCodeScanSuccess', 'qrCodeScanCancel', @@ -43,13 +44,16 @@ class SubDisplayCoordinator extends ChangeNotifier { final ReservationQueryCoordinator reservationCoordinator; final FaceRecognitionCoordinator faceRecognitionCoordinator; - SubDisplayMode _mode = SubDisplayMode.settlement; - SubDisplayMode _modeBeforeFace = SubDisplayMode.settlement; + SubDisplayMode _mode = SubDisplayMode.welcome; + SubDisplayMode _modeBeforeFace = SubDisplayMode.welcome; Timer? _faceRestoreTimer; String? _reservationSessionAtFaceStart; int _reservationRevisionAtFaceStart = -1; bool _reservationSuccessDelayPassed = false; bool _reservationResultReady = false; + bool _reservationClearedDuringFace = false; + bool _reservationFaceHandoffActive = false; + late ReservationQueryDisplayScenario _lastReservationScenario; bool _disposed = false; SubDisplayMode get mode => _mode; @@ -80,6 +84,11 @@ class SubDisplayCoordinator extends ChangeNotifier { case FaceRecognitionDisplayTransition.restoreAfterSuccess: _faceRestoreTimer?.cancel(); if (faceState?.scene == FaceRecognitionDisplayScene.reservation) { + if (_reservationClearedDuringFace) { + _replaceMode(SubDisplayMode.welcome); + _resetReservationFaceHandoff(); + break; + } // 预约查询必须在人脸成功提示展示满 700ms 且查询拿到最终结果后再恢复, // 否则慢网络会先露出中间 loading,顾客看不到“人脸 -> 本人菜单”的完整顺序。 _reservationSuccessDelayPassed = false; @@ -104,6 +113,7 @@ class SubDisplayCoordinator extends ChangeNotifier { if (action == _reservationUpdate) { final accepted = reservationCoordinator.handleUpdate(arguments); if (accepted) { + _reservationClearedDuringFace = false; _showOrRememberMode(SubDisplayMode.reservationQuery); _handleReservationUpdateDuringFace(); } @@ -112,14 +122,38 @@ class SubDisplayCoordinator extends ChangeNotifier { if (action == _reservationClear) { final accepted = reservationCoordinator.handleClear(arguments); if (accepted) { - _showOrRememberMode(SubDisplayMode.reservationQuery); + _showOrRememberMode(SubDisplayMode.welcome); _handleReservationClearDuringFace(); } return accepted; } - if (_settlementActions.contains(action)) { + if (action == _jumpSubBack) { final changed = customerCoordinator.handleAction(action, arguments); - final modeChanged = _showOrRememberMode(SubDisplayMode.settlement); + final modeChanged = _showOrRememberMode(SubDisplayMode.welcome); + return changed || modeChanged; + } + if (action == _jumpSubPage) { + final changed = customerCoordinator.handleAction(action, arguments); + final target = customerCoordinator.state.products.isEmpty + ? SubDisplayMode.welcome + : SubDisplayMode.settlement; + final modeChanged = _showOrRememberMode(target); + return changed || modeChanged; + } + if (action == _updateCard) { + final changed = customerCoordinator.handleAction(action, arguments); + final target = customerCoordinator.state.products.isEmpty + ? SubDisplayMode.welcome + : SubDisplayMode.settlement; + final modeChanged = _showOrRememberMode(target); + return changed || modeChanged; + } + if (_paymentActions.contains(action)) { + final changed = customerCoordinator.handleAction(action, arguments); + final target = customerCoordinator.state.products.isEmpty + ? SubDisplayMode.welcome + : SubDisplayMode.settlement; + final modeChanged = _showOrRememberMode(target); return changed || modeChanged; } return false; @@ -138,6 +172,7 @@ class SubDisplayCoordinator extends ChangeNotifier { _reservationRevisionAtFaceStart = reservationState.revision; _reservationSuccessDelayPassed = false; _reservationResultReady = false; + _reservationFaceHandoffActive = true; } void _handleReservationUpdateDuringFace() { @@ -163,15 +198,20 @@ class SubDisplayCoordinator extends ChangeNotifier { if (_mode != SubDisplayMode.faceRecognition) return; final faceState = faceRecognitionCoordinator.state; if (faceState?.scene != FaceRecognitionDisplayScene.reservation) return; + _resetReservationFaceHandoff(preserveClear: true); + _reservationClearedDuringFace = true; if (faceState?.status != FaceRecognitionDisplayStatus.success && faceState?.status != FaceRecognitionDisplayStatus.failed) { return; } // 页面退出或隐私清屏不再等待查询结果,避免副屏停留在已结束的人脸成功页。 - _restoreReservationAfterFace(); + _faceRestoreTimer?.cancel(); + _replaceMode(SubDisplayMode.welcome); + _resetReservationFaceHandoff(); } bool _hasNewReservationTerminalState() { + if (!_reservationFaceHandoffActive) return false; final state = reservationCoordinator.state; final isNewer = state.sessionId != _reservationSessionAtFaceStart || state.revision > _reservationRevisionAtFaceStart; @@ -192,11 +232,13 @@ class SubDisplayCoordinator extends ChangeNotifier { _resetReservationFaceHandoff(); } - void _resetReservationFaceHandoff() { + void _resetReservationFaceHandoff({bool preserveClear = false}) { _reservationSessionAtFaceStart = null; _reservationRevisionAtFaceStart = -1; _reservationSuccessDelayPassed = false; _reservationResultReady = false; + _reservationFaceHandoffActive = false; + if (!preserveClear) _reservationClearedDuringFace = false; } /// 人脸页显示期间,普通业务消息仍更新各自 Coordinator,但只修改待恢复页面。 @@ -214,6 +256,25 @@ class SubDisplayCoordinator extends ChangeNotifier { if (!_disposed) notifyListeners(); } + void _notifyFromReservation() { + if (_disposed) return; + final previous = _lastReservationScenario; + final current = reservationCoordinator.state.scenario; + _lastReservationScenario = current; + + if (previous != ReservationQueryDisplayScenario.idle && + current == ReservationQueryDisplayScenario.idle) { + final modeBeforeTransition = _mode; + // 预约结果可由副屏自己的隐私计时器清空;顶层必须同步回欢迎页, + // 否则 Coordinator 已无身份数据但仍会停留在预约查询框架。 + _showOrRememberMode(SubDisplayMode.welcome); + _handleReservationClearDuringFace(); + if (_mode == modeBeforeTransition) notifyListeners(); + return; + } + notifyListeners(); + } + @override void dispose() { if (_disposed) return; @@ -221,7 +282,7 @@ class SubDisplayCoordinator extends ChangeNotifier { _faceRestoreTimer?.cancel(); _resetReservationFaceHandoff(); customerCoordinator.removeListener(_notifyFromChild); - reservationCoordinator.removeListener(_notifyFromChild); + reservationCoordinator.removeListener(_notifyFromReservation); faceRecognitionCoordinator.removeListener(_notifyFromChild); customerCoordinator.dispose(); reservationCoordinator.dispose(); diff --git a/lib/sub_screen/page/customer_display.dart b/lib/sub_screen/page/customer_display.dart index 0d56b40..5c8241f 100644 --- a/lib/sub_screen/page/customer_display.dart +++ b/lib/sub_screen/page/customer_display.dart @@ -6,6 +6,7 @@ import 'package:qr_flutter/qr_flutter.dart'; import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.dart'; import 'package:pj_supermarket_cloud/sub_screen/common/models/customer_display_models.dart'; import 'package:pj_supermarket_cloud/sub_screen/common/utils/customer_display_coordinator.dart'; +import 'package:pj_supermarket_cloud/sub_screen/page/customer_welcome.dart'; /// 真实副屏和模拟器预览共用的顾客结算屏。 class CustomerDisplayPage extends StatelessWidget { @@ -29,9 +30,13 @@ class CustomerDisplayPage extends StatelessWidget { Positioned.fill( child: AnimatedBuilder( animation: coordinator, - builder: (context, _) => _CustomerDisplayView( - state: coordinator.state, - ), + builder: (context, _) { + final state = coordinator.state; + if (state.scenario == CustomerDisplayScenario.idle) { + return const CustomerWelcomePage(); + } + return _CustomerDisplayView(state: state); + }, ), ), if (previewMode) diff --git a/lib/sub_screen/page/customer_welcome.dart b/lib/sub_screen/page/customer_welcome.dart new file mode 100644 index 0000000..6477058 --- /dev/null +++ b/lib/sub_screen/page/customer_welcome.dart @@ -0,0 +1,659 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.dart'; + +/// 顾客副屏待机欢迎页,真实副屏与主屏全屏预览共用同一份布局和时钟生命周期。 +class CustomerWelcomePage extends StatefulWidget { + const CustomerWelcomePage({ + super.key, + this.previewMode = false, + this.onExit, + this.now, + this.clockTick = const Duration(minutes: 1), + }); + + final bool previewMode; + final VoidCallback? onExit; + final DateTime Function()? now; + final Duration clockTick; + + @override + State createState() => _CustomerWelcomePageState(); +} + +class _CustomerWelcomePageState extends State { + late DateTime _currentTime; + Timer? _clockTimer; + + @override + void initState() { + super.initState(); + _currentTime = _readNow(); + _startClock(); + } + + @override + void didUpdateWidget(CustomerWelcomePage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.clockTick != widget.clockTick || + oldWidget.now != widget.now) { + _currentTime = _readNow(); + _startClock(); + } + } + + DateTime _readNow() => widget.now?.call() ?? DateTime.now(); + + void _startClock() { + _clockTimer?.cancel(); + final tick = widget.clockTick > Duration.zero + ? widget.clockTick + : const Duration(minutes: 1); + _clockTimer = Timer.periodic(tick, (_) { + if (mounted) setState(() => _currentTime = _readNow()); + }); + } + + @override + void dispose() { + _clockTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: XfjDesignTokens.canvas, + body: Stack( + children: [ + Positioned.fill( + child: _CustomerWelcomeContent(currentTime: _currentTime), + ), + if (widget.previewMode) + Positioned( + top: 16, + right: 16, + child: SafeArea( + child: _PreviewExitButton(onTap: widget.onExit), + ), + ), + ], + ), + ); + } +} + +class _CustomerWelcomeContent extends StatelessWidget { + const _CustomerWelcomeContent({required this.currentTime}); + + final DateTime currentTime; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final scale = math + .min(constraints.maxWidth / 1920, constraints.maxHeight / 1080) + .clamp(0.55, 1.12) + .toDouble(); + return ColoredBox( + key: const ValueKey('customer-welcome-page'), + color: XfjDesignTokens.canvas, + child: SafeArea( + child: Column( + children: [ + _WelcomeHeader(currentTime: currentTime, scale: scale), + Expanded( + child: Padding( + padding: EdgeInsets.fromLTRB( + 24 * scale, + 0, + 24 * scale, + 24 * scale, + ), + child: _WelcomeHero(scale: scale), + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _WelcomeHeader extends StatelessWidget { + const _WelcomeHeader({required this.currentTime, required this.scale}); + + final DateTime currentTime; + final double scale; + + @override + Widget build(BuildContext context) { + final hour = currentTime.hour.toString().padLeft(2, '0'); + final minute = currentTime.minute.toString().padLeft(2, '0'); + return SizedBox( + height: 80 * scale, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 32 * scale), + child: Row( + children: [ + Text( + 'ZAX', + style: TextStyle( + color: const Color(0xFF0865E8), + fontSize: 36 * scale, + fontWeight: FontWeight.w900, + fontStyle: FontStyle.italic, + letterSpacing: -2 * scale, + ), + ), + SizedBox(width: 20 * scale), + Container( + width: 1, + height: 36 * scale, + color: XfjDesignTokens.line, + ), + SizedBox(width: 20 * scale), + Text( + '欢迎光临', + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: 24 * scale, + fontWeight: FontWeight.w800, + letterSpacing: 1.2 * scale, + ), + ), + SizedBox(width: 28 * scale), + Container( + padding: EdgeInsets.symmetric( + horizontal: 16 * scale, + vertical: 9 * scale, + ), + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + border: Border.all( + color: XfjDesignTokens.primary.withValues(alpha: 0.2), + ), + borderRadius: BorderRadius.circular(12 * scale), + ), + child: Text( + '智慧收银', + style: TextStyle( + color: XfjDesignTokens.primary, + fontSize: 16 * scale, + fontWeight: FontWeight.w700, + ), + ), + ), + const Spacer(), + _ConnectionStatus(scale: scale), + SizedBox(width: 20 * scale), + Container( + width: 1, + height: 28 * scale, + color: XfjDesignTokens.line, + ), + SizedBox(width: 20 * scale), + Text( + '$hour:$minute', + key: const ValueKey('customer-welcome-clock'), + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: 20 * scale, + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + ); + } +} + +class _ConnectionStatus extends StatelessWidget { + const _ConnectionStatus({required this.scale}); + + final double scale; + + @override + Widget build(BuildContext context) { + return Semantics( + label: '设备已连接', + child: Row( + children: [ + Container( + width: 10 * scale, + height: 10 * scale, + decoration: BoxDecoration( + color: XfjDesignTokens.success, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: XfjDesignTokens.success.withValues(alpha: 0.12), + spreadRadius: 5 * scale, + ), + ], + ), + ), + SizedBox(width: 10 * scale), + Text( + '设备已连接', + style: TextStyle( + color: XfjDesignTokens.success, + fontSize: 18 * scale, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +class _WelcomeHero extends StatelessWidget { + const _WelcomeHero({required this.scale}); + + final double scale; + + @override + Widget build(BuildContext context) { + final radius = BorderRadius.circular(24 * scale); + return DecoratedBox( + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: radius, + boxShadow: const [ + BoxShadow( + color: Color(0x14111827), + blurRadius: 56, + offset: Offset(0, 18), + ), + ], + ), + child: ClipRRect( + borderRadius: radius, + child: Stack( + fit: StackFit.expand, + children: [ + _WelcomeAssetImage( + key: const ValueKey('customer-welcome-hero'), + asset: + 'assets/images/customer_welcome/abstract-welcome-hero.webp', + semanticsLabel: '蓝色、薄荷绿与暖黄色卡通伙伴挥手欢迎', + fit: BoxFit.cover, + ), + Positioned( + left: 32 * scale, + top: 32 * scale, + child: _ReadyCard(scale: scale), + ), + Positioned( + right: 32 * scale, + top: 32 * scale, + child: _SyncBadge(scale: scale), + ), + Positioned( + left: 32 * scale, + bottom: 32 * scale, + child: Row( + children: [ + _WelcomeInfoCard( + imageKey: const ValueKey('customer-welcome-flow'), + asset: 'assets/images/customer_welcome/abstract-flow.webp', + semanticsLabel: '卡通伙伴与空白卡片组成的轻量信息流插图', + icon: Icons.auto_awesome_rounded, + title: '内容实时更新', + detail: '新的信息会自动显示', + scale: scale, + ), + SizedBox(width: 16 * scale), + _WelcomeInfoCard( + imageKey: const ValueKey('customer-welcome-care'), + asset: 'assets/images/customer_welcome/abstract-care.webp', + semanticsLabel: '卡通伙伴拥抱爱心盾牌的安心守护插图', + icon: Icons.shield_outlined, + title: '安心使用', + detail: '个人信息会妥善保护', + scale: scale, + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _ReadyCard extends StatelessWidget { + const _ReadyCard({required this.scale}); + + final double scale; + + @override + Widget build(BuildContext context) { + return Container( + width: 504 * scale, + padding: EdgeInsets.all(24 * scale), + decoration: _floatingDecoration(radius: 20 * scale), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.check_circle_rounded, + color: XfjDesignTokens.success, + size: 19 * scale, + ), + SizedBox(width: 8 * scale), + Text( + '终端服务已就绪', + style: TextStyle( + color: XfjDesignTokens.success, + fontSize: 14 * scale, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + SizedBox(height: 16 * scale), + Text( + '欢迎使用', + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: 48 * scale, + height: 1.05, + fontWeight: FontWeight.w900, + letterSpacing: -1.7 * scale, + ), + ), + SizedBox(height: 12 * scale), + Text( + '请稍候,接下来的内容会在这里清晰展示。', + style: TextStyle( + color: XfjDesignTokens.muted, + fontSize: 18 * scale, + height: 1.55, + ), + ), + SizedBox(height: 20 * scale), + Container( + padding: EdgeInsets.symmetric( + horizontal: 16 * scale, + vertical: 12 * scale, + ), + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular(12 * scale), + ), + child: Row( + children: [ + Container( + width: 10 * scale, + height: 10 * scale, + decoration: const BoxDecoration( + color: XfjDesignTokens.primary, + shape: BoxShape.circle, + ), + ), + SizedBox(width: 12 * scale), + Text( + '正在等待服务开始', + style: TextStyle( + color: XfjDesignTokens.primary, + fontSize: 14 * scale, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _SyncBadge extends StatelessWidget { + const _SyncBadge({required this.scale}); + + final double scale; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.symmetric( + horizontal: 16 * scale, + vertical: 10 * scale, + ), + decoration: _floatingDecoration( + radius: 24 * scale, + shadow: const BoxShadow( + color: Color(0x0F111827), + blurRadius: 8, + offset: Offset(0, 2), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.wifi_rounded, + color: XfjDesignTokens.success, + size: 19 * scale, + ), + SizedBox(width: 8 * scale), + Text( + '内容将自动同步', + style: TextStyle( + color: XfjDesignTokens.success, + fontSize: 14 * scale, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +class _WelcomeInfoCard extends StatelessWidget { + const _WelcomeInfoCard({ + required this.imageKey, + required this.asset, + required this.semanticsLabel, + required this.icon, + required this.title, + required this.detail, + required this.scale, + }); + + final Key imageKey; + final String asset; + final String semanticsLabel; + final IconData icon; + final String title; + final String detail; + final double scale; + + @override + Widget build(BuildContext context) { + final radius = BorderRadius.circular(16 * scale); + return Container( + width: 248 * scale, + decoration: _floatingDecoration(radius: 16 * scale), + child: ClipRRect( + borderRadius: radius, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: double.infinity, + height: 120 * scale, + child: _WelcomeAssetImage( + key: imageKey, + asset: asset, + semanticsLabel: semanticsLabel, + fit: BoxFit.cover, + ), + ), + Container( + color: XfjDesignTokens.surface, + padding: EdgeInsets.all(14 * scale), + child: Row( + children: [ + Container( + width: 36 * scale, + height: 36 * scale, + alignment: Alignment.center, + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular(10 * scale), + ), + child: Icon( + icon, + color: XfjDesignTokens.primary, + size: 20 * scale, + ), + ), + SizedBox(width: 12 * scale), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: 14 * scale, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 4 * scale), + Text( + detail, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: XfjDesignTokens.muted, + fontSize: 12 * scale, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _WelcomeAssetImage extends StatelessWidget { + const _WelcomeAssetImage({ + super.key, + required this.asset, + required this.semanticsLabel, + required this.fit, + }); + + final String asset; + final String semanticsLabel; + final BoxFit fit; + + @override + Widget build(BuildContext context) { + return Image.asset( + asset, + fit: fit, + semanticLabel: semanticsLabel, + errorBuilder: (context, error, stackTrace) => ColoredBox( + color: XfjDesignTokens.primarySoft, + child: Center( + child: Icon( + Icons.image_not_supported_outlined, + color: XfjDesignTokens.primary, + size: 40, + semanticLabel: '欢迎插图暂不可用', + ), + ), + ), + ); + } +} + +class _PreviewExitButton extends StatelessWidget { + const _PreviewExitButton({required this.onTap}); + + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: '退出欢迎副屏预览', + child: Material( + color: const Color(0xD9111827), + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: const SizedBox( + width: 118, + height: 48, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.close_fullscreen_rounded, + color: Colors.white, + size: 20, + ), + SizedBox(width: 8), + Text( + '退出预览', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +BoxDecoration _floatingDecoration({ + required double radius, + BoxShadow shadow = const BoxShadow( + color: Color(0x1F111827), + blurRadius: 48, + offset: Offset(0, 18), + ), +}) { + return BoxDecoration( + color: const Color(0xF0FFFFFF), + border: Border.all(color: const Color(0xE6FFFFFF)), + borderRadius: BorderRadius.circular(radius), + boxShadow: [shadow], + ); +} diff --git a/lib/sub_screen/page/customer_welcome_preview.dart b/lib/sub_screen/page/customer_welcome_preview.dart new file mode 100644 index 0000000..ce7359c --- /dev/null +++ b/lib/sub_screen/page/customer_welcome_preview.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +import 'package:pj_supermarket_cloud/sub_screen/page/customer_welcome.dart'; + +/// 主屏调试路由包装页,只提供退出能力,不接入真实副屏消息或业务状态。 +class CustomerWelcomePreviewPage extends StatelessWidget { + const CustomerWelcomePreviewPage({super.key}); + + @override + Widget build(BuildContext context) { + return CustomerWelcomePage( + previewMode: true, + onExit: () => Navigator.of(context).maybePop(), + ); + } +} diff --git a/lib/sub_screen/page/index.dart b/lib/sub_screen/page/index.dart index 4ca2345..4e1d46e 100644 --- a/lib/sub_screen/page/index.dart +++ b/lib/sub_screen/page/index.dart @@ -2,6 +2,8 @@ library sub_page; export './customer_display.dart'; export './customer_preview.dart'; +export './customer_welcome.dart'; +export './customer_welcome_preview.dart'; export './face_recognition_customer_page.dart'; export './reservation_query_display.dart'; export './reservation_query_preview.dart'; diff --git a/lib/sub_screen/page/sub_app.dart b/lib/sub_screen/page/sub_app.dart index 8024b67..57ff916 100644 --- a/lib/sub_screen/page/sub_app.dart +++ b/lib/sub_screen/page/sub_app.dart @@ -7,6 +7,7 @@ import 'package:forui/forui.dart'; import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.dart'; import 'package:pj_supermarket_cloud/sub_screen/common/utils/sub_display_coordinator.dart'; import 'package:pj_supermarket_cloud/sub_screen/page/customer_display.dart'; +import 'package:pj_supermarket_cloud/sub_screen/page/customer_welcome.dart'; import 'package:pj_supermarket_cloud/sub_screen/page/face_recognition_customer_page.dart'; import 'package:pj_supermarket_cloud/sub_screen/page/reservation_query_display.dart'; @@ -57,6 +58,7 @@ class _SubAppPageState extends State { animation: _coordinator, builder: (context, _) { return switch (_coordinator.mode) { + SubDisplayMode.welcome => const CustomerWelcomePage(), SubDisplayMode.settlement => CustomerDisplayPage( coordinator: _coordinator.customerCoordinator, ), diff --git a/pubspec.lock b/pubspec.lock index 4dc0c38..e4f24b9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1018,6 +1018,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.8" + pointycastle: + dependency: "direct main" + description: + name: pointycastle + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.9.1" pool: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9a0a236..84bee4c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -66,6 +66,8 @@ dependencies: logger: 2.3.0 # 工具类 common_utils: ^2.1.0 + # 登录密码 AES-CFB 加密 + pointycastle: 3.9.1 # 判断两个对象相等 equatable: ^2.0.7 # 打印机 @@ -91,4 +93,5 @@ flutter: assets: - assets/images/ + - assets/images/customer_welcome/ - assets/crt/ diff --git a/test/sub_screen/customer_display_coordinator_test.dart b/test/sub_screen/customer_display_coordinator_test.dart index f063330..36d323a 100644 --- a/test/sub_screen/customer_display_coordinator_test.dart +++ b/test/sub_screen/customer_display_coordinator_test.dart @@ -71,6 +71,7 @@ void main() { final coordinator = CustomerDisplayCoordinator(qrCountdownSeconds: 3); const qrEvent = {'qrCodeData': 'preview-payment'}; + coordinator.handleAction('updateCard', _productEvent()); expect(coordinator.handleAction('qrCodeScan', qrEvent), isTrue); await tester.pump(const Duration(seconds: 1)); expect(coordinator.state.countdownSeconds, 2); @@ -87,6 +88,7 @@ void main() { testWidgets('释放后停止倒计时并忽略后续消息', (tester) async { final coordinator = CustomerDisplayCoordinator(qrCountdownSeconds: 2); + coordinator.handleAction('updateCard', _productEvent()); coordinator.handleAction( 'qrCodeScan', const {'qrCodeData': 'preview-payment'}, @@ -117,6 +119,27 @@ void main() { coordinator.dispose(); }); + + test('空购物车跳页保持欢迎状态,空商品快照清除已有上下文', () { + final coordinator = CustomerDisplayCoordinator(); + + expect(coordinator.handleAction('jumpSubPage'), isFalse); + expect(coordinator.state.scenario, CustomerDisplayScenario.idle); + + coordinator.handleAction('updateCard', _productEvent()); + expect(coordinator.state.products, hasLength(2)); + expect( + coordinator.handleAction( + 'updateCard', + _productEvent()..['shopList'] = >[], + ), + isTrue, + ); + expect(coordinator.state.scenario, CustomerDisplayScenario.idle); + expect(coordinator.state.products, isEmpty); + + coordinator.dispose(); + }); } Map _productEvent() { diff --git a/test/sub_screen/customer_display_widget_test.dart b/test/sub_screen/customer_display_widget_test.dart index b1b2fa0..5c0dd92 100644 --- a/test/sub_screen/customer_display_widget_test.dart +++ b/test/sub_screen/customer_display_widget_test.dart @@ -28,14 +28,15 @@ void main() { ); await tester.pump(); - expect( - find.byKey(ValueKey('customer-scenario-${scenario.name}')), - findsOneWidget, - ); + final scenarioKey = scenario == CustomerDisplayScenario.idle + ? const ValueKey('customer-welcome-page') + : ValueKey('customer-scenario-${scenario.name}'); + expect(find.byKey(scenarioKey), findsOneWidget); expect(tester.takeException(), isNull); if (scenario == CustomerDisplayScenario.idle) { - expect(find.text('等待添加商品'), findsOneWidget); + expect(find.text('终端服务已就绪'), findsOneWidget); + expect(find.text('等待添加商品'), findsNothing); } else { expect(find.text('张*明,您好'), findsOneWidget); expect(find.text('张小明'), findsNothing); diff --git a/test/sub_screen/customer_welcome_widget_test.dart b/test/sub_screen/customer_welcome_widget_test.dart new file mode 100644 index 0000000..8581172 --- /dev/null +++ b/test/sub_screen/customer_welcome_widget_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:pj_supermarket_cloud/sub_screen/page/customer_welcome.dart'; +import 'package:pj_supermarket_cloud/sub_screen/page/customer_welcome_preview.dart'; + +void main() { + for (final size in [const Size(1920, 1080), const Size(1280, 720)]) { + testWidgets('${size.width.toInt()}x${size.height.toInt()} 欢迎页无布局异常', + (tester) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + MaterialApp( + home: CustomerWelcomePage( + now: () => DateTime(2026, 7, 27, 10, 28), + ), + ), + ); + await tester.pump(); + + expect( + find.byKey(const ValueKey('customer-welcome-page')), findsOneWidget); + expect( + find.byKey(const ValueKey('customer-welcome-hero')), findsOneWidget); + expect( + find.byKey(const ValueKey('customer-welcome-flow')), findsOneWidget); + expect( + find.byKey(const ValueKey('customer-welcome-care')), findsOneWidget); + expect(find.text('终端服务已就绪'), findsOneWidget); + expect(find.text('正在等待服务开始'), findsOneWidget); + expect(find.text('内容将自动同步'), findsOneWidget); + expect(find.text('10:28'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('欢迎页时钟按配置刷新并在页面释放后停止', (tester) async { + var now = DateTime(2026, 7, 27, 10, 28); + await tester.pumpWidget( + MaterialApp( + home: CustomerWelcomePage( + now: () => now, + clockTick: const Duration(seconds: 1), + ), + ), + ); + expect(find.text('10:28'), findsOneWidget); + + now = DateTime(2026, 7, 27, 10, 29); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('10:29'), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 2)); + expect(tester.takeException(), isNull); + }); + + testWidgets('欢迎副屏悬浮按钮和系统返回都能退出预览', (tester) async { + await tester.pumpWidget(const _WelcomePreviewHarness()); + + await tester.tap(find.text('打开欢迎预览')); + await tester.pumpAndSettle(); + expect(find.text('退出预览'), findsOneWidget); + + await tester.tap(find.text('退出预览')); + await tester.pumpAndSettle(); + expect(find.text('打开欢迎预览'), findsOneWidget); + + await tester.tap(find.text('打开欢迎预览')); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text('打开欢迎预览'), findsOneWidget); + }); +} + +class _WelcomePreviewHarness extends StatelessWidget { + const _WelcomePreviewHarness(); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const CustomerWelcomePreviewPage(), + ), + ), + child: const Text('打开欢迎预览'), + ), + ), + ), + ), + ); + } +} diff --git a/test/sub_screen/reservation_query_coordinator_test.dart b/test/sub_screen/reservation_query_coordinator_test.dart index 4d6721a..af348f4 100644 --- a/test/sub_screen/reservation_query_coordinator_test.dart +++ b/test/sub_screen/reservation_query_coordinator_test.dart @@ -72,6 +72,54 @@ void main() { coordinator.dispose(); }); + testWidgets('预约结果自行过期后顶层副屏返回欢迎页', (tester) async { + var now = DateTime(2026, 7, 24, 8); + final coordinator = SubDisplayCoordinator( + reservationCoordinator: ReservationQueryCoordinator(now: () => now), + ); + addTearDown(coordinator.dispose); + + expect( + coordinator.handleAction( + 'reservationQueryUpdate', + _reservationEvent( + sessionId: '250', + revision: 1, + expiresAt: now.add(const Duration(seconds: 2)), + ), + ), + isTrue, + ); + expect(coordinator.mode, SubDisplayMode.reservationQuery); + + now = now.add(const Duration(seconds: 2)); + await tester.pump(const Duration(seconds: 1)); + + expect( + coordinator.reservationCoordinator.state.scenario, + ReservationQueryDisplayScenario.idle, + ); + expect(coordinator.mode, SubDisplayMode.welcome); + }); + + test('收到 idle 预约更新时仍进入查询页等待后续状态', () { + final coordinator = SubDisplayCoordinator(); + addTearDown(coordinator.dispose); + + expect( + coordinator.handleAction( + 'reservationQueryUpdate', + const { + 'sessionId': '260', + 'revision': 1, + 'scenario': 'idle', + }, + ), + isTrue, + ); + expect(coordinator.mode, SubDisplayMode.reservationQuery); + }); + test('显式清屏也需要递增版本并保留最近有效状态', () { final now = DateTime(2026, 7, 24, 8); final coordinator = ReservationQueryCoordinator(now: () => now); @@ -294,7 +342,7 @@ void main() { ), isTrue, ); - expect(coordinator.mode, SubDisplayMode.reservationQuery); + expect(coordinator.mode, SubDisplayMode.welcome); }); } diff --git a/test/sub_screen/sub_display_coordinator_test.dart b/test/sub_screen/sub_display_coordinator_test.dart new file mode 100644 index 0000000..07e9cc9 --- /dev/null +++ b/test/sub_screen/sub_display_coordinator_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:pj_supermarket_cloud/sub_screen/common/utils/sub_display_coordinator.dart'; + +void main() { + test('默认欢迎,空购物车保持欢迎,商品更新后进入结算', () { + final coordinator = SubDisplayCoordinator(); + addTearDown(coordinator.dispose); + + expect(coordinator.mode, SubDisplayMode.welcome); + expect(coordinator.handleAction('jumpSubPage'), isFalse); + expect(coordinator.mode, SubDisplayMode.welcome); + + expect(coordinator.handleAction('updateCard', _productEvent()), isTrue); + expect(coordinator.mode, SubDisplayMode.settlement); + expect(coordinator.customerCoordinator.state.products, hasLength(1)); + + expect(coordinator.handleAction('jumpSubBack'), isTrue); + expect(coordinator.mode, SubDisplayMode.welcome); + expect(coordinator.customerCoordinator.state.products, isEmpty); + }); + + test('有效空购物车快照清除上一位顾客并返回欢迎页', () { + final coordinator = SubDisplayCoordinator(); + addTearDown(coordinator.dispose); + + coordinator.handleAction('updateCard', _productEvent()); + expect(coordinator.mode, SubDisplayMode.settlement); + + expect( + coordinator.handleAction( + 'updateCard', + _productEvent()..['shopList'] = >[], + ), + isTrue, + ); + expect(coordinator.mode, SubDisplayMode.welcome); + expect(coordinator.customerCoordinator.state.products, isEmpty); + }); + + test('清屏后的迟到支付消息不会重新露出空结算页', () { + final coordinator = SubDisplayCoordinator(); + addTearDown(coordinator.dispose); + + expect( + coordinator.handleAction( + 'qrCodeScan', + const {'qrCodeData': 'stale-payment-code'}, + ), + isFalse, + ); + expect(coordinator.mode, SubDisplayMode.welcome); + expect(coordinator.customerCoordinator.state.products, isEmpty); + }); + + testWidgets('收银人脸结束后恢复进入人脸前的页面', (tester) async { + final coordinator = SubDisplayCoordinator(); + addTearDown(coordinator.dispose); + + coordinator.handleAction( + 'faceRecognitionStart', + _faceEvent(sessionId: 'welcome-face', revision: 1), + ); + coordinator.handleAction( + 'faceRecognitionSuccess', + _faceEvent(sessionId: 'welcome-face', revision: 2), + ); + await tester.pump(const Duration(milliseconds: 700)); + expect(coordinator.mode, SubDisplayMode.welcome); + + coordinator.handleAction('updateCard', _productEvent()); + coordinator.handleAction( + 'faceRecognitionStart', + _faceEvent(sessionId: 'settlement-face', revision: 1), + ); + coordinator.handleAction( + 'faceRecognitionSuccess', + _faceEvent(sessionId: 'settlement-face', revision: 2), + ); + await tester.pump(const Duration(milliseconds: 700)); + expect(coordinator.mode, SubDisplayMode.settlement); + }); +} + +Map _productEvent() { + return { + 'sysType': 'SUPERMARKET', + 'payUser': {'username': '张小明'}, + 'totalPrice': 480, + 'amountMealPrice': 0, + 'shopList': [ + { + 'productName': '纯牛奶 250ml', + 'productPrice': 480, + 'nature': 'DEFAULT', + 'productNumber': 1, + 'productWidget': 0, + 'totalPrice': 480, + }, + ], + }; +} + +Map _faceEvent({ + required String sessionId, + required int revision, +}) { + return { + 'sessionId': sessionId, + 'revision': revision, + 'scene': 'cashier', + 'message': revision == 1 ? '正在启动摄像头' : '识别成功,请稍候', + }; +}