diff --git a/lib/common/api/hard_api.dart b/lib/common/api/hard_api.dart index e1b1257..1689bfd 100644 --- a/lib/common/api/hard_api.dart +++ b/lib/common/api/hard_api.dart @@ -127,6 +127,41 @@ abstract class HardApi { return ServiceUser.fromJson(res.data); } + /// 按当前设备绑定部门树(或全租户)拉取脱敏后的离线人员固定上界游标快照。 + /// + /// 部门范围、钱包类型全部由 DEVICE-ID 解析;姓名脱敏且不含手机号头像。 + /// [allTenant] 仅非生产调试使用,生产环境服务端会强制收敛为部门范围。 + /// 业务失败必须抛出,由调用方在同步中断时保留已写入的本地数据。 + static Future getOfflineUserSyncSnapshot({ + required String afterId, + required int limit, + String? snapshotMaxId, + bool allTenant = false, + }) async { + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + if (hardId == null) throw StateError('终端尚未绑定设备'); + final res = await HttpManage().dio.post( + '/hard/hard-api/ad/offline-user/sync-snapshot', + data: { + 'afterId': afterId, + 'limit': limit, + if (snapshotMaxId != null) 'snapshotMaxId': snapshotMaxId, + if (allTenant) 'allTenant': true, + }, + options: dio.Options( + headers: {'DEVICE-ID': hardId}, + extra: {HttpManage.rejectBusinessErrorKey: true}, + ), + ); + if (res.data is! Map) { + throw const FormatException('服务端未返回有效的离线人员快照'); + } + return ServiceOfflineUserSyncSnapshot.fromJson( + Map.from(res.data as Map), + ); + } + /// 仅按当前终端和识别身份查询本人预约,调用方不得补充商户或其他用户参数。 static Future queryReservationMenu( ReservationMenuQueryRequest request, diff --git a/lib/common/models/api/hard_api_models.dart b/lib/common/models/api/hard_api_models.dart index a15d5e5..41e4b2f 100644 --- a/lib/common/models/api/hard_api_models.dart +++ b/lib/common/models/api/hard_api_models.dart @@ -1414,3 +1414,105 @@ class ServiceReservationComboItem { /// 将套餐组成转换为可传输 JSON。 Map toJson() => _$ServiceReservationComboItemToJson(this); } + +/// 终端离线人员快照记录。 +/// +/// 服务端按设备绑定部门树返回,姓名已脱敏且不含手机号头像;仅用于预置本机 +/// 断网消费能力,余额为同步时刻快照。 +@JsonSerializable(includeIfNull: false) +class ServiceOfflineUserRecord { + /// 创建一条服务端脱敏后的离线人员记录。 + const ServiceOfflineUserRecord({ + required this.userId, + required this.username, + required this.walletCardNumber, + required this.walletId, + required this.walletTypeId, + required this.walletBalance, + this.canteenId, + }); + + /// 用户ID;同时作为快照分页游标。 + @JsonKey( + fromJson: JsonTool.anyToRequiredString, + toJson: JsonTool.stringToInt, + ) + final String userId; + + /// 脱敏用户名,例如“张*”。 + final String username; + + /// 钱包卡号;断网刷离线饭卡时的匹配键。 + final String walletCardNumber; + + /// 钱包id。 + @JsonKey( + fromJson: JsonTool.anyToRequiredString, + toJson: JsonTool.stringToInt, + ) + final String walletId; + + /// 钱包类型id。 + @JsonKey( + fromJson: JsonTool.anyToRequiredString, + toJson: JsonTool.stringToInt, + ) + final String walletTypeId; + + /// 钱包余额(分)快照。 + final int walletBalance; + + /// 用户所属的商户id,主要用于机器的离线模式。 + @JsonKey( + fromJson: JsonTool.anyToNullableInt, + toJson: JsonTool.intToString, + ) + final int? canteenId; + + /// 从统一网络层返回的 JSON 创建离线人员记录。 + factory ServiceOfflineUserRecord.fromJson(Map json) => + _$ServiceOfflineUserRecordFromJson(json); + + /// 将离线人员记录转换为 JSON,主要供模型测试使用。 + Map toJson() => _$ServiceOfflineUserRecordToJson(this); +} + +/// 终端离线人员同步的固定上界游标页面。 +@JsonSerializable(includeIfNull: false) +class ServiceOfflineUserSyncSnapshot { + /// 创建一个固定上界游标页面。 + const ServiceOfflineUserSyncSnapshot({ + required this.records, + required this.nextAfterId, + required this.hasMore, + required this.snapshotMaxId, + }); + + /// 当前页面记录,服务端保证按用户主键严格升序返回。 + final List records; + + /// 下一页排他游标;末页等于当前请求游标。 + @JsonKey( + fromJson: JsonTool.anyToRequiredString, + toJson: JsonTool.stringToInt, + ) + final String nextAfterId; + + /// 是否还有位于固定快照上界内的下一页。 + final bool hasMore; + + /// 首次请求固定的用户主键上界,后续请求必须原样携带。 + @JsonKey( + fromJson: JsonTool.anyToRequiredString, + toJson: JsonTool.stringToInt, + ) + final String snapshotMaxId; + + /// 从统一网络层返回的 JSON 创建离线人员快照页面。 + factory ServiceOfflineUserSyncSnapshot.fromJson( + Map json, + ) => _$ServiceOfflineUserSyncSnapshotFromJson(json); + + /// 将快照页面转换为 JSON,主要供模型测试使用。 + Map toJson() => _$ServiceOfflineUserSyncSnapshotToJson(this); +} diff --git a/lib/common/models/api/hard_api_models.g.dart b/lib/common/models/api/hard_api_models.g.dart index 63f7735..8620e29 100644 --- a/lib/common/models/api/hard_api_models.g.dart +++ b/lib/common/models/api/hard_api_models.g.dart @@ -950,3 +950,65 @@ Map _$ServiceReservationComboItemToJson( 'sortOrder': instance.sortOrder, 'price': instance.price, }; + +ServiceOfflineUserRecord _$ServiceOfflineUserRecordFromJson( + Map json) => + ServiceOfflineUserRecord( + userId: JsonTool.anyToRequiredString(json['userId']), + username: json['username'] as String, + walletCardNumber: json['walletCardNumber'] as String, + walletId: JsonTool.anyToRequiredString(json['walletId']), + walletTypeId: JsonTool.anyToRequiredString(json['walletTypeId']), + walletBalance: (json['walletBalance'] as num).toInt(), + canteenId: JsonTool.anyToNullableInt(json['canteenId']), + ); + +Map _$ServiceOfflineUserRecordToJson( + ServiceOfflineUserRecord instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('userId', JsonTool.stringToInt(instance.userId)); + val['username'] = instance.username; + val['walletCardNumber'] = instance.walletCardNumber; + writeNotNull('walletId', JsonTool.stringToInt(instance.walletId)); + writeNotNull('walletTypeId', JsonTool.stringToInt(instance.walletTypeId)); + val['walletBalance'] = instance.walletBalance; + writeNotNull('canteenId', JsonTool.intToString(instance.canteenId)); + return val; +} + +ServiceOfflineUserSyncSnapshot _$ServiceOfflineUserSyncSnapshotFromJson( + Map json) => + ServiceOfflineUserSyncSnapshot( + records: (json['records'] as List) + .map((e) => + ServiceOfflineUserRecord.fromJson(e as Map)) + .toList(), + nextAfterId: JsonTool.anyToRequiredString(json['nextAfterId']), + hasMore: json['hasMore'] as bool, + snapshotMaxId: JsonTool.anyToRequiredString(json['snapshotMaxId']), + ); + +Map _$ServiceOfflineUserSyncSnapshotToJson( + ServiceOfflineUserSyncSnapshot instance) { + final val = { + 'records': instance.records, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('nextAfterId', JsonTool.stringToInt(instance.nextAfterId)); + val['hasMore'] = instance.hasMore; + writeNotNull('snapshotMaxId', JsonTool.stringToInt(instance.snapshotMaxId)); + return val; +} diff --git a/lib/common/models/api/product_api_models.dart b/lib/common/models/api/product_api_models.dart index c55724f..9fc98b6 100644 --- a/lib/common/models/api/product_api_models.dart +++ b/lib/common/models/api/product_api_models.dart @@ -138,6 +138,9 @@ class ServiceSupermarketSpu { /// 销量 int? salesCount; + /// 是否删除;增量同步时服务端返回的删除墓碑行会携带该标记 + String? delFlag; + ServiceSupermarketSpu({ this.id, this.productCode, @@ -151,6 +154,7 @@ class ServiceSupermarketSpu { this.retailPrice, this.promotionPrice, this.salesCount, + this.delFlag, }); factory ServiceSupermarketSpu.fromJson(Map json) => diff --git a/lib/common/models/api/product_api_models.g.dart b/lib/common/models/api/product_api_models.g.dart index c0e3d83..ff0c402 100644 --- a/lib/common/models/api/product_api_models.g.dart +++ b/lib/common/models/api/product_api_models.g.dart @@ -76,6 +76,7 @@ ServiceSupermarketSpu _$ServiceSupermarketSpuFromJson( retailPrice: (json['retailPrice'] as num?)?.toInt(), promotionPrice: (json['promotionPrice'] as num?)?.toInt(), salesCount: (json['salesCount'] as num?)?.toInt(), + delFlag: json['delFlag'] as String?, ); Map _$ServiceSupermarketSpuToJson( @@ -93,6 +94,7 @@ Map _$ServiceSupermarketSpuToJson( 'retailPrice': instance.retailPrice, 'promotionPrice': instance.promotionPrice, 'salesCount': instance.salesCount, + 'delFlag': instance.delFlag, }; ServiceSupermarketSpuPage _$ServiceSupermarketSpuPageFromJson( diff --git a/lib/common/models/mq/mq_models.dart b/lib/common/models/mq/mq_models.dart index ce28e64..c653648 100644 --- a/lib/common/models/mq/mq_models.dart +++ b/lib/common/models/mq/mq_models.dart @@ -112,6 +112,39 @@ class MqttFaceChangeMsg { Map toJson() => _$MqttFaceChangeMsgToJson(this); } +/// SUPERMARKET_PRODUCT 动作专用消息,携带发生变化的商品标识供终端定向同步。 +@JsonSerializable() +class MqttSupermarketProductMsg { + /// MQTT 动作,协议值固定为 SUPERMARKET_PRODUCT。 + final MqtActionEnum? action; + + /// 服务端发生变更的商品 SPU 编号;缺省时终端回退为常规增量同步。 + @JsonKey(fromJson: _spuIdsFromJson, toJson: _spuIdsToJson) + final List? spuIds; + + /// 创建一条超市商品变更通知。 + const MqttSupermarketProductMsg({ + this.action, + this.spuIds, + }); + + /// 从 MQTT JSON 创建专用消息。 + factory MqttSupermarketProductMsg.fromJson(Map json) => + _$MqttSupermarketProductMsgFromJson(json); + + /// 将专用消息转换为 JSON,主要供协议测试使用。 + Map toJson() => _$MqttSupermarketProductMsgToJson(this); + + /// 服务端下发的是数字编号数组,统一转成字符串再与本地口径比较。 + static List? _spuIdsFromJson(Object? value) { + if (value is! List) return null; + if (value.isEmpty) return null; + return value.map((e) => e.toString()).toList(); + } + + static Object? _spuIdsToJson(List? value) => value; +} + @JsonSerializable() class MqttTakeFoodBroadcastMsg { /// 发送硬件的topic diff --git a/lib/common/models/mq/mq_models.g.dart b/lib/common/models/mq/mq_models.g.dart index 452c52d..a76cc3f 100644 --- a/lib/common/models/mq/mq_models.g.dart +++ b/lib/common/models/mq/mq_models.g.dart @@ -24,6 +24,7 @@ const _$MqtActionEnumEnumMap = { MqtActionEnum.PRINT: 'PRINT', MqtActionEnum.FACE: 'FACE', MqtActionEnum.TAKE_FOOD_BROADCAST: 'TAKE_FOOD_BROADCAST', + MqtActionEnum.PULL_TERMINAL_LOG: 'PULL_TERMINAL_LOG', }; MqttInvoiceMsg _$MqttInvoiceMsgFromJson(Map json) => @@ -72,6 +73,20 @@ Map _$MqttFaceChangeMsgToJson(MqttFaceChangeMsg instance) => 'isGlobalFace': instance.isGlobalFace, }; +MqttSupermarketProductMsg _$MqttSupermarketProductMsgFromJson( + Map json) => + MqttSupermarketProductMsg( + action: $enumDecodeNullable(_$MqtActionEnumEnumMap, json['action']), + spuIds: MqttSupermarketProductMsg._spuIdsFromJson(json['spuIds']), + ); + +Map _$MqttSupermarketProductMsgToJson( + MqttSupermarketProductMsg instance) => + { + 'action': _$MqtActionEnumEnumMap[instance.action], + 'spuIds': MqttSupermarketProductMsg._spuIdsToJson(instance.spuIds), + }; + MqttTakeFoodBroadcastMsg _$MqttTakeFoodBroadcastMsgFromJson( Map json) => MqttTakeFoodBroadcastMsg( diff --git a/lib/common/models/page/setting_page_enum_models.dart b/lib/common/models/page/setting_page_enum_models.dart index 4ec5ab8..43770d7 100644 --- a/lib/common/models/page/setting_page_enum_models.dart +++ b/lib/common/models/page/setting_page_enum_models.dart @@ -13,6 +13,7 @@ enum SettingMenuType implements BaseEnum { versionSetting("70", "版本管理"), facePluginSetting("80", "人脸设置"), mealPeriodSetting("90", "餐段时间配置"), + offlineUserSetting("100", "离线购买人员"), ; @override diff --git a/lib/common/services/database/dao/supermarket_sku_dao.dart b/lib/common/services/database/dao/supermarket_sku_dao.dart index d1289a8..ae6b593 100644 --- a/lib/common/services/database/dao/supermarket_sku_dao.dart +++ b/lib/common/services/database/dao/supermarket_sku_dao.dart @@ -34,6 +34,7 @@ abstract class SupermarketSkuDao { @Query(""" SELECT DISTINCT COUNT(0) FROM supermarket_sku INNER JOIN supermarket_spu ON supermarket_sku.spuId = supermarket_spu.serviceUniqueCode AND supermarket_spu.delFlag = 0 + INNER JOIN classify ON supermarket_spu.classifyServiceUniqueCode = classify.serviceUniqueCode AND classify.delFlag = 0 WHERE 1 = 1 AND (:barCode = '-1' OR supermarket_sku.barCode LIKE '%' || :barCode || '%' OR supermarket_spu.productName LIKE '%' || :barCode || '%' OR (:barCode != '' AND supermarket_spu.productNamePy LIKE '%' || :barCode || '%') ) @@ -68,6 +69,7 @@ abstract class SupermarketSkuDao { supermarket_sku.packageNum FROM supermarket_sku INNER JOIN supermarket_spu ON supermarket_sku.spuId = supermarket_spu.serviceUniqueCode AND supermarket_spu.delFlag = 0 + INNER JOIN classify ON supermarket_spu.classifyServiceUniqueCode = classify.serviceUniqueCode AND classify.delFlag = 0 WHERE 1 = 1 AND (:barCode = '-1' OR supermarket_sku.barCode LIKE '%' || :barCode || '%' OR supermarket_spu.productName LIKE '%' || :barCode || '%' OR (:barCode != '' AND supermarket_spu.productNamePy LIKE '%' || :barCode || '%') ) diff --git a/lib/common/services/database/dao/user_dao.dart b/lib/common/services/database/dao/user_dao.dart index b8baefc..5bf556c 100644 --- a/lib/common/services/database/dao/user_dao.dart +++ b/lib/common/services/database/dao/user_dao.dart @@ -25,15 +25,55 @@ abstract class UserDao { @Query(''' SELECT * FROM user_record - WHERE + WHERE userId = :userId ''') Future findByUserId(String userId); @Query(''' SELECT * FROM user_record - WHERE + WHERE walletCardNumber = :walletCardNumber ''') Future findByCardNumber(String walletCardNumber); + + @Query(''' + DELETE FROM user_record WHERE id = :id + ''') + Future delById(int id); + + @Query(''' + DELETE FROM user_record + ''') + Future delAll(); + + /// 统计离线人员缓存总数;关键字同时匹配姓名、人员编号、饭卡号和手机号。 + @Query(''' + SELECT COUNT(0) FROM user_record + WHERE + (:keyword = '-1' + OR nickname LIKE '%' || :keyword || '%' + OR userId LIKE '%' || :keyword || '%' + OR walletCardNumber LIKE '%' || :keyword || '%' + OR mobile LIKE '%' || :keyword || '%') + ''') + Future findCount(String keyword); + + /// 分页查询离线人员缓存,最近使用者优先。 + @Query(''' + SELECT * FROM user_record + WHERE + (:keyword = '-1' + OR nickname LIKE '%' || :keyword || '%' + OR userId LIKE '%' || :keyword || '%' + OR walletCardNumber LIKE '%' || :keyword || '%' + OR mobile LIKE '%' || :keyword || '%') + ORDER BY updateTime DESC, id DESC + LIMIT :pageSize OFFSET :offset + ''') + Future> findAll( + String keyword, + int pageSize, + int offset, + ); } diff --git a/lib/common/services/database/database.g.dart b/lib/common/services/database/database.g.dart index 3715c86..2f1fbc8 100644 --- a/lib/common/services/database/database.g.dart +++ b/lib/common/services/database/database.g.dart @@ -992,7 +992,7 @@ class _$SupermarketSkuDao extends SupermarketSkuDao { String classifyServiceUniqueCode, ) async { return _queryAdapter.query( - 'SELECT DISTINCT COUNT(0) FROM supermarket_sku INNER JOIN supermarket_spu ON supermarket_sku.spuId = supermarket_spu.serviceUniqueCode AND supermarket_spu.delFlag = 0 WHERE 1 = 1 AND (?1 = \'-1\' OR supermarket_sku.barCode LIKE \'%\' || ?1 || \'%\' OR supermarket_spu.productName LIKE \'%\' || ?1 || \'%\' OR (?1 != \'\' AND supermarket_spu.productNamePy LIKE \'%\' || ?1 || \'%\') ) AND (?2 = \'-1\' OR supermarket_spu.classifyServiceUniqueCode = ?2)', + 'SELECT DISTINCT COUNT(0) FROM supermarket_sku INNER JOIN supermarket_spu ON supermarket_sku.spuId = supermarket_spu.serviceUniqueCode AND supermarket_spu.delFlag = 0 INNER JOIN classify ON supermarket_spu.classifyServiceUniqueCode = classify.serviceUniqueCode AND classify.delFlag = 0 WHERE 1 = 1 AND (?1 = \'-1\' OR supermarket_sku.barCode LIKE \'%\' || ?1 || \'%\' OR supermarket_spu.productName LIKE \'%\' || ?1 || \'%\' OR (?1 != \'\' AND supermarket_spu.productNamePy LIKE \'%\' || ?1 || \'%\') ) AND (?2 = \'-1\' OR supermarket_spu.classifyServiceUniqueCode = ?2)', mapper: (Map row) => row.values.first as int, arguments: [barCode, classifyServiceUniqueCode]); } @@ -1005,7 +1005,7 @@ class _$SupermarketSkuDao extends SupermarketSkuDao { int offset, ) async { return _queryAdapter.queryList( - 'SELECT supermarket_sku.id, supermarket_sku.serviceUniqueCode, supermarket_sku.spuId, supermarket_spu.productCode, supermarket_spu.classifyServiceUniqueCode, supermarket_spu.productName, supermarket_spu.productNamePy, supermarket_spu.picUrl, supermarket_spu.productNature, supermarket_spu.salesCount, supermarket_spu.stock, supermarket_spu.sort, supermarket_spu.createTime, supermarket_spu.updateTime, supermarket_spu.delFlag, supermarket_sku.barCode, supermarket_sku.promotionPrice, supermarket_sku.purchasePrice, supermarket_sku.retailPrice, supermarket_sku.spec, supermarket_sku.packageNum, supermarket_sku.unit, supermarket_sku.packageNum FROM supermarket_sku INNER JOIN supermarket_spu ON supermarket_sku.spuId = supermarket_spu.serviceUniqueCode AND supermarket_spu.delFlag = 0 WHERE 1 = 1 AND (?1 = \'-1\' OR supermarket_sku.barCode LIKE \'%\' || ?1 || \'%\' OR supermarket_spu.productName LIKE \'%\' || ?1 || \'%\' OR (?1 != \'\' AND supermarket_spu.productNamePy LIKE \'%\' || ?1 || \'%\') ) AND (?2 = \'-1\' OR supermarket_spu.classifyServiceUniqueCode = ?2) ORDER BY supermarket_sku.spuId LIMIT ?3 OFFSET ?4', + 'SELECT supermarket_sku.id, supermarket_sku.serviceUniqueCode, supermarket_sku.spuId, supermarket_spu.productCode, supermarket_spu.classifyServiceUniqueCode, supermarket_spu.productName, supermarket_spu.productNamePy, supermarket_spu.picUrl, supermarket_spu.productNature, supermarket_spu.salesCount, supermarket_spu.stock, supermarket_spu.sort, supermarket_spu.createTime, supermarket_spu.updateTime, supermarket_spu.delFlag, supermarket_sku.barCode, supermarket_sku.promotionPrice, supermarket_sku.purchasePrice, supermarket_sku.retailPrice, supermarket_sku.spec, supermarket_sku.packageNum, supermarket_sku.unit, supermarket_sku.packageNum FROM supermarket_sku INNER JOIN supermarket_spu ON supermarket_sku.spuId = supermarket_spu.serviceUniqueCode AND supermarket_spu.delFlag = 0 INNER JOIN classify ON supermarket_spu.classifyServiceUniqueCode = classify.serviceUniqueCode AND classify.delFlag = 0 WHERE 1 = 1 AND (?1 = \'-1\' OR supermarket_sku.barCode LIKE \'%\' || ?1 || \'%\' OR supermarket_spu.productName LIKE \'%\' || ?1 || \'%\' OR (?1 != \'\' AND supermarket_spu.productNamePy LIKE \'%\' || ?1 || \'%\') ) AND (?2 = \'-1\' OR supermarket_spu.classifyServiceUniqueCode = ?2) ORDER BY supermarket_sku.spuId LIMIT ?3 OFFSET ?4', mapper: (Map row) => SupermarketSpuEntity(id: row['id'] as int?, serviceUniqueCode: row['serviceUniqueCode'] as String, classifyServiceUniqueCode: row['classifyServiceUniqueCode'] as String, productCode: row['productCode'] as String, productName: row['productName'] as String, productNature: _supermarketNatureConverter.decode(row['productNature'] as String), barCode: row['barCode'] as String?, packageNum: row['packageNum'] as int, unit: row['unit'] as String, spec: row['spec'] as String, picUrl: row['picUrl'] as String?, stock: row['stock'] as double?, salesCount: row['salesCount'] as double?, purchasePrice: row['purchasePrice'] as int, retailPrice: row['retailPrice'] as int, promotionPrice: row['promotionPrice'] as int, sort: row['sort'] as int, createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String), delFlag: _boolConverter.decode(row['delFlag'] as int)), arguments: [barCode, classifyServiceUniqueCode, pageSize, offset]); } @@ -1748,7 +1748,7 @@ class _$UserDao extends UserDao { @override Future findByUserId(String userId) async { return _queryAdapter.query( - 'SELECT * FROM user_record WHERE userId = ?1', + 'SELECT * FROM user_record WHERE userId = ?1', mapper: (Map row) => UserEntity( id: row['id'] as int?, userId: row['userId'] as String, @@ -1774,7 +1774,7 @@ class _$UserDao extends UserDao { @override Future findByCardNumber(String walletCardNumber) async { return _queryAdapter.query( - 'SELECT * FROM user_record WHERE walletCardNumber = ?1', + 'SELECT * FROM user_record WHERE walletCardNumber = ?1', mapper: (Map row) => UserEntity( id: row['id'] as int?, userId: row['userId'] as String, @@ -1797,6 +1797,37 @@ class _$UserDao extends UserDao { arguments: [walletCardNumber]); } + @override + Future delById(int id) async { + await _queryAdapter.queryNoReturn('DELETE FROM user_record WHERE id = ?1', + arguments: [id]); + } + + @override + Future delAll() async { + await _queryAdapter.queryNoReturn('DELETE FROM user_record'); + } + + @override + Future findCount(String keyword) async { + return _queryAdapter.query( + 'SELECT COUNT(0) FROM user_record WHERE (?1 = \'-1\' OR nickname LIKE \'%\' || ?1 || \'%\' OR userId LIKE \'%\' || ?1 || \'%\' OR walletCardNumber LIKE \'%\' || ?1 || \'%\' OR mobile LIKE \'%\' || ?1 || \'%\')', + mapper: (Map row) => row.values.first as int, + arguments: [keyword]); + } + + @override + Future> findAll( + String keyword, + int pageSize, + int offset, + ) async { + return _queryAdapter.queryList( + 'SELECT * FROM user_record WHERE (?1 = \'-1\' OR nickname LIKE \'%\' || ?1 || \'%\' OR userId LIKE \'%\' || ?1 || \'%\' OR walletCardNumber LIKE \'%\' || ?1 || \'%\' OR mobile LIKE \'%\' || ?1 || \'%\') ORDER BY updateTime DESC, id DESC LIMIT ?2 OFFSET ?3', + mapper: (Map row) => UserEntity(id: row['id'] as int?, userId: row['userId'] as String, nickname: row['nickname'] as String, mobile: row['mobile'] as String?, avatar: row['avatar'] as String?, walletCardNumber: row['walletCardNumber'] as String?, walletId: row['walletId'] as String?, walletTypeId: row['walletTypeId'] as String?, walletBalance: row['walletBalance'] as int?, canteenId: row['canteenId'] as String?, createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String?), updateTime: _dateTimeEmptyConverter.decode(row['updateTime'] as String?), discountsUpdateTime: _dateTimeEmptyConverter.decode(row['discountsUpdateTime'] as String?), discountsList: _listDiscountsConverter.decode(row['discountsList'] as String?)), + arguments: [keyword, pageSize, offset]); + } + @override Future save(UserEntity entity) async { await _userEntityInsertionAdapter.insert(entity, OnConflictStrategy.abort); diff --git a/lib/common/utils/app/app_mq_tool.dart b/lib/common/utils/app/app_mq_tool.dart index 147cf66..64f90a9 100644 --- a/lib/common/utils/app/app_mq_tool.dart +++ b/lib/common/utils/app/app_mq_tool.dart @@ -91,7 +91,23 @@ class AppMqTool { } break; case MqtActionEnum.SUPERMARKET_PRODUCT: - await SyncProductTool.syncProduct(); + // 消息携带 spuIds 时只定向同步变化的商品,否则走常规增量同步。 + Set? spuIds; + try { + final ids = MqttSupermarketProductMsg.fromJson( + json.decode(orgStr), + ).spuIds; + if (ObjectUtil.isNotEmpty(ids)) { + spuIds = ids!.toSet(); + } + } catch (e) { + logger.e("|$_tag|解析超市商品变更消息错误error:|$e|"); + } + if (spuIds != null) { + await SyncProductTool.syncSupermarketProductByIds(spuIds); + } else { + await SyncProductTool.syncProduct(); + } mqUpdateEventBus.fire(MqtActionEnum.SUPERMARKET_PRODUCT); break; case MqtActionEnum.SUPERMARKET: diff --git a/lib/common/utils/index.dart b/lib/common/utils/index.dart index a7578d1..0022201 100644 --- a/lib/common/utils/index.dart +++ b/lib/common/utils/index.dart @@ -45,6 +45,8 @@ export './plugs/face_discern_plugs.dart'; export './router/router_observer_tool.dart'; +export './str/search_highlight_tool.dart'; + export './sync/sync_check_tool.dart'; export './sync/scheduled_task_storage_tool.dart'; export './sync/terminal_heartbeat_tool.dart'; diff --git a/lib/common/utils/logger/logger_tool.dart b/lib/common/utils/logger/logger_tool.dart index 82fec41..550365c 100644 --- a/lib/common/utils/logger/logger_tool.dart +++ b/lib/common/utils/logger/logger_tool.dart @@ -354,7 +354,7 @@ class ConsoleOutput extends LogOutput { }); } - /// 开发模式保留现有文件记录范围;生产模式仅持久化达到配置阈值的日志。 + /// 文件持久化阈值按编译模式取常量:开发取 developmentFileLogLevel,生产取 productionFileLogLevel。 @override void output(OutputEvent event) { try { @@ -365,11 +365,10 @@ class ConsoleOutput extends LogOutput { } unawaited(_enqueue(() async { if (_filePath == null || _sink == null) return; - if (AppSysTool.isProd() && - event.level.value < Constants.productionFileLogLevel.value) { - return; - } - if (Level.debug == event.level) return; + final fileLogLevel = AppSysTool.isProd() + ? Constants.productionFileLogLevel + : Constants.developmentFileLogLevel; + if (event.level.value < fileLogLevel.value) return; _sink!.writeAll(event.lines, '\n'); _sink!.writeln(); await _sink!.flush(); diff --git a/lib/common/utils/manage/http_manage.dart b/lib/common/utils/manage/http_manage.dart index 7a6688b..56cdcaa 100644 --- a/lib/common/utils/manage/http_manage.dart +++ b/lib/common/utils/manage/http_manage.dart @@ -137,7 +137,9 @@ class HttpManage { Toast.error(msg: serviceData.msg ?? "服务器未知错误"); } final isLogin = await _autoLogin(); - if (!isLogin) { + // 已在登录页时跳过跳转:同路由替换会销毁现有 LoginController, + // fenix 重建出的空实例会让已输入的表单读到空账号。 + if (!isLogin && Get.currentRoute != RouteNames.login) { Get.offAndToNamed(RouteNames.login); } } else if (serviceData.code != 0 && !silent) { @@ -182,7 +184,8 @@ class HttpManage { if (statusCode == 424 && !e.requestOptions.path.contains(Constants.oauth2AppApiUrl)) { final isLogin = await _autoLogin(); - if (!isLogin) { + // 同上:登录页自我替换会令 fenix 重建空 LoginController。 + if (!isLogin && Get.currentRoute != RouteNames.login) { Get.offAndToNamed(RouteNames.login); } } diff --git a/lib/common/utils/str/search_highlight_tool.dart b/lib/common/utils/str/search_highlight_tool.dart new file mode 100644 index 0000000..cfae29b --- /dev/null +++ b/lib/common/utils/str/search_highlight_tool.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:pinyin/pinyin.dart'; + +/// 搜索关键字高亮 +/// 与 SupermarketSkuDao 的搜索口径保持一致: +/// 1. 原文不区分大小写 LIKE 命中(条码/商品名); +/// 2. 未直接命中时按拼音首字母 LIKE 命中(商品名拼音),并把命中的 +/// 首字母区间映射回对应的汉字区间。 +class SearchHighlightTool { + /// 默认高亮样式:红色加粗 + static TextStyle defaultHighlight(TextStyle base) { + return base.copyWith( + color: Colors.red, + fontWeight: FontWeight.bold, + ); + } + + /// 按 [keyword] 计算 [text] 的命中区间。 + /// 返回 rune 下标区间(含头不含尾),按 start 升序且互不重叠。 + static List<({int start, int end})> matchRanges(String text, String keyword) { + final kw = keyword.trim(); + if (kw.isEmpty || text.isEmpty) return const []; + final direct = _directRanges(text, kw); + if (direct.isNotEmpty) return direct; + return _pinyinRanges(text, kw); + } + + /// 生成带红色高亮的文本;未命中时退化为普通 Text。 + static Text richText( + String text, { + String? keyword, + required TextStyle baseStyle, + TextStyle? highlightStyle, + int? maxLines, + TextOverflow? overflow, + TextAlign? textAlign, + }) { + final ranges = matchRanges(text, keyword ?? ""); + if (ranges.isEmpty) { + return Text( + text, + style: baseStyle, + maxLines: maxLines, + overflow: overflow, + textAlign: textAlign, + ); + } + final runes = text.runes.toList(); + final highlight = highlightStyle ?? defaultHighlight(baseStyle); + final spans = []; + int cursor = 0; + for (final range in ranges) { + if (range.start < cursor) continue; + if (range.start > cursor) { + spans.add( + TextSpan(text: String.fromCharCodes(runes.sublist(cursor, range.start))), + ); + } + spans.add( + TextSpan( + text: String.fromCharCodes(runes.sublist(range.start, range.end)), + style: highlight, + ), + ); + cursor = range.end; + } + if (cursor < runes.length) { + spans.add(TextSpan(text: String.fromCharCodes(runes.sublist(cursor)))); + } + return Text.rich( + TextSpan(style: baseStyle, children: spans), + maxLines: maxLines, + overflow: overflow, + textAlign: textAlign, + ); + } + + /// 原文直接命中(与 SQLite LIKE 对 ASCII 不区分大小写一致),返回全部命中位置 + static List<({int start, int end})> _directRanges(String text, String keyword) { + final textRunes = text.toLowerCase().runes.toList(); + final kwRunes = keyword.toLowerCase().runes.toList(); + if (kwRunes.isEmpty || kwRunes.length > textRunes.length) return const []; + final ranges = <({int start, int end})>[]; + int i = 0; + while (i <= textRunes.length - kwRunes.length) { + bool hit = true; + for (int j = 0; j < kwRunes.length; j++) { + if (textRunes[i + j] != kwRunes[j]) { + hit = false; + break; + } + } + if (hit) { + ranges.add((start: i, end: i + kwRunes.length)); + i += kwRunes.length; + } else { + i++; + } + } + return ranges; + } + + /// 拼音首字母命中:逐字取拼音首字母(汉字取首字母,非汉字原样), + /// 在拼接结果里找 keyword,再把字母区间映射回汉字区间。 + /// 与同步侧 _shortPinyin 的 getShortPinyin 口径一致。 + static List<({int start, int end})> _pinyinRanges(String text, String keyword) { + final chars = text.runes.toList(); + final pyBuffer = StringBuffer(); + final spans = <({int start, int end})>[]; + for (final rune in chars) { + final py = PinyinHelper.getShortPinyin(String.fromCharCode(rune)); + spans.add((start: pyBuffer.length, end: pyBuffer.length + py.length)); + pyBuffer.write(py); + } + final py = pyBuffer.toString().toLowerCase(); + final kw = keyword.toLowerCase(); + if (kw.isEmpty || py.isEmpty) return const []; + final ranges = <({int start, int end})>[]; + int from = 0; + while (true) { + final idx = py.indexOf(kw, from); + if (idx == -1) break; + final pyEnd = idx + kw.length; + int charStart = -1; + int charEnd = -1; + for (int c = 0; c < spans.length; c++) { + final span = spans[c]; + if (span.end <= idx) continue; + if (span.start >= pyEnd) break; + if (charStart == -1) charStart = c; + charEnd = c + 1; + } + if (charStart != -1 && charEnd > charStart) { + ranges.add((start: charStart, end: charEnd)); + } + from = idx + 1; + } + return ranges; + } +} diff --git a/lib/common/utils/sync/sync_product_tool.dart b/lib/common/utils/sync/sync_product_tool.dart index 95c254f..78d2cad 100644 --- a/lib/common/utils/sync/sync_product_tool.dart +++ b/lib/common/utils/sync/sync_product_tool.dart @@ -1,3 +1,5 @@ +import 'dart:isolate'; + import 'package:common_utils/common_utils.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:get/get.dart'; @@ -168,11 +170,12 @@ abstract class SyncProductTool { // 同步商品分类 spu end // ************************************************************************** - /// 同步超市所有数据 - /// 1-删除所有商品分类 - /// 2-删除所有商品 - /// 3-同步商品分类 - /// 4-同步商品 + /// 同步超市所有数据(全量 diff) + /// 1-同步商品分类 + /// 2-分页拉取全部商品,与本地快照比对后只写入变化部分 + /// 3-清理服务端已不存在的商品 + /// + /// 不再先清空本地表:未变化的商品直接跳过,拼音等派生数据全部沿用本地旧值。 static Future syncSupermarket() async { final stopwatch = Stopwatch()..start(); EasyLoading.show( @@ -180,11 +183,17 @@ abstract class SyncProductTool { maskType: EasyLoadingMaskType.black, ); try { - // 删除 - await delDatabase(); // 同步 await syncClassify(); - await syncProduct(); + final success = await _syncSupermarketProduct( + mode: _SupermarketSyncMode.full, + ); + if (success) { + // 全量同步成功才推进水位,失败保留完整窗口下次重试 + final sysSetting = await AppSettingTool.getSysSetting(); + sysSetting.supermarketProductSyncTime = DateTime.now(); + await AppSettingTool.setSysSetting(sysSetting); + } } catch (error, stack) { logger.e( "|$_tag|同步|${SysType.SUPERMARKET.title}|商品信息意外的错误error:|$error|,stack:|$stack|"); @@ -212,36 +221,13 @@ abstract class SyncProductTool { ); if (!success) return; } else if (sysType == SysType.SUPERMARKET) { - // 超市 - final app = Get.find(); - final dataBase = app.dataBase; - final supermarketSpuDao = dataBase.getSupermarketSpuDao; - final supermarketSkuDao = dataBase.getSupermarketSkuDao; - final supermarketCodeDao = dataBase.getSupermarketCodeDao; - int currentPage = 1; - Set serviceCodes = {}; - while (true) { - final pa = {"current": currentPage, "size": 100}; - final supermarket = await ProductApi.supermarketSpu(pa); - if (supermarket == null) break; - if (ObjectUtil.isEmptyList(supermarket.records)) break; - final ids = await _batchUpdateSupermarketProduct( - list: supermarket.records!, - ); - currentPage += 1; - serviceCodes.addAll(ids); - } - // 处理删除 - final productList = await supermarketSpuDao.getAll( - "-1", - ); - final dbCodes = productList.map((e) => e.serviceUniqueCode).toSet(); - final deleteCodes = dbCodes.difference(serviceCodes).toList(); - if (ObjectUtil.isNotEmpty(deleteCodes)) { - await supermarketSpuDao.delByIds(deleteCodes); - await supermarketSkuDao.delBySpuIds(deleteCodes); - await supermarketCodeDao.delBySpuIds(deleteCodes); - } + // 超市:已有同步水位时走增量(只请求上次同步后变化的商品,含删除墓碑), + // 首次同步退化为全量 diff。 + final mode = sysSetting.supermarketProductSyncTime == null + ? _SupermarketSyncMode.full + : _SupermarketSyncMode.incremental; + final success = await _syncSupermarketProduct(mode: mode); + if (!success) return; } // 更新同步时间 if (sysType == SysType.CANTEEN) { @@ -448,59 +434,101 @@ abstract class SyncProductTool { // ************************************************************************** // ************************************************************************** -// 同步食堂商品 spu start +// 同步超市商品 spu start // ************************************************************************** - static Future> _batchUpdateSupermarketProduct({ - required List list, + /// 按服务端推送的编号定向同步超市商品。 + /// + /// MQTT SUPERMARKET_PRODUCT 消息携带 spuIds 时走此入口, + /// 只拉取变化的商品;接口未返回的编号视为服务端已删除。 + static Future syncSupermarketProductByIds(Set spuIds) async { + final sysSetting = await AppSettingTool.getSysSetting(); + if (sysSetting.sysType != SysType.SUPERMARKET) return false; + if (ObjectUtil.isEmpty(spuIds)) return false; + try { + return await _syncSupermarketProduct( + mode: _SupermarketSyncMode.surgical, + onlySpuIds: spuIds, + ); + } catch (e, stack) { + logger.e("|$_tag|定向同步|超市|商品信息意外的错误error:|$e|,stack:|$stack|"); + return false; + } + } + + /// 超市商品同步主体,返回 false 表示接口无有效响应,调用方不应推进同步水位。 + static Future _syncSupermarketProduct({ + required _SupermarketSyncMode mode, + Set? onlySpuIds, }) async { final app = Get.find(); final dataBase = app.dataBase; final supermarketSpuDao = dataBase.getSupermarketSpuDao; - final productList = await supermarketSpuDao.getAll( - "-1", - ); - if (ObjectUtil.isEmpty(productList)) { - await _batchAddOrUpdateSupermarketProduct( - list: list, - isUpdate: false, - ); - await _batchAddOrUpdateSupermarketSpuAndCode(list); - return list.map((e) => e.id.toString()).toSet().toList(); - } - final apiServiceCodes = list.map((e) => e.id.toString()).toSet(); - final dbServiceCodes = productList.map((e) => e.serviceUniqueCode).toSet(); + final supermarketSkuDao = dataBase.getSupermarketSkuDao; + final supermarketCodeDao = dataBase.getSupermarketCodeDao; - final addCodes = apiServiceCodes.difference(dbServiceCodes).toList(); - final updateCodes = apiServiceCodes.intersection(dbServiceCodes).toList(); - if (ObjectUtil.isNotEmpty(addCodes)) { - await _batchAddOrUpdateSupermarketProduct( - list: _filterCaneenSupermarketByCodes(list, addCodes), - isUpdate: false, - ); - } - if (ObjectUtil.isNotEmpty(updateCodes)) { - await _batchAddOrUpdateSupermarketProduct( - list: _filterCaneenSupermarketByCodes(list, updateCodes), - isUpdate: true, - ); - } - await _batchAddOrUpdateSupermarketSpuAndCode(list); - return apiServiceCodes.toList(); - } + // 本地快照只取一次,分页循环内全部走内存比对,替代逐页全表查询和逐条 getByCode。 + final productList = await supermarketSpuDao.getAll("-1"); + final dbMap = { + for (final v in productList) v.serviceUniqueCode: v, + }; - static Future _batchAddOrUpdateSupermarketProduct({ - required List list, - required bool isUpdate, - }) async { - if (ObjectUtil.isEmpty(list)) return; - final app = Get.find(); - final dataBase = app.dataBase; - final productDao = dataBase.getSupermarketSpuDao; - List changeList = []; - for (final v in list) { - SupermarketSpuEntity item = SupermarketSpuEntity( - serviceUniqueCode: v.id ?? "-1", + // 增量模式多回看几分钟容忍终端与服务端时钟偏差,重复拉取是幂等的。 + String? updateTime; + if (mode == _SupermarketSyncMode.incremental) { + final sysSetting = await AppSettingTool.getSysSetting(); + final last = sysSetting.supermarketProductSyncTime; + if (last != null) { + updateTime = DateUtil.formatDate( + last.subtract(const Duration(minutes: 5)), + format: "yyyy-MM-dd HH:mm:ss", + ); + } + } + + final serviceCodes = {}; + final tombstoneCodes = {}; + final apiList = []; + int currentPage = 1; + while (true) { + final pa = {"current": currentPage, "size": 100}; + if (updateTime != null) pa["updateTime"] = updateTime; + if (onlySpuIds != null) pa["ids"] = onlySpuIds.toList(); + final supermarket = await ProductApi.supermarketSpu(pa); + if (supermarket == null) return false; + if (ObjectUtil.isEmptyList(supermarket.records)) break; + for (final v in supermarket.records!) { + final code = v.id.toString(); + if (v.delFlag == "1") { + // 服务端逻辑删除的墓碑行,本地有才需要清理 + tombstoneCodes.add(code); + continue; + } + serviceCodes.add(code); + apiList.add(v); + } + currentPage += 1; + } + + // 拼音只对新增或改名的商品计算,名称未变的沿用本地旧值。 + final pendingNames = {}; + for (final v in apiList) { + final old = dbMap[v.id.toString()]; + final name = v.productName ?? ""; + if (old == null || old.productName != name) { + pendingNames.add(name); + } + } + final pyMap = await _batchTransitionPy(pendingNames.toList()); + + final addList = []; + final editList = []; + final detailList = []; + for (final v in apiList) { + final code = v.id.toString(); + final old = dbMap[code]; + final item = SupermarketSpuEntity( + serviceUniqueCode: code, classifyServiceUniqueCode: v.categoryId ?? "-1", productCode: v.productCode ?? "-1", productName: v.productName ?? "", @@ -518,44 +546,78 @@ abstract class SyncProductTool { retailPrice: v.retailPrice ?? 0, promotionPrice: v.promotionPrice ?? 0, sort: 0, - createTime: DateTime.now(), + createTime: old?.createTime ?? DateTime.now(), updateTime: DateTime.now(), delFlag: false, ); - // 计算一下拼音 - final productNamePy = await transitionPy(item.productName); - if (ObjectUtil.isNotEmpty(productNamePy)) { - item.productNamePy = productNamePy; + if (old != null && old.productName == item.productName) { + item.productNamePy = old.productNamePy; + } else { + item.productNamePy = pyMap[item.productName] ?? ""; } - if (isUpdate) { - final detail = await productDao.getByCode(item.serviceUniqueCode); - if (ObjectUtil.isEmpty(detail)) continue; - if (detail == item) continue; - item.id = detail?.id; + if (old == null) { + addList.add(item); + detailList.add(v); + continue; } - changeList.add(item); + // Equatable 只比较业务字段:未变化的商品跳过写库与详情请求 + if (old == item) continue; + item.id = old.id; + editList.add(item); + detailList.add(v); } - if (ObjectUtil.isNotEmpty(changeList)) { - isUpdate - ? await productDao.batchEdit(changeList) - : await productDao.batchSave(changeList); + if (ObjectUtil.isNotEmpty(addList)) { + await supermarketSpuDao.batchSave(addList); } + if (ObjectUtil.isNotEmpty(editList)) { + await supermarketSpuDao.batchEdit(editList); + } + // sku 与辅助条码只对变化的商品重建 + await _batchSaveSupermarketSkuAndCode(detailList); + + // 删除:增量模式看服务端墓碑,全量/定向模式比对差集 + List deleteCodes; + if (mode == _SupermarketSyncMode.incremental) { + deleteCodes = tombstoneCodes + .where((code) => dbMap.containsKey(code)) + .toList(); + } else if (mode == _SupermarketSyncMode.surgical) { + deleteCodes = onlySpuIds! + .where((code) => + !serviceCodes.contains(code) && dbMap.containsKey(code)) + .toList(); + } else { + deleteCodes = dbMap.keys + .where((code) => !serviceCodes.contains(code)) + .toList(); + } + if (ObjectUtil.isNotEmpty(deleteCodes)) { + // 分批删除,避免 IN 占位符超出 SQLite 变量数上限 + for (final codes in _chunked(deleteCodes, 500)) { + await supermarketSpuDao.delByIds(codes); + await supermarketSkuDao.delBySpuIds(codes); + await supermarketCodeDao.delBySpuIds(codes); + } + } + return true; } - static List _filterCaneenSupermarketByCodes( - List sourceList, - List codes, - ) { - return sourceList - .where((item) => codes.contains(item.id.toString())) - .toList(); + /// 按固定大小切分列表。 + static List> _chunked(List source, int size) { + final result = >[]; + for (var i = 0; i < source.length; i += size) { + result.add( + source.sublist(i, i + size > source.length ? source.length : i + size), + ); + } + return result; } - // 更新辅助条码和spu - static Future _batchAddOrUpdateSupermarketSpuAndCode( + // 重建指定商品的 sku 与辅助条码 + static Future _batchSaveSupermarketSkuAndCode( List list, ) async { - if (list.isEmpty) return; + if (ObjectUtil.isEmpty(list)) return; final app = Get.find(); final dataBase = app.dataBase; final skuDao = dataBase.getSupermarketSkuDao; @@ -564,53 +626,56 @@ abstract class SyncProductTool { final spuIds = list.where((v) => ObjectUtil.isNotEmpty(v)).map((v) => v.id!).toList(); try { - final pa = {"ids": spuIds}; - final skuDetailList = await ProductApi.supermarketSupDetial(pa); - if (skuDetailList == null) { - return; - } - // 删除 sku - skuDao.delBySpuIds(spuIds); - // 删除辅助条码 - codeDao.delBySpuIds(spuIds); - List codeList = []; - List skuList = []; - for (final v in skuDetailList) { - // 辅助条码 - if (v.supermarketAuxiliaryCodeEntityList != null) { - for (final i in v.supermarketAuxiliaryCodeEntityList!) { - SupermarketCodeEntity item = SupermarketCodeEntity( - serviceUniqueCode: i.id ?? "-1", - spuId: i.spuId ?? "-1", - unitId: i.unitId ?? "", - barCode: i.barCode ?? "", - ); - codeList.add(item); + // 详情接口的 ids 拼在 GET URL 上,分批请求避免超出网关 URL 长度限制 + for (final chunkIds in _chunked(spuIds, 100)) { + final pa = {"ids": chunkIds}; + final skuDetailList = await ProductApi.supermarketSupDetial(pa); + if (skuDetailList == null) { + return; + } + // 删除 sku + await skuDao.delBySpuIds(chunkIds); + // 删除辅助条码 + await codeDao.delBySpuIds(chunkIds); + List codeList = []; + List skuList = []; + for (final v in skuDetailList) { + // 辅助条码 + if (v.supermarketAuxiliaryCodeEntityList != null) { + for (final i in v.supermarketAuxiliaryCodeEntityList!) { + SupermarketCodeEntity item = SupermarketCodeEntity( + serviceUniqueCode: i.id ?? "-1", + spuId: i.spuId ?? "-1", + unitId: i.unitId ?? "", + barCode: i.barCode ?? "", + ); + codeList.add(item); + } + } + // sku + if (v.supermarketUnitEntityList != null) { + for (final i in v.supermarketUnitEntityList!) { + SupermarketSkuEntity item = SupermarketSkuEntity( + serviceUniqueCode: i.id ?? "-1", + spuId: i.spuId ?? "-1", + barCode: i.barCode ?? "", + spec: i.spec ?? "", + unit: i.unit ?? "", + packageNum: i.packageNum ?? 0, + purchasePrice: i.purchasePrice ?? 0, + retailPrice: i.retailPrice ?? 0, + promotionPrice: i.promotionPrice ?? 0, + ); + skuList.add(item); + } } } - // sku - if (v.supermarketUnitEntityList != null) { - for (final i in v.supermarketUnitEntityList!) { - SupermarketSkuEntity item = SupermarketSkuEntity( - serviceUniqueCode: i.id ?? "-1", - spuId: i.spuId ?? "-1", - barCode: i.barCode ?? "", - spec: i.spec ?? "", - unit: i.unit ?? "", - packageNum: i.packageNum ?? 0, - purchasePrice: i.purchasePrice ?? 0, - retailPrice: i.retailPrice ?? 0, - promotionPrice: i.promotionPrice ?? 0, - ); - skuList.add(item); - } + if (codeList.isNotEmpty) { + await codeDao.batchSave(codeList); + } + if (skuList.isNotEmpty) { + await skuDao.batchSave(skuList); } - } - if (codeList.isNotEmpty) { - await codeDao.batchSave(codeList); - } - if (skuList.isNotEmpty) { - await skuDao.batchSave(skuList); } } catch (e, stack) { logger.e("|$_tag|添加超市spu detail 错误error:|$e|stack:|$stack|"); @@ -618,22 +683,41 @@ abstract class SyncProductTool { } // ************************************************************************** -// 同步食堂商品 spu end +// 同步超市商品 spu end // ************************************************************************** - // 中文转拼音 - static Future transitionPy(String str) async { + // 中文转短拼音;纯数字内容返回空串 + static String _shortPinyin(String str) { + if (ObjectUtil.isEmpty(str)) return ""; + if (double.tryParse(str) != null) return ""; + return PinyinHelper.getShortPinyin(str).replaceAll(" ", ""); + } + + /// 批量中文转拼音。计算量随商品数增长,放后台 isolate 避免卡住收银界面; + /// isolate 异常时退回主 isolate 逐个计算,保证同步流程不中断。 + static Future> _batchTransitionPy( + List names, + ) async { + if (ObjectUtil.isEmpty(names)) return {}; try { - if (ObjectUtil.isEmpty(str)) return ""; - // 尝试转换为数字,如果是数字,返回空 - if (double.tryParse(str) != null) return ""; - final py = PinyinHelper.getShortPinyin(str); - logger.d("|$_tag|中文转拼音|中文:|$str|转换到拼音:|$py|"); - // 去除所有空格 - final pyNoSpace = py.replaceAll(" ", ""); - return pyNoSpace; - } catch (error, stack) { - logger.e("|$_tag|添加超市spu detail 错误error:|$error|stack:|$stack|"); + return await Isolate.run(() { + return {for (final name in names) name: _shortPinyin(name)}; + }); + } catch (e, stack) { + logger.e("|$_tag|批量中文转拼音isolate错误error:|$e|stack:|$stack|"); + return {for (final name in names) name: _shortPinyin(name)}; } } } + +/// 超市商品同步模式。 +enum _SupermarketSyncMode { + /// 全量分页拉取,并清理服务端已不存在的商品。 + full, + + /// 只拉取上次同步时间之后变化的商品(含删除墓碑),不做全量差集清理。 + incremental, + + /// 只同步指定的商品编号,接口未返回的编号按已删除处理。 + surgical, +} diff --git a/lib/common/utils/sync/sync_user_tool.dart b/lib/common/utils/sync/sync_user_tool.dart index c22c695..6623a1f 100644 --- a/lib/common/utils/sync/sync_user_tool.dart +++ b/lib/common/utils/sync/sync_user_tool.dart @@ -1,9 +1,34 @@ import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; +/// 一次离线人员同步的不可变结果汇总。 +class OfflineUserSyncSummary { + /// 创建一次同步的统计结果。 + const OfflineUserSyncSummary({ + required this.added, + required this.updated, + required this.unchanged, + }); + + /// 本机新增的人员数量。 + final int added; + + /// 更新钱包等快照字段的人员数量。 + final int updated; + + /// 与本地数据一致而跳过写入的人员数量。 + final int unchanged; +} + abstract class SyncUserTool { static const _tag = "sync商品工具"; + /// 离线人员快照单页数量,与服务端默认值保持一致。 + static const int _offlineSyncPageSize = 200; + + /// 游标保护页数,避免异常服务端持续返回 hasMore 造成无限循环。 + static const int _maxOfflineSyncPages = 10000; + // 获取存储用户 static Future storageUserByCarNumber(String cardNumber) async { final app = Get.find(); @@ -82,4 +107,91 @@ abstract class SyncUserTool { } return userEntity; } + + /// 按设备绑定部门树(或全租户)同步脱敏后的离线人员到本机缓存。 + /// + /// [isAll] 只允许非生产调试使用;生产调用即使误传也会被服务端收敛为本部门。 + /// 服务端返回的记录姓名已脱敏且不含手机号头像;本地已有的完整消费缓存 + /// (真实姓名、手机号)优先保留,同步只补齐缺失人员和刷新钱包快照字段。 + /// 分页期间快照上界变化会立即中断,已写入的本地数据保持有效。 + static Future syncOfflineUsers({bool isAll = false}) async { + if (!AppNetworkTool.isAutoNetwork()) { + throw StateError('网络不可用,无法同步离线人员'); + } + final app = Get.find(); + final userDao = app.dataBase.getUserDao; + var afterId = '0'; + String? snapshotMaxId; + var added = 0; + var updated = 0; + var unchanged = 0; + for (var page = 0; page < _maxOfflineSyncPages; page++) { + final snapshot = await HardApi.getOfflineUserSyncSnapshot( + afterId: afterId, + limit: _offlineSyncPageSize, + snapshotMaxId: snapshotMaxId, + allTenant: isAll, + ); + snapshotMaxId ??= snapshot.snapshotMaxId; + if (snapshot.snapshotMaxId != snapshotMaxId) { + throw StateError('离线人员快照上界在分页期间发生变化'); + } + for (final record in snapshot.records) { + final existing = await userDao.findByUserId(record.userId); + if (existing == null) { + final now = DateTime.now(); + await userDao.save( + UserEntity( + userId: record.userId, + nickname: record.username, + walletCardNumber: record.walletCardNumber, + walletId: record.walletId, + walletTypeId: record.walletTypeId, + walletBalance: record.walletBalance, + canteenId: record.canteenId?.toString(), + createTime: now, + updateTime: now, + ), + ); + added++; + continue; + } + // 姓名、手机号、头像和优惠保留本地消费缓存的真实数据,仅刷新钱包快照。 + final merged = UserEntity( + id: existing.id, + userId: existing.userId, + nickname: existing.nickname, + mobile: existing.mobile, + avatar: existing.avatar, + walletCardNumber: record.walletCardNumber, + walletId: record.walletId, + walletTypeId: record.walletTypeId, + walletBalance: record.walletBalance, + canteenId: record.canteenId?.toString() ?? existing.canteenId, + discountsList: existing.discountsList, + discountsUpdateTime: existing.discountsUpdateTime, + createTime: existing.createTime, + updateTime: DateTime.now(), + ); + // UserEntity 等值比较不含时间戳;完全一致时避免无谓写库。 + if (merged == existing) { + unchanged++; + } else { + await userDao.edit(merged); + updated++; + } + } + if (!snapshot.hasMore) break; + afterId = snapshot.nextAfterId; + } + final summary = OfflineUserSyncSummary( + added: added, + updated: updated, + unchanged: unchanged, + ); + logger.i( + '|$_tag|离线人员同步完成|新增:$added|更新:$updated|无变化:$unchanged|', + ); + return summary; + } } diff --git a/lib/common/values/constants.dart b/lib/common/values/constants.dart index 357929f..ec51800 100644 --- a/lib/common/values/constants.dart +++ b/lib/common/values/constants.dart @@ -11,6 +11,10 @@ enum LoginVersion { class Constants { /// 生产模式写入文件的最低日志等级,达到该等级及以上的日志才会持久化。 static const productionFileLogLevel = Level.warning; + + /// 开发模式写入文件的最低日志等级,达到该等级及以上的日志才会持久化。 + static const developmentFileLogLevel = Level.warning; + // oauth2 APP模式客户端信息 static const oauth2AppClient = 'android:android'; diff --git a/lib/common/widgets/app/app_img.dart b/lib/common/widgets/app/app_img.dart index a10794f..8be81a0 100644 --- a/lib/common/widgets/app/app_img.dart +++ b/lib/common/widgets/app/app_img.dart @@ -77,14 +77,14 @@ Widget appImg( ? appNetworkImg( src, errorSize, - imgWidth: double.infinity, + imgWidth: width, imgHeight: height, fit: fit, ) : appFileImg( src, errorSize, - imgWidth: double.infinity, + imgWidth: width, imgHeight: height, fit: fit, ); diff --git a/lib/common/widgets/app/terminal_toolbar.dart b/lib/common/widgets/app/terminal_toolbar.dart index 35c77e7..2f1e707 100644 --- a/lib/common/widgets/app/terminal_toolbar.dart +++ b/lib/common/widgets/app/terminal_toolbar.dart @@ -174,24 +174,41 @@ class TerminalToolbarControl extends StatelessWidget { final double? width; /// 根据当前字体与按钮档位计算输入内边距,保证实际边框高度等于按钮高度。 + /// + /// Forui 输入框使用 [OutlineInputBorder],上下各占一条边框宽度;空态渲染 hint、 + /// 有值渲染正文,按两种样式行高中的较大值反算;真机中西文字体(Inter)与中文 + /// 回退字体混排的行盒会比测量值高约 1px,再预留同量保护量。三者合计从可用高度 + /// 中扣除后反算垂直内边距,字体与按钮大小档位可独立变化且不再产生底部溢出。 @override Widget build(BuildContext context) { final theme = FTheme.of(context); final controlHeight = XfjDesignTokens.controlHeight.h; - final textPainter = TextPainter( - text: TextSpan( - text: '国Hg', - style: theme.textFieldStyle.enabledStyle.contentTextStyle, - ), - maxLines: 1, - textDirection: Directionality.of(context), - textScaler: MediaQuery.textScalerOf(context), - )..layout(); - // Forui 单行输入框的绘制高度由文字行高和上下内边距组成,按运行时字体反算 - // 内边距后,字体大小和按钮大小可独立变化且不会再次造成控件错位。 - final verticalPadding = ((controlHeight - textPainter.height) / 2) - .clamp(0.0, controlHeight / 2); - textPainter.dispose(); + final enabledStyle = theme.textFieldStyle.enabledStyle; + final focusedBorderWidth = enabledStyle.focusedStyle.width; + final unfocusedBorderWidth = enabledStyle.unfocusedStyle.width; + final borderWidth = focusedBorderWidth > unfocusedBorderWidth + ? focusedBorderWidth + : unfocusedBorderWidth; + const fontMetricGuard = 1.0; + final availableHeight = controlHeight - borderWidth * 2 - fontMetricGuard; + var textHeight = 0.0; + for (final style in [ + enabledStyle.contentTextStyle, + enabledStyle.hintTextStyle, + ]) { + final textPainter = TextPainter( + text: TextSpan(text: '国Hg', style: style), + maxLines: 1, + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(); + if (textPainter.height > textHeight) textHeight = textPainter.height; + textPainter.dispose(); + } + // Forui 单行输入框的绘制高度 = 上下边框 + 垂直内边距×2 + 单行文字高度; + // 按运行时字体反算内边距,让内容垂直居中且总高恰好等于按钮高度。 + final verticalPadding = + ((availableHeight - textHeight) / 2).clamp(0.0, availableHeight / 2); return FTheme( // Forui 0.9.1+1 的默认输入内边距是未适配的逻辑像素,工具栏内需按终端比例收敛。 data: theme.copyWith( diff --git a/lib/common/widgets/order/order_supermarket_common.dart b/lib/common/widgets/order/order_supermarket_common.dart index d902954..09841f3 100644 --- a/lib/common/widgets/order/order_supermarket_common.dart +++ b/lib/common/widgets/order/order_supermarket_common.dart @@ -8,6 +8,8 @@ Widget orderSupermarketSpuCard( BuildContext context, { required SupermarketSpuEntity supermarketSpu, bool visibleProductImg = false, + // 搜索关键字,传入时商品名/条码的命中内容红色高亮 + String? searchKeyword, }) { final theme = FTheme.of(context); final textStyle = theme.typography.xs.copyWith( @@ -23,11 +25,12 @@ Widget orderSupermarketSpuCard( .center() .offstage(offstage: !visibleProductImg), subtitle: [ - Text( + SearchHighlightTool.richText( supermarketSpu.productName, - maxLines: 1, + keyword: searchKeyword, + baseStyle: theme.typography.base, + maxLines: 2, overflow: TextOverflow.ellipsis, - style: theme.typography.base, ), SizedBox(height: 5.h), FBadge( @@ -55,9 +58,10 @@ Widget orderSupermarketSpuCard( ].toRow(mainAxisAlignment: MainAxisAlignment.center), [ Text("条码:", style: textStyle), - Text( + SearchHighlightTool.richText( supermarketSpu.barCode ?? "", - style: textStyle, + keyword: searchKeyword, + baseStyle: textStyle, ), ].toRow(mainAxisAlignment: MainAxisAlignment.center), ] diff --git a/lib/common/widgets/order/select_hold_bill_dialog.dart b/lib/common/widgets/order/select_hold_bill_dialog.dart index e05e1c6..de48590 100644 --- a/lib/common/widgets/order/select_hold_bill_dialog.dart +++ b/lib/common/widgets/order/select_hold_bill_dialog.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:common_utils/common_utils.dart'; @@ -27,8 +28,15 @@ class SelectHoldBillController extends GetxController { RxInt pageSize = RxInt(5); RxList holdBillList = RxList(); + Rxn selectedId = Rxn(null); + /// 当前处于行内删除确认的挂单 id;为空表示没有删除确认进行中。 + Rxn deletingId = Rxn(null); + + /// 删除确认超时自动还原计时器,防止操作员点开删除后离开导致按钮停在确认态。 + Timer? _deleteResetTimer; + @override void onInit() async { super.onInit(); @@ -36,7 +44,10 @@ class SelectHoldBillController extends GetxController { } @override - void onClose() {} + void onClose() { + _deleteResetTimer?.cancel(); + _deleteResetTimer = null; + } Future doDateRangeDate(List? dateTime) async { if (ObjectUtil.isEmptyList(dateTime)) { @@ -99,6 +110,41 @@ class SelectHoldBillController extends GetxController { orderHoldBillDao.deleteOlderThan7Days(); } } + + /// 进入指定挂单的行内删除确认状态,超时未确认自动还原为普通按钮。 + void requestDelete(OrderHoldBillEntity bill) { + deletingId.value = bill.id; + _deleteResetTimer?.cancel(); + _deleteResetTimer = Timer(const Duration(seconds: 5), () { + deletingId.value = null; + _deleteResetTimer = null; + }); + } + + /// 取消行内删除确认,恢复该行普通删除按钮。 + void cancelDelete() { + _deleteResetTimer?.cancel(); + _deleteResetTimer = null; + deletingId.value = null; + } + + /// 删除挂单并刷新列表;删掉的是当前页最后一条时回退页码,避免停留空页。 + Future confirmDelete(OrderHoldBillEntity bill) async { + _deleteResetTimer?.cancel(); + _deleteResetTimer = null; + deletingId.value = null; + await AppRuntimeTool.vibrate(durationMs: 60); + final orderHoldBillDao = appC.dataBase.getOrderHoldBillDao; + await orderHoldBillDao.del(bill); + if (selectedId.value == bill.id) { + selectedId.value = null; + } + if (holdBillList.length == 1 && currentPage.value > 1) { + currentPage.value = currentPage.value - 1; + } + await getHoldBillList(); + Toast.success(msg: "已删除挂单"); + } } Future selectHoldBillDialog() async { @@ -146,25 +192,26 @@ Future selectHoldBillDialog() async { ].toColumn().width(double.infinity).height(dialogHeight), actions: [ [ - FButton( - label: Text( - '确定', - style: theme.typography.base.copyWith( - color: theme.colorScheme.secondary, + Obx( + () => FButton( + label: Text( + '确定', + style: theme.typography.base.copyWith( + color: theme.colorScheme.secondary, + ), ), - ), - onPress: () { - final holdBillC = Get.find(); - final selected = holdBillC.holdBillList.firstWhereOrNull( - (v) => v.id == holdBillC.selectedId.value, - ); - if (selected == null) { - EasyLoading.showInfo("请先选择一条挂单"); - return; - } - Get.back(result: selected); - }, - ).flexible(flex: 1), + // 未选中挂单时禁用确定按钮,避免可点击但只弹提示的空操作。 + onPress: holdBillC.selectedId.value == null + ? null + : () { + final selected = holdBillC.holdBillList.firstWhereOrNull( + (v) => v.id == holdBillC.selectedId.value, + ); + if (selected == null) return; + Get.back(result: selected); + }, + ).flexible(flex: 1), + ), SizedBox(width: 10.w), FButton( style: FButtonStyle.outline, @@ -179,8 +226,21 @@ Future selectHoldBillDialog() async { } Widget _buildHoldBillSearch(BuildContext context) { + final theme = FTheme.of(context); final holdBillC = Get.find(); return [ + [ + FIcon( + FAssets.icons.shoppingCart, + size: 17.w, + color: theme.colorScheme.primary, + ), + SizedBox(width: 6.w), + Text( + '点击选择挂单,确定后恢复到购物车;挂单保留 7 天', + style: theme.typography.sm.copyWith(color: theme.colorScheme.mutedForeground), + ), + ].toRow(mainAxisSize: MainAxisSize.min), const Spacer(), Obx( () => appDatePicker( @@ -208,8 +268,41 @@ Widget _buildHoldBillList(BuildContext context) { final height = constraints.maxHeight; final hdHeight = (height - 60.h) / 5; - return Obx( - () => DataTable2( + return Obx(() { + if (holdBillC.holdBillList.isEmpty) { + return [ + FIcon( + FAssets.icons.shoppingCart, + size: 46.w, + color: theme.colorScheme.mutedForeground, + ), + SizedBox(height: 14.h), + Text( + '暂无挂单', + style: theme.typography.lg.copyWith( + color: theme.colorScheme.foreground, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 8.h), + Text( + '在收银页点击「更多 → 加入挂单」可暂存当前购物车', + style: theme.typography.sm.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + ] + .toColumn( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + ) + .expanded(); + } + return DataTable2( + // 挂单恢复是单选业务:默认表头全选框会逐行触发 onSelectChanged(true), + // 单选模型下 selectedId 被反复覆盖成最后一行,看起来就像“随机选中一行”, + // 因此直接隐藏复选框列,保留点击行选中的交互。 + showCheckboxColumn: false, columnSpacing: 12.w, horizontalMargin: 12.w, minWidth: 600.w, @@ -221,7 +314,7 @@ Widget _buildHoldBillList(BuildContext context) { DataColumn2( label: Text("购物车", textAlign: TextAlign.center), headingRowAlignment: MainAxisAlignment.center, - fixedWidth: hdWidth * 1.7, + fixedWidth: hdWidth * 1.5, ), DataColumn2( label: Text("价格", textAlign: TextAlign.center), @@ -231,7 +324,12 @@ Widget _buildHoldBillList(BuildContext context) { DataColumn2( label: Text("挂单时间", textAlign: TextAlign.center), headingRowAlignment: MainAxisAlignment.center, - fixedWidth: hdWidth, + fixedWidth: hdWidth * 0.9, + ), + DataColumn2( + label: Text("操作", textAlign: TextAlign.center), + headingRowAlignment: MainAxisAlignment.center, + fixedWidth: hdWidth * 0.8, ), ], rows: holdBillC.holdBillList.map((e) { @@ -247,7 +345,8 @@ Widget _buildHoldBillList(BuildContext context) { }, color: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.selected)) { - return theme.colorScheme.secondary.withAlpha(24); + // 没有复选框列后选中反馈只剩行底色,加深一档保证可辨识。 + return theme.colorScheme.secondary.withAlpha(48); } if (states.contains(WidgetState.hovered)) { return theme.colorScheme.secondary.withAlpha(12); @@ -356,11 +455,57 @@ Widget _buildHoldBillList(BuildContext context) { style: theme.typography.base, ).center(), ), + DataCell( + Obx(() { + // 删除是破坏性操作,先在行内展开“确认删除/取消”二次确认, + // 5 秒未确认自动还原,避免误触也避免再叠一层弹框。 + final confirming = holdBillC.deletingId.value == e.id; + if (!confirming) { + return FButton( + style: FButtonStyle.outline, + prefix: FIcon( + FAssets.icons.trash2, + size: theme.typography.base.fontSize, + ), + label: Text( + '删除', + style: theme.typography.sm.copyWith( + color: theme.colorScheme.error, + ), + ), + onPress: () => holdBillC.requestDelete(e), + ).height(48.h); + } + return [ + FButton( + style: FButtonStyle.destructive, + label: Text( + '确认删除', + style: theme.typography.sm.copyWith( + color: theme.colorScheme.secondary, + ), + ), + onPress: () => holdBillC.confirmDelete(e), + ).expanded(), + SizedBox(width: 6.w), + FButton( + style: FButtonStyle.outline, + label: Text( + '取消', + style: theme.typography.sm.copyWith( + color: theme.colorScheme.secondaryForeground, + ), + ), + onPress: () => holdBillC.cancelDelete(), + ).expanded(), + ].toRow().height(48.h); + }).center(), + ), ], ); }).toList(), - ), - ); + ); + }); }, ); } diff --git a/lib/pages/panel/controller.dart b/lib/pages/panel/controller.dart index 5cb1468..1b46b44 100644 --- a/lib/pages/panel/controller.dart +++ b/lib/pages/panel/controller.dart @@ -144,7 +144,10 @@ class PanelController extends GetxController { if (isNetwork) { await AppSettingTool.setUserSetting(UserSetting()); } - Get.offAndToNamed(RouteNames.login); + // 已在登录页时不再重复跳转,避免登录页自我替换换出空表单实例。 + if (Get.currentRoute != RouteNames.login) { + Get.offAndToNamed(RouteNames.login); + } break; case PanelType.test: final qrData = "?action=hardQrCodePay&orderId=123&mchId=123"; diff --git a/lib/pages/setting/controller.dart b/lib/pages/setting/controller.dart index a8c2047..6806597 100644 --- a/lib/pages/setting/controller.dart +++ b/lib/pages/setting/controller.dart @@ -85,6 +85,11 @@ class SettingController extends GetxController { title: "支付设置", type: SettingMenuType.paySetting, ), + SettingMenu( + prefixIcon: FAssets.icons.users, + title: "离线购买人员", + type: SettingMenuType.offlineUserSetting, + ), SettingMenu( prefixIcon: FAssets.icons.utensils, title: "餐段时间配置", diff --git a/lib/pages/setting/index.dart b/lib/pages/setting/index.dart index ea81a18..645b0dd 100644 --- a/lib/pages/setting/index.dart +++ b/lib/pages/setting/index.dart @@ -3,6 +3,7 @@ library; export './controller.dart'; export './face_manage_controller.dart'; export './face_setting_controller.dart'; +export './offline_user_controller.dart'; export './view.dart'; export './sync_logs/controller.dart'; export './sync_logs/view.dart'; diff --git a/lib/pages/setting/offline_user_controller.dart b/lib/pages/setting/offline_user_controller.dart new file mode 100644 index 0000000..8765516 --- /dev/null +++ b/lib/pages/setting/offline_user_controller.dart @@ -0,0 +1,183 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 设置页离线购买人员管理控制器。 +/// +/// 离线人员缓存(user_record 表)由线上支付识别人员时自动写入,也可手动同步 +/// 本部门人员;本控制器负责本机缓存列表的查询、分页、删除和手动同步。 +class OfflineUserController extends GetxController { + /// 应用级数据库持有者,由根 Binding 统一注册。 + final appC = Get.find(); + + /// 当前分页展示的离线人员缓存。 + RxList userList = RxList(); + + /// 当前列表页码,从 1 开始。 + RxInt currentPage = RxInt(1); + + /// 当前搜索条件命中的记录总数,供分页组件计算页数。 + RxInt totalPage = RxInt(0); + + /// 单页展示数量,与终端其他管理页保持一致。 + RxInt pageSize = RxInt(15); + + /// 列表查询是否进行中,仅驱动列表区域加载状态。 + RxBool isLoading = false.obs; + + /// 离线人员同步是否进行中,用于互斥按钮和禁用重复提交。 + RxBool isSyncing = false.obs; + + /// 最近一次列表查询失败的安全提示,不包含数据库细节。 + RxString errorMessage = ''.obs; + + /// 关键字搜索输入控制器,由本控制器负责释放;可输入姓名、人员编号、饭卡号或手机号。 + final keywordC = TextEditingController(); + + @override + void onInit() { + super.onInit(); + getUserList(); + } + + @override + void onClose() { + keywordC.dispose(); + super.onClose(); + } + + /// 按当前关键字和页码读取本机离线人员缓存。 + Future getUserList() async { + isLoading.value = true; + errorMessage.value = ''; + try { + final userDao = appC.dataBase.getUserDao; + var keyword = keywordC.text.trim(); + if (ObjectUtil.isEmpty(keyword)) keyword = "-1"; + final findCount = await userDao.findCount(keyword); + totalPage.value = findCount ?? 0; + final list = await userDao.findAll( + keyword, + pageSize.value, + (currentPage.value - 1) * pageSize.value, + ); + userList.value = list; + } catch (e, stack) { + errorMessage.value = '离线人员数据加载失败,请稍后重试'; + logger.e('|离线购买人员|加载失败error:$e|stack:$stack|'); + } finally { + isLoading.value = false; + } + } + + /// 按指定页码执行搜索,并在查询前收起软键盘。 + Future doPage({int? page}) async { + if (page != null) currentPage.value = page; + FocusManager.instance.primaryFocus?.unfocus(); + await getUserList(); + } + + /// 清空搜索条件、回到第一页并重新加载列表。 + Future doReset() async { + currentPage.value = 1; + keywordC.clear(); + FocusManager.instance.primaryFocus?.unfocus(); + await getUserList(); + } + + /// 删除一条离线人员缓存;删除后断网期间该人员无法再刷卡或刷脸消费。 + Future deleteUser(UserEntity user) async { + final isSure = await appMessageDialog( + Get.context!, + '是否删除“${user.nickname}”的离线购买缓存?删除后断网期间该人员将无法继续消费。', + ); + if (!isSure) return; + try { + await appC.dataBase.getUserDao.del(user); + Toast.success(msg: "删除成功"); + } catch (e, stack) { + Toast.error(msg: "删除离线人员失败"); + logger.e('|离线购买人员|删除失败error:$e|stack:$stack|'); + } + await _reloadAfterDelete(); + } + + /// 经确认后清空全部离线人员缓存;人员重新在线消费时会自动再次缓存。 + Future deleteAllUser() async { + if (totalPage.value == 0) return; + final isSure = await appMessageDialog( + Get.context!, + '是否清空全部 ${totalPage.value} 条离线购买人员缓存?清空后断网期间将无法离线消费,且不可撤销。', + ); + if (!isSure) return; + try { + await appC.dataBase.getUserDao.delAll(); + Toast.success(msg: "清空成功"); + } catch (e, stack) { + Toast.error(msg: "清空离线人员失败"); + logger.e('|离线购买人员|清空失败error:$e|stack:$stack|'); + } + await _reloadAfterDelete(); + } + + /// 删除后修正越界页码并刷新列表。 + Future _reloadAfterDelete() async { + final maxPage = (totalPage.value / pageSize.value).ceil(); + if (currentPage.value > 1 && currentPage.value > maxPage) { + currentPage.value = maxPage < 1 ? 1 : maxPage; + } + await getUserList(); + } + + /// 确认后同步本部门或全租户人员到本机离线缓存。 + /// + /// 全租户同步仅非生产调试可用,与人脸管理的调试全量同步保持一致;同步数据 + /// 由服务端脱敏(姓名仅保留首字,不含手机号头像),本地已有的完整消费缓存 + /// 不会被同步数据覆盖,仅刷新钱包快照字段。 + Future syncOfflineUsers({bool isAll = false}) async { + if (isSyncing.value) return; + final isNetwork = AppNetworkTool.isAutoNetworkMsg(); + if (!isNetwork) return; + final setting = await AppSettingTool.getSysSetting(); + if (ObjectUtil.isEmpty(setting.serviceId)) { + Toast.error(msg: "请先绑定设备"); + return; + } + if (isAll && AppSysTool.isProd()) { + Toast.error(msg: '生产环境仅允许同步本部门人员'); + return; + } + final isSure = await appMessageDialog( + Get.context!, + isAll ? '是否同步全租户人员?此能力仅用于非生产调试,人数可能较多' : '是否同步本部门及子部门人员?', + ); + if (!isSure) return; + isSyncing.value = true; + EasyLoading.show( + status: '同步中...', + maskType: EasyLoadingMaskType.black, + ); + try { + final summary = await SyncUserTool.syncOfflineUsers(isAll: isAll); + currentPage.value = 1; + await getUserList(); + Toast.success( + msg: '同步完成:新增 ${summary.added} 人,更新 ${summary.updated} 人', + ); + } catch (e, stack) { + final message = e + .toString() + .replaceFirst('Bad state: ', '') + .replaceFirst('Exception: ', '') + .trim(); + Toast.error(msg: message.isEmpty ? '同步离线人员失败,请稍后重试' : '同步失败:$message'); + logger.e('|离线购买人员|同步失败error:$e|stack:$stack|'); + } finally { + EasyLoading.dismiss(); + isSyncing.value = false; + } + } +} diff --git a/lib/pages/setting/view.dart b/lib/pages/setting/view.dart index 60b9bab..b2d9d74 100644 --- a/lib/pages/setting/view.dart +++ b/lib/pages/setting/view.dart @@ -108,6 +108,8 @@ class SettingPage extends GetView { return const SizedBox.shrink(); } return settingPay(context); + case SettingMenuType.offlineUserSetting: + return settingOfflineUser(context); case SettingMenuType.faceSetting: if (!isBand) { return const SizedBox.shrink(); diff --git a/lib/pages/setting/widgets/index.dart b/lib/pages/setting/widgets/index.dart index 3627140..9b5f4cf 100644 --- a/lib/pages/setting/widgets/index.dart +++ b/lib/pages/setting/widgets/index.dart @@ -6,6 +6,7 @@ export './setting_face.dart'; export './setting_face_plugin.dart'; export './setting_log_preview.dart'; export './setting_meal_periods.dart'; +export './setting_offline_user.dart'; export './setting_pay.dart'; export './setting_product.dart'; export './setting_sync.dart'; diff --git a/lib/pages/setting/widgets/setting_baseInfo.dart b/lib/pages/setting/widgets/setting_baseInfo.dart index 3cf4e25..ad5ae06 100644 --- a/lib/pages/setting/widgets/setting_baseInfo.dart +++ b/lib/pages/setting/widgets/setting_baseInfo.dart @@ -28,21 +28,91 @@ Future _refreshMqAfterBinding() async { /// 打开服务端设备绑定弹窗;输入框只回显本机当前值,不为调试包注入固定业务终端 ID。 Future _bindServiceDialog(BuildContext context, SysSetting sys) async { - final theme = FTheme.of(context); - // 调试包不得注入固定业务终端 ID,否则不同机器会共用同一套心跳、MQTT 和打印代理身份。 - final text = sys.serviceId ?? ""; - final uniqueCodeController = TextEditingController(text: text); await showAppDialog( context: context, - builder: (dialogContext) => AppDialog( + builder: (dialogContext) => _BindServiceDialog( + initialServiceId: sys.serviceId ?? "", + ), + ); +} + +/// 绑定唯一编号弹窗表面。 +class _BindServiceDialog extends StatefulWidget { + const _BindServiceDialog({required this.initialServiceId}); + + /// 打开弹窗时回显的本机终端编号,仅作输入初值。 + final String initialServiceId; + + @override + State<_BindServiceDialog> createState() => _BindServiceDialogState(); +} + +class _BindServiceDialogState extends State<_BindServiceDialog> { + late final TextEditingController _uniqueCodeController; + + /// 输入控制器必须随弹窗组件生命周期释放:showAppDialog 的 Future 在 pop 时 + /// 即完成,此时退出动画和键盘收起的重建仍会访问输入框,提前 dispose 会触发 + /// "used after being disposed" 断言。 + @override + void initState() { + super.initState(); + // 调试包不得注入固定业务终端 ID,否则不同机器会共用同一套心跳、MQTT 和打印代理身份。 + _uniqueCodeController = TextEditingController( + text: widget.initialServiceId, + ); + } + + @override + void dispose() { + _uniqueCodeController.dispose(); + super.dispose(); + } + + Future _submit() async { + final serverId = int.tryParse(_uniqueCodeController.text.trim()); + if (serverId == null) { + Toast.error(msg: '请输入正确的设备唯一编号'); + return; + } + if (!AppNetworkTool.isAutoNetworkMsg()) return; + EasyLoading.show( + status: '绑定中...', + maskType: EasyLoadingMaskType.black, + ); + try { + final success = await AppSettingTool.bindSys(serverId); + if (!success) { + Toast.error(msg: '绑定失败,请核对唯一编号'); + return; + } + final controller = Get.find(); + await controller.getSetting(); + Toast.success(msg: '绑定成功'); + unawaited(TerminalHeartbeatTool.report()); + // 先释放输入法焦点再关闭弹窗,避免键盘收起与路由退场互相重建。 + FocusManager.instance.primaryFocus?.unfocus(); + if (mounted) Navigator.of(context).pop(); + unawaited(_refreshMqAfterBinding()); + } catch (e, stack) { + logger.e('|绑定服务端设备信息error:|$e|stack:$stack|'); + Toast.error(msg: '绑定失败,请稍后重试'); + } finally { + EasyLoading.dismiss(); + } + } + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return AppDialog( title: Text('绑定唯一编号', style: theme.typography.xl2), width: context.width * 0.55, maxWidth: context.width * 0.55, - onClose: () => Navigator.of(dialogContext).pop(), + onClose: () => Navigator.of(context).pop(), semanticLabel: '绑定设备唯一编号弹框', content: FTextField( textInputAction: TextInputAction.none, - controller: uniqueCodeController, + controller: _uniqueCodeController, hint: '请输入设备唯一编号', maxLines: 1, ).width(double.infinity).marginSymmetric(vertical: 20.h), @@ -50,48 +120,18 @@ Future _bindServiceDialog(BuildContext context, SysSetting sys) async { FButton( style: FButtonStyle.outline, label: Text('取消', style: theme.typography.base), - onPress: () => Navigator.of(dialogContext).pop(), + onPress: () => Navigator.of(context).pop(), ), FButton( label: Text( '绑定', style: theme.typography.base.copyWith(color: Colors.white), ), - onPress: () async { - final serverId = int.tryParse(uniqueCodeController.text.trim()); - if (serverId == null) { - Toast.error(msg: '请输入正确的设备唯一编号'); - return; - } - if (!AppNetworkTool.isAutoNetworkMsg()) return; - EasyLoading.show( - status: '绑定中...', - maskType: EasyLoadingMaskType.black, - ); - try { - final success = await AppSettingTool.bindSys(serverId); - if (!success) { - Toast.error(msg: '绑定失败,请核对唯一编号'); - return; - } - final controller = Get.find(); - await controller.getSetting(); - Toast.success(msg: '绑定成功'); - unawaited(TerminalHeartbeatTool.report()); - if (dialogContext.mounted) Navigator.of(dialogContext).pop(); - unawaited(_refreshMqAfterBinding()); - } catch (e, stack) { - logger.e('|绑定服务端设备信息error:|$e|stack:$stack|'); - Toast.error(msg: '绑定失败,请稍后重试'); - } finally { - EasyLoading.dismiss(); - } - }, + onPress: _submit, ), ], - ), - ); - uniqueCodeController.dispose(); + ); + } } Widget settingBaseInfo(BuildContext context) { diff --git a/lib/pages/setting/widgets/setting_offline_user.dart b/lib/pages/setting/widgets/setting_offline_user.dart new file mode 100644 index 0000000..c56e406 --- /dev/null +++ b/lib/pages/setting/widgets/setting_offline_user.dart @@ -0,0 +1,328 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/pages/setting/offline_user_controller.dart'; + +import 'setting_components.dart'; + +/// 离线购买人员管理页。 +/// +/// 展示本机缓存的离线人员(在线消费识别成功后自动写入),供操作员查询和 +/// 清理缓存;删除只影响本机断网期间的饭卡、人脸离线消费能力。 +Widget settingOfflineUser(BuildContext context) { + final controller = Get.find(); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SettingNotice( + text: '人员在本机完成一次识别消费后自动缓存,也可同步本部门人员;断网期间可用饭卡或人脸继续消费,重新联网后自动恢复。', + icon: FAssets.icons.info, + ), + SizedBox(height: XfjDesignTokens.cardGap.h), + _buildSearch(context, controller), + SizedBox(height: XfjDesignTokens.cardGap.h), + Expanded(child: _buildList(context, controller)), + SizedBox(height: XfjDesignTokens.cardGap.h), + Obx( + () => appPage( + context, + pageNo: controller.currentPage.value, + pageSize: controller.pageSize.value, + total: controller.totalPage.value, + onPageChanged: (pageNo) => controller.doPage(page: pageNo), + ), + ), + ], + ); +} + +/// 关键字搜索与同步、清空操作区;全租户同步仅在非生产调试环境出现。 +Widget _buildSearch(BuildContext context, OfflineUserController controller) { + return Obx( + () { + final busy = controller.isLoading.value || controller.isSyncing.value; + return [ + TerminalToolbarTextField( + width: 300.w, + controller: controller.keywordC, + hint: '姓名/编号/卡号/手机号', + onSubmit: (_) => controller.doPage(page: 1), + ), + TerminalToolbarButton( + label: '重置', + icon: FAssets.icons.ban, + style: TerminalToolbarButtonStyle.secondary, + onTap: busy ? null : controller.doReset, + ), + TerminalToolbarButton( + label: '搜索', + icon: FAssets.icons.search, + onTap: busy ? null : () => controller.doPage(page: 1), + ), + const Spacer(), + TerminalToolbarButton( + label: controller.isSyncing.value ? '同步中...' : '同步本部门人员', + icon: FAssets.icons.userRoundPlus, + style: TerminalToolbarButtonStyle.secondary, + minWidth: 188.w, + onTap: busy ? null : () => controller.syncOfflineUsers(isAll: false), + ), + if (!AppSysTool.isProd()) + TerminalToolbarButton( + label: '同步全部人员', + icon: FAssets.icons.usersRound, + style: TerminalToolbarButtonStyle.secondary, + minWidth: 176.w, + onTap: busy ? null : () => controller.syncOfflineUsers(isAll: true), + ), + TerminalToolbarButton( + label: '清空全部人员', + icon: FAssets.icons.trash2, + style: TerminalToolbarButtonStyle.destructive, + minWidth: 176.w, + onTap: busy || controller.totalPage.value == 0 + ? null + : controller.deleteAllUser, + ), + ].toRowSpace(space: 12.w); + }, + ); +} + +/// 表头列描述;列宽权重与数据行保持一致。 +const List<(String label, int flex)> _offlineUserColumns = [ + ('姓名', 3), + ('人员编号', 3), + ('饭卡号', 3), + ('钱包余额快照', 2), + ('最近同步时间', 3), + ('操作', 2), +]; + +/// 构建表头行。 +Widget _buildHeader(BuildContext context) { + final theme = FTheme.of(context); + return Container( + height: 48.h, + padding: EdgeInsets.symmetric(horizontal: 16.w), + decoration: BoxDecoration( + color: XfjDesignTokens.control, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Row( + children: _offlineUserColumns + .map( + (col) => Expanded( + flex: col.$2, + child: Text( + col.$1, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ), + ), + ), + ) + .toList(growable: false), + ), + ); +} + +/// 构建支持加载、失败、空数据和分页内容的离线人员列表。 +Widget _buildList(BuildContext context, OfflineUserController controller) { + return Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + if (controller.errorMessage.isNotEmpty) { + return SettingEmpty(message: controller.errorMessage.value); + } + if (controller.userList.isEmpty) { + return const SettingEmpty(message: '暂无离线购买人员缓存'); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHeader(context), + SizedBox(height: XfjDesignTokens.cardGap.h), + Expanded( + child: ListView.separated( + itemCount: controller.userList.length, + separatorBuilder: (_, __) => + SizedBox(height: XfjDesignTokens.cardGap.h), + itemBuilder: (BuildContext context, int index) { + final user = controller.userList[index]; + return _OfflineUserRow( + user: user, + onPressed: controller.isLoading.value || controller.isSyncing.value + ? null + : () => controller.deleteUser(user), + ); + }, + ), + ), + ], + ); + }); +} + +/// 单条离线人员数据行。 +class _OfflineUserRow extends StatelessWidget { + const _OfflineUserRow({required this.user, required this.onPressed}); + + final UserEntity user; + + /// 删除按钮回调;为空时按钮禁用。 + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final cellStyle = theme.typography.sm.copyWith( + color: XfjDesignTokens.ink, + fontFeatures: const [FontFeature.tabularFigures()], + ); + return Container( + constraints: BoxConstraints(minHeight: 64.h), + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + ), + child: Row( + children: [ + Expanded( + flex: 3, + child: Text( + user.nickname, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: cellStyle.copyWith(fontWeight: FontWeight.w700), + ), + ), + Expanded( + flex: 3, + child: Text( + _orDash(user.userId), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: cellStyle, + ), + ), + Expanded( + flex: 3, + child: Text( + _orDash(user.walletCardNumber), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: cellStyle, + ), + ), + Expanded( + flex: 2, + child: Text( + user.walletBalance == null ? '-' : AppPriceTool.fenToYuan(user.walletBalance), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: cellStyle, + ), + ), + Expanded( + flex: 3, + child: Text( + _formatSyncTime(user.updateTime ?? user.createTime), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: cellStyle.copyWith(color: XfjDesignTokens.muted), + ), + ), + Expanded( + flex: 2, + child: Align( + alignment: Alignment.centerRight, + child: _CompactDeleteButton(onPressed: onPressed), + ), + ), + ], + ), + ); + } +} + +/// 行内紧凑删除按钮;48dp 触控高度内收敛为 32dp 视觉高度。 +class _CompactDeleteButton extends StatelessWidget { + const _CompactDeleteButton({required this.onPressed}); + + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final enabled = onPressed != null; + final borderRadius = BorderRadius.circular(XfjDesignTokens.controlRadius); + return Semantics( + button: true, + enabled: enabled, + label: '删除离线人员', + onTap: onPressed, + child: ExcludeSemantics( + child: SizedBox( + width: 96, + height: 48, + child: Center( + child: SizedBox( + width: 96, + height: 32, + child: Material( + color: enabled + ? XfjDesignTokens.danger + : XfjDesignTokens.danger.withValues(alpha: 0.42), + borderRadius: borderRadius, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onPressed, + borderRadius: borderRadius, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FIcon( + FAssets.icons.trash2, + color: Colors.white, + size: 14.sp, + ), + SizedBox(width: 4.w), + Text( + '删除', + style: theme.typography.xs.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +String _orDash(String? value) { + final text = value?.trim() ?? ''; + return text.isEmpty ? '-' : text; +} + +String _formatSyncTime(DateTime? time) { + if (time == null) return '-'; + return DateUtil.formatDate(time, format: DateFormats.full); +} diff --git a/lib/pages/supermarket_order/controller.dart b/lib/pages/supermarket_order/controller.dart index 1dbf666..619234b 100644 --- a/lib/pages/supermarket_order/controller.dart +++ b/lib/pages/supermarket_order/controller.dart @@ -91,7 +91,26 @@ class SupermarketOrderController extends GetxController with OrderMixin { final snapshot = _createCheckoutSnapshot(); // 创建本地订单 await createSqlOrder(); - await selectPayTypeDialog(snapshot: snapshot); + await selectPayTypeDialog( + snapshot: snapshot, + onQuantityChanged: _changeCheckoutQuantity, + ); + } + + /// 在结算弹框中按商品编码修改真实购物车数量,并返回包含最新金额的展示快照。 + /// + /// 弹框只负责触发操作,购物车和副屏仍由当前页面 Controller 统一维护; + /// 称重商品在弹框内不显示步进器,这里只会收到普通商品的调整。 + Future _changeCheckoutQuantity( + String productServiceUniqueCode, + int quantity, + ) async { + final exists = shopCardList.any( + (item) => item.productServiceUniqueCode == productServiceUniqueCode, + ); + if (!exists) return _createCheckoutSnapshot(); + doComputedInputChange(quantity, productServiceUniqueCode); + return _createCheckoutSnapshot(); } /// 在打开支付弹框前冻结当前商品、数量和整数分金额,避免背景状态影响本次结算展示。 diff --git a/lib/pages/supermarket_order/view.dart b/lib/pages/supermarket_order/view.dart index 7a637a7..a722539 100644 --- a/lib/pages/supermarket_order/view.dart +++ b/lib/pages/supermarket_order/view.dart @@ -120,7 +120,11 @@ class SupermarketOrderPage extends GetView { .offstage(offstage: ObjectUtil.isEmpty(title)), productList .map( - (v) => orderSupermarketSpuCard(context, supermarketSpu: v) + (v) => orderSupermarketSpuCard( + context, + supermarketSpu: v, + searchKeyword: controller.barCode, + ) .width(productSize.width) .height(productSize.height) .onTap(() => controller.doProduct(v)), diff --git a/lib/pages/terminal/view.dart b/lib/pages/terminal/view.dart index f588149..daeeaa3 100644 --- a/lib/pages/terminal/view.dart +++ b/lib/pages/terminal/view.dart @@ -19,6 +19,7 @@ import 'package:pj_supermarket_cloud/pages/order_history_detail/view.dart'; import 'package:pj_supermarket_cloud/pages/setting/controller.dart'; import 'package:pj_supermarket_cloud/pages/setting/face_manage_controller.dart'; import 'package:pj_supermarket_cloud/pages/setting/face_setting_controller.dart'; +import 'package:pj_supermarket_cloud/pages/setting/offline_user_controller.dart'; import 'package:pj_supermarket_cloud/pages/setting/sync_logs/controller.dart'; import 'package:pj_supermarket_cloud/pages/setting/sync_logs/view.dart'; import 'package:pj_supermarket_cloud/pages/setting/view.dart'; @@ -329,6 +330,7 @@ final Map _terminalRoutes = { Get.lazyPut(() => SettingController(), fenix: true); Get.lazyPut(() => FaceManageController(), fenix: true); Get.lazyPut(() => FaceSettingController(), fenix: true); + Get.lazyPut(() => OfflineUserController(), fenix: true); }), ), RouteNames.settingSyncLogs: _TerminalRouteConfig( diff --git a/test/mq_supermarket_product_msg_test.dart b/test/mq_supermarket_product_msg_test.dart new file mode 100644 index 0000000..d5488ef --- /dev/null +++ b/test/mq_supermarket_product_msg_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; + +void main() { + test('超市商品变更消息解析数字编号数组', () { + final msg = MqttSupermarketProductMsg.fromJson({ + "action": "SUPERMARKET_PRODUCT", + "spuIds": [1234567890123456789, 2097582805528469506], + }); + expect(msg.action, MqtActionEnum.SUPERMARKET_PRODUCT); + expect(msg.spuIds, ["1234567890123456789", "2097582805528469506"]); + }); + + test('超市商品变更消息编号为字符串数组也能解析', () { + final msg = MqttSupermarketProductMsg.fromJson({ + "action": "SUPERMARKET_PRODUCT", + "spuIds": ["123", "456"], + }); + expect(msg.spuIds, ["123", "456"]); + }); + + test('无编号时为 null 回退常规同步', () { + final msg = MqttSupermarketProductMsg.fromJson({ + "action": "SUPERMARKET_PRODUCT", + }); + expect(msg.spuIds, isNull); + }); + + test('空编号数组视为无定向同步', () { + final msg = MqttSupermarketProductMsg.fromJson({ + "action": "SUPERMARKET_PRODUCT", + "spuIds": [], + }); + expect(msg.spuIds, isNull); + }); +} diff --git a/test/search_highlight_tool_test.dart b/test/search_highlight_tool_test.dart new file mode 100644 index 0000000..1c29d92 --- /dev/null +++ b/test/search_highlight_tool_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; + +String marked(String text, String kw) { + final ranges = SearchHighlightTool.matchRanges(text, kw); + final buf = StringBuffer(); + int cursor = 0; + final runes = text.runes.toList(); + for (final r in ranges) { + if (r.start < cursor) continue; + if (r.start > cursor) { + buf.write(String.fromCharCodes(runes.sublist(cursor, r.start))); + } + buf.write('[${String.fromCharCodes(runes.sublist(r.start, r.end))}]'); + cursor = r.end; + } + if (cursor < runes.length) buf.write(String.fromCharCodes(runes.sublist(cursor))); + return buf.toString(); +} + +void main() { + test('商品名直接命中', () { + expect(marked('康师傅红烧方便面', '方便'), '康师傅红烧[方便]面'); + }); + + test('多次出现全部高亮', () { + expect(marked('可口可乐可乐瓶装', '可乐'), '可口[可乐][可乐]瓶装'); + }); + + test('大小写不敏感', () { + expect(marked('CocaCola 500ml', 'cola'), 'Coca[Cola] 500ml'); + }); + + test('条码直接命中', () { + expect(marked('6901234567892', '1234'), '690[1234]567892'); + }); + + test('拼音首字母头部命中', () { + expect(marked('康师傅红烧方便面', 'ksf'), '[康师傅]红烧方便面'); + }); + + test('拼音首字母中部命中', () { + expect(marked('康师傅红烧方便面', 'fhs'), '康师[傅红烧]方便面'); + }); + + test('拼音首字母尾部命中', () { + expect(marked('康师傅红烧方便面', 'fbm'), '康师傅红烧[方便面]'); + }); + + test('汉字与字母混合拼音命中', () { + expect(marked('农夫山泉550ml', 'nfsq'), '[农夫山泉]550ml'); + }); + + test('无命中不高亮', () { + expect(marked('康师傅红烧方便面', 'zzz'), '康师傅红烧方便面'); + }); + + test('空关键字不高亮', () { + expect(marked('康师傅红烧方便面', ''), '康师傅红烧方便面'); + expect(marked('康师傅红烧方便面', ' '), '康师傅红烧方便面'); + }); +} diff --git a/test/terminal_toolbar_test.dart b/test/terminal_toolbar_test.dart new file mode 100644 index 0000000..6982390 --- /dev/null +++ b/test/terminal_toolbar_test.dart @@ -0,0 +1,84 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 复刻 app.dart 的最小主题环境:ScreenUtil 按设计稿 1920x1080 初始化, +/// Forui 主题按字体档位缩放,与真机设置页的工具栏渲染条件一致。 +Widget _harness({required double sizeScalar}) { + final base = FThemes.zinc.light; + return ScreenUtilInit( + designSize: const Size(1920, 1080), + minTextAdapt: true, + builder: (_, __) => FTheme( + data: FThemeData.inherit( + colorScheme: base.colorScheme, + typography: base.typography.scale(sizeScalar: sizeScalar), + style: base.style, + ), + child: MaterialApp( + home: Scaffold( + body: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + TerminalToolbarTextField( + controller: TextEditingController(), + hint: '姓名/编号/卡号/手机号', + width: 300, + ), + TerminalToolbarButton(label: '重置', onTap: () {}), + TerminalToolbarButton( + label: '搜索', + style: TerminalToolbarButtonStyle.primary, + onTap: () {}, + ), + ], + ), + ), + ), + ), + ), + ); +} + +void main() { + tearDown(() { + XfjDesignTokens.applyThemeMetrics( + cardRadius: 12, + controlRadius: 12, + buttonSizeScale: 1, + ); + }); + + testWidgets('工具栏输入框在全部字体与按钮档位下与按钮等高且无溢出', (tester) async { + for (final fontSize in SettingGlobalFontSize.values) { + for (final buttonSize in SettingButtonSize.values) { + XfjDesignTokens.applyThemeMetrics( + cardRadius: 12, + controlRadius: 12, + buttonSizeScale: buttonSize.scale, + ); + await tester.pumpWidget(_harness(sizeScalar: fontSize.fontSize)); + await tester.pump(); + final combo = '字体${fontSize.title}+按钮${buttonSize.title}'; + expect( + tester.takeException(), + isNull, + reason: '$combo 组合出现布局溢出', + ); + final inputHeight = tester.getSize(find.byType(FTextField)).height; + final buttonHeight = tester + .getSize(find.byType(TerminalToolbarButton).first) + .height; + expect( + inputHeight, + buttonHeight, + reason: '$combo 组合输入框与按钮高度不一致', + ); + } + } + }); +}