feat:日志查看

fix:真机bug
This commit is contained in:
dzc committed 2026-07-27 13:28:35 +08:00
1 parent 5b9ef49564
commit 6ef0b6320f
40 files changed
+2773 -198

No files matched your search

+3 -1
View File
@@ -33,12 +33,14 @@
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize" />
<!-- 主入口随软键盘缩放,避免横屏登录表单和主按钮被输入法遮挡。 -->
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme">
android:theme="@style/LaunchTheme"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
@@ -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<Boolean>("enabled")
+1 -1
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 是否在初始化时自动显示副屏 -->
<bool name="autoShowSubScreenWhenInit">false</bool>
<bool name="autoShowSubScreenWhenInit">true</bool>
</resources>
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+6 -1
View File
@@ -35,8 +35,13 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
await Get.find<FaceRecognitionSessionService>().suspend();
} catch (_) {}
}
return;
}
if (state == AppLifecycleState.resumed) {
// 前台恢复需要重新确认原生 Presentation;若系统回收了副屏窗口才重新创建,
// 正常常驻时不会触发插件内部的“先关闭再打开”。
try {
AppSubScreenTool.hide();
await AppSubScreenTool.show(verifyPresentation: true);
} catch (_) {}
}
}
+5 -1
View File
@@ -5,6 +5,10 @@ import 'package:pj_supermarket_cloud/common/index.dart';
abstract class UserApi {
/// 硬件管理登录
static Future<dynamic> 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: {
@@ -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);
}
}
@@ -10,6 +10,7 @@ enum SettingMenuType implements BaseEnum {
faceSetting("50", "人脸管理"),
facePluginSetting("55", "人脸设置"),
productSetting("60", "商品管理"),
logPreview("62", "日志预览"),
subScreenRoutes("65", "副屏路由"),
versionSetting("70", "版本管理"),
;
+3
View File
@@ -8,6 +8,9 @@ class RouteNames {
/// 顾客屏全窗口预览,不装载终端侧栏和顶栏。
static const customer = '/customer';
/// 欢迎副屏全窗口预览。
static const customerWelcome = '/customer-welcome';
/// 预约查询副屏全窗口预览。
static const menuQueryCustomer = '/menu-query-customer';
+5
View File
@@ -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),
-3
View File
@@ -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;
}
@@ -208,6 +208,15 @@ abstract class AppRuntimeTool {
}
}
/// 查询插件当前 Presentation 是否仍在展示,用于避免 showSubDisplay() 先关后开造成闪屏。
static Future<bool> isSubScreenShowing() async {
try {
return await _channel.invokeMethod<bool>('isSubScreenShowing') ?? false;
} on PlatformException {
return false;
}
}
static void applyManualHardwareInput(bool enabled) {
_manualHardwareInput = enabled;
}
+34 -7
View File
@@ -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<bool>? _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<bool> show() async {
static Future<bool> 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<bool> _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("|副屏工具|忽略业务关闭请求,副屏保持常驻");
}
}
@@ -153,6 +153,9 @@ class AppControl extends GetxController {
detail: '正在读取运行环境与本地终端配置',
);
await AppRuntimeTool.initializePlatform();
// 插件会在 Android 注册阶段自动创建副屏;这里统一确认可用状态并记录常驻标记,
// 即使无副屏或权限不足也只影响设备状态,不阻塞后续启动流程。
unawaited(AppSubScreenTool.show());
await AppSettingTool.removeSettingCache();
_setStartupStep(
@@ -40,7 +40,7 @@ abstract final class TerminalNavigationTool {
if (!await _verifyAdminSession(actionLabel: '退出登录')) return;
Get.find<TerminalAdminSessionControl>().lock();
if (AppNetworkTool.isAutoNetwork()) {
// await AppSettingTool.setUserSetting(UserSetting());
await AppSettingTool.setUserSetting(UserSetting());
}
Get.offAllNamed(RouteNames.login);
}
+282 -1
View File
@@ -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<AppLogPreview> readAppLogPreview({
String? path,
int maxBytes = 256 * 1024,
int maxLines = 1000,
}) async {
await logger.init;
return _consoleOutput.readPreview(
path: path,
maxBytes: maxBytes,
maxLines: maxLines,
);
}
/// 列出日志根目录中的文件,设置页据此构建可切换的日志目录树。
Future<AppLogCatalog> 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<AppLogFile> 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<AppLogPreview> 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<int> 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<AppLogCatalog> listLogFiles() async {
final currentPath = await _resolveCurrentLogPath();
await _enqueue(() async => _sink?.flush());
final root = Directory(currentPath).parent.parent;
final files = <AppLogFile>[];
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<String> _resolveCurrentLogPath() async {
final path = _filePath ?? await AppFileTool.getLoggerBackupPath();
if (ObjectUtil.isEmpty(path)) {
throw const FileSystemException('无法获取日志文件路径');
}
return path!;
}
Future<void> _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<void> 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)}';
});
}
+52 -19
View File
@@ -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<void>? _initializing;
// 初始化TTS
/// 初始化 TTS。
///
/// 收银终端只使用讯飞引擎;未安装或初始化失败时直接禁用语音,
/// 避免 TTS 插件异常阻断应用启动和收银流程。
static Future<void> 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<void> _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<void> 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<void> stop() async {
await _flutterTts.stop();
if (!_isAvailable) return;
try {
await _flutterTts.stop();
} catch (e, stack) {
logger.w("|$_tag|停止语音播放失败error:|$e|stack:|$stack|");
}
}
// 释放资源
static Future<void> dispose() async {
await _flutterTts.stop();
await stop();
}
/// 将数字转换为中文读法(手机号码格式)
+6 -3
View File
@@ -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';
+16 -4
View File
@@ -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<void> doLogin(bool visibleMsg) async {
Future<void> 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 = <String, dynamic>{
'username': usernameC.value.text,
'password': passwordC.value.text,
@@ -108,12 +113,14 @@ class LoginController extends GetxController {
}
Future<void> 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() {
+233 -121
View File
@@ -10,7 +10,237 @@ import 'controller.dart';
class LoginPage extends GetView<LoginController> {
const LoginPage({super.key});
Widget _buildBrandArea(BuildContext context) {
@override
Widget build(BuildContext context) {
return GetBuilder<LoginController>(
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<LoginController> {
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<LoginController> {
),
);
}
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<LoginController>(
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),
],
),
),
),
),
);
}
}
/// 登录品牌区的能力标签仅表达终端能力,避免在页面内重复堆叠相同布局。
+4
View File
@@ -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 => '查看可用终端版本、更新内容和下载状态',
};
@@ -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';
@@ -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<SettingLogPreview> createState() => _SettingLogPreviewState();
}
class _SettingLogPreviewState extends State<SettingLogPreview> {
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<void> _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<void> _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<String> 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<String, List<AppLogFile>> get _groupedFiles {
final groups = <String, List<AppLogFile>>{};
for (final file in _catalog?.files ?? const <AppLogFile>[]) {
final separator = file.relativePath.indexOf('/');
final label =
separator < 0 ? '日志文件' : file.relativePath.substring(0, separator);
groups.putIfAbsent(label, () => <AppLogFile>[]).add(file);
}
return groups;
}
Widget _buildToolbar(BuildContext context, List<String> 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 <AppLogFile>[]) {
if (file.path == _selectedPath) return file;
}
return null;
}
Widget _buildPreview(BuildContext context, List<String> 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<AppLogFile> files;
final String? selectedPath;
final ValueChanged<AppLogFile> 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;
}
@@ -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<SettingSubScreenRoutes> {
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<SettingSubScreenRoutes> {
_ScenarioGroup(
title: '顾客结算屏演示状态',
scenarios: CustomerDisplayScenario.values
.where((scenario) => scenario != CustomerDisplayScenario.idle)
.map(
(scenario) => _PreviewScenario(
title: scenario.title,
@@ -109,16 +117,21 @@ class _SettingSubScreenRoutesState extends State<SettingSubScreenRoutes> {
}
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 <String, String>{},
_SubScreenPreviewType.settlement => {'scenario': _scenario.name},
_SubScreenPreviewType.reservationQuery => {
'scenario': _reservationScenario.name,
},
};
Navigator.of(context, rootNavigator: true).push<void>(
GetPageRoute<void>(
routeName: definition.route,
@@ -126,9 +139,13 @@ class _SettingSubScreenRoutesState extends State<SettingSubScreenRoutes> {
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({
@@ -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) {
@@ -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();
+8 -3
View File
@@ -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)
+659
View File
@@ -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<CustomerWelcomePage> createState() => _CustomerWelcomePageState();
}
class _CustomerWelcomePageState extends State<CustomerWelcomePage> {
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],
);
}
@@ -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(),
);
}
}
+2
View File
@@ -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';
+2
View File
@@ -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<SubAppPage> {
animation: _coordinator,
builder: (context, _) {
return switch (_coordinator.mode) {
SubDisplayMode.welcome => const CustomerWelcomePage(),
SubDisplayMode.settlement => CustomerDisplayPage(
coordinator: _coordinator.customerCoordinator,
),
+8
View File
@@ -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:
+3
View File
@@ -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/
@@ -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'] = <Map<String, dynamic>>[],
),
isTrue,
);
expect(coordinator.state.scenario, CustomerDisplayScenario.idle);
expect(coordinator.state.products, isEmpty);
coordinator.dispose();
});
}
Map<String, dynamic> _productEvent() {
@@ -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);
@@ -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<void>(
MaterialPageRoute(
builder: (_) => const CustomerWelcomePreviewPage(),
),
),
child: const Text('打开欢迎预览'),
),
),
),
),
);
}
}
@@ -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);
});
}
@@ -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'] = <Map<String, dynamic>>[],
),
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<String, dynamic> _productEvent() {
return {
'sysType': 'SUPERMARKET',
'payUser': {'username': '张小明'},
'totalPrice': 480,
'amountMealPrice': 0,
'shopList': [
{
'productName': '纯牛奶 250ml',
'productPrice': 480,
'nature': 'DEFAULT',
'productNumber': 1,
'productWidget': 0,
'totalPrice': 480,
},
],
};
}
Map<String, dynamic> _faceEvent({
required String sessionId,
required int revision,
}) {
return {
'sessionId': sessionId,
'revision': revision,
'scene': 'cashier',
'message': revision == 1 ? '正在启动摄像头' : '识别成功,请稍候',
};
}