feat:日志查看
fix:真机bug
This commit is contained in:
1 parent
5b9ef49564
commit
6ef0b6320f
40 files changed
+2773
-198
No files matched your search
@@ -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,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
@@ -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,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", "版本管理"),
|
||||
;
|
||||
|
||||
@@ -8,6 +8,9 @@ class RouteNames {
|
||||
/// 顾客屏全窗口预览,不装载终端侧栏和顶栏。
|
||||
static const customer = '/customer';
|
||||
|
||||
/// 欢迎副屏全窗口预览。
|
||||
static const customerWelcome = '/customer-welcome';
|
||||
|
||||
/// 预约查询副屏全窗口预览。
|
||||
static const menuQueryCustomer = '/menu-query-customer';
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)}';
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/// 将数字转换为中文读法(手机号码格式)
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录品牌区的能力标签仅表达终端能力,避免在页面内重复堆叠相同布局。
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ? '正在启动摄像头' : '识别成功,请稍候',
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user