This commit is contained in:
dzc committed 2026-09-10 13:51:51 +08:00
1 parent 2e0036af45
commit c75b441dea
37 files changed
+1894 -271

No files matched your search

+35
View File
@@ -127,6 +127,41 @@ abstract class HardApi {
return ServiceUser.fromJson(res.data);
}
/// 按当前设备绑定部门树(或全租户)拉取脱敏后的离线人员固定上界游标快照。
///
/// 部门范围、钱包类型全部由 DEVICE-ID 解析;姓名脱敏且不含手机号头像。
/// [allTenant] 仅非生产调试使用,生产环境服务端会强制收敛为部门范围。
/// 业务失败必须抛出,由调用方在同步中断时保留已写入的本地数据。
static Future<ServiceOfflineUserSyncSnapshot> 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<String, dynamic>.from(res.data as Map),
);
}
/// 仅按当前终端和识别身份查询本人预约,调用方不得补充商户或其他用户参数。
static Future<ServiceReservationMenuQuery?> queryReservationMenu(
ReservationMenuQueryRequest request,
+102
View File
@@ -1414,3 +1414,105 @@ class ServiceReservationComboItem {
/// 将套餐组成转换为可传输 JSON。
Map<String, dynamic> 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<String, dynamic> json) =>
_$ServiceOfflineUserRecordFromJson(json);
/// 将离线人员记录转换为 JSON,主要供模型测试使用。
Map<String, dynamic> toJson() => _$ServiceOfflineUserRecordToJson(this);
}
/// 终端离线人员同步的固定上界游标页面。
@JsonSerializable(includeIfNull: false)
class ServiceOfflineUserSyncSnapshot {
/// 创建一个固定上界游标页面。
const ServiceOfflineUserSyncSnapshot({
required this.records,
required this.nextAfterId,
required this.hasMore,
required this.snapshotMaxId,
});
/// 当前页面记录,服务端保证按用户主键严格升序返回。
final List<ServiceOfflineUserRecord> 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<String, dynamic> json,
) => _$ServiceOfflineUserSyncSnapshotFromJson(json);
/// 将快照页面转换为 JSON,主要供模型测试使用。
Map<String, dynamic> toJson() => _$ServiceOfflineUserSyncSnapshotToJson(this);
}
@@ -950,3 +950,65 @@ Map<String, dynamic> _$ServiceReservationComboItemToJson(
'sortOrder': instance.sortOrder,
'price': instance.price,
};
ServiceOfflineUserRecord _$ServiceOfflineUserRecordFromJson(
Map<String, dynamic> 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<String, dynamic> _$ServiceOfflineUserRecordToJson(
ServiceOfflineUserRecord instance) {
final val = <String, dynamic>{};
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<String, dynamic> json) =>
ServiceOfflineUserSyncSnapshot(
records: (json['records'] as List<dynamic>)
.map((e) =>
ServiceOfflineUserRecord.fromJson(e as Map<String, dynamic>))
.toList(),
nextAfterId: JsonTool.anyToRequiredString(json['nextAfterId']),
hasMore: json['hasMore'] as bool,
snapshotMaxId: JsonTool.anyToRequiredString(json['snapshotMaxId']),
);
Map<String, dynamic> _$ServiceOfflineUserSyncSnapshotToJson(
ServiceOfflineUserSyncSnapshot instance) {
final val = <String, dynamic>{
'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;
}
@@ -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<String, dynamic> json) =>
@@ -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<String, dynamic> _$ServiceSupermarketSpuToJson(
@@ -93,6 +94,7 @@ Map<String, dynamic> _$ServiceSupermarketSpuToJson(
'retailPrice': instance.retailPrice,
'promotionPrice': instance.promotionPrice,
'salesCount': instance.salesCount,
'delFlag': instance.delFlag,
};
ServiceSupermarketSpuPage _$ServiceSupermarketSpuPageFromJson(
+33
View File
@@ -112,6 +112,39 @@ class MqttFaceChangeMsg {
Map<String, dynamic> toJson() => _$MqttFaceChangeMsgToJson(this);
}
/// SUPERMARKET_PRODUCT 动作专用消息,携带发生变化的商品标识供终端定向同步。
@JsonSerializable()
class MqttSupermarketProductMsg {
/// MQTT 动作,协议值固定为 SUPERMARKET_PRODUCT。
final MqtActionEnum? action;
/// 服务端发生变更的商品 SPU 编号;缺省时终端回退为常规增量同步。
@JsonKey(fromJson: _spuIdsFromJson, toJson: _spuIdsToJson)
final List<String>? spuIds;
/// 创建一条超市商品变更通知。
const MqttSupermarketProductMsg({
this.action,
this.spuIds,
});
/// 从 MQTT JSON 创建专用消息。
factory MqttSupermarketProductMsg.fromJson(Map<String, dynamic> json) =>
_$MqttSupermarketProductMsgFromJson(json);
/// 将专用消息转换为 JSON,主要供协议测试使用。
Map<String, dynamic> toJson() => _$MqttSupermarketProductMsgToJson(this);
/// 服务端下发的是数字编号数组,统一转成字符串再与本地口径比较。
static List<String>? _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<String>? value) => value;
}
@JsonSerializable()
class MqttTakeFoodBroadcastMsg {
/// 发送硬件的topic
+15
View File
@@ -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<String, dynamic> json) =>
@@ -72,6 +73,20 @@ Map<String, dynamic> _$MqttFaceChangeMsgToJson(MqttFaceChangeMsg instance) =>
'isGlobalFace': instance.isGlobalFace,
};
MqttSupermarketProductMsg _$MqttSupermarketProductMsgFromJson(
Map<String, dynamic> json) =>
MqttSupermarketProductMsg(
action: $enumDecodeNullable(_$MqtActionEnumEnumMap, json['action']),
spuIds: MqttSupermarketProductMsg._spuIdsFromJson(json['spuIds']),
);
Map<String, dynamic> _$MqttSupermarketProductMsgToJson(
MqttSupermarketProductMsg instance) =>
<String, dynamic>{
'action': _$MqtActionEnumEnumMap[instance.action],
'spuIds': MqttSupermarketProductMsg._spuIdsToJson(instance.spuIds),
};
MqttTakeFoodBroadcastMsg _$MqttTakeFoodBroadcastMsgFromJson(
Map<String, dynamic> json) =>
MqttTakeFoodBroadcastMsg(
@@ -13,6 +13,7 @@ enum SettingMenuType implements BaseEnum {
versionSetting("70", "版本管理"),
facePluginSetting("80", "人脸设置"),
mealPeriodSetting("90", "餐段时间配置"),
offlineUserSetting("100", "离线购买人员"),
;
@override
@@ -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 || '%') )
+42 -2
View File
@@ -25,15 +25,55 @@ abstract class UserDao {
@Query('''
SELECT * FROM user_record
WHERE
WHERE
userId = :userId
''')
Future<UserEntity?> findByUserId(String userId);
@Query('''
SELECT * FROM user_record
WHERE
WHERE
walletCardNumber = :walletCardNumber
''')
Future<UserEntity?> findByCardNumber(String walletCardNumber);
@Query('''
DELETE FROM user_record WHERE id = :id
''')
Future<void> delById(int id);
@Query('''
DELETE FROM user_record
''')
Future<void> 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<int?> 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<List<UserEntity>> findAll(
String keyword,
int pageSize,
int offset,
);
}
+35 -4
View File
@@ -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<String, Object?> 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<String, Object?> 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<UserEntity?> findByUserId(String userId) async {
return _queryAdapter.query(
'SELECT * FROM user_record WHERE userId = ?1',
'SELECT * FROM user_record WHERE userId = ?1',
mapper: (Map<String, Object?> row) => UserEntity(
id: row['id'] as int?,
userId: row['userId'] as String,
@@ -1774,7 +1774,7 @@ class _$UserDao extends UserDao {
@override
Future<UserEntity?> findByCardNumber(String walletCardNumber) async {
return _queryAdapter.query(
'SELECT * FROM user_record WHERE walletCardNumber = ?1',
'SELECT * FROM user_record WHERE walletCardNumber = ?1',
mapper: (Map<String, Object?> row) => UserEntity(
id: row['id'] as int?,
userId: row['userId'] as String,
@@ -1797,6 +1797,37 @@ class _$UserDao extends UserDao {
arguments: [walletCardNumber]);
}
@override
Future<void> delById(int id) async {
await _queryAdapter.queryNoReturn('DELETE FROM user_record WHERE id = ?1',
arguments: [id]);
}
@override
Future<void> delAll() async {
await _queryAdapter.queryNoReturn('DELETE FROM user_record');
}
@override
Future<int?> 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<String, Object?> row) => row.values.first as int,
arguments: [keyword]);
}
@override
Future<List<UserEntity>> 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<String, Object?> 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<void> save(UserEntity entity) async {
await _userEntityInsertionAdapter.insert(entity, OnConflictStrategy.abort);
+17 -1
View File
@@ -91,7 +91,23 @@ class AppMqTool {
}
break;
case MqtActionEnum.SUPERMARKET_PRODUCT:
await SyncProductTool.syncProduct();
// 消息携带 spuIds 时只定向同步变化的商品,否则走常规增量同步。
Set<String>? 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:
+2
View File
@@ -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';
+5 -6
View File
@@ -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();
+5 -2
View File
@@ -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);
}
}
@@ -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 = <TextSpan>[];
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;
}
}
+249 -165
View File
@@ -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<void> 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<AppControl>();
final dataBase = app.dataBase;
final supermarketSpuDao = dataBase.getSupermarketSpuDao;
final supermarketSkuDao = dataBase.getSupermarketSkuDao;
final supermarketCodeDao = dataBase.getSupermarketCodeDao;
int currentPage = 1;
Set<String> 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<List<String>> _batchUpdateSupermarketProduct({
required List<ServiceSupermarketSpu> list,
/// 按服务端推送的编号定向同步超市商品。
///
/// MQTT SUPERMARKET_PRODUCT 消息携带 spuIds 时走此入口,
/// 只拉取变化的商品;接口未返回的编号视为服务端已删除。
static Future<bool> syncSupermarketProductByIds(Set<String> 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<bool> _syncSupermarketProduct({
required _SupermarketSyncMode mode,
Set<String>? onlySpuIds,
}) async {
final app = Get.find<AppControl>();
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<void> _batchAddOrUpdateSupermarketProduct({
required List<ServiceSupermarketSpu> list,
required bool isUpdate,
}) async {
if (ObjectUtil.isEmpty(list)) return;
final app = Get.find<AppControl>();
final dataBase = app.dataBase;
final productDao = dataBase.getSupermarketSpuDao;
List<SupermarketSpuEntity> 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 = <String>{};
final tombstoneCodes = <String>{};
final apiList = <ServiceSupermarketSpu>[];
int currentPage = 1;
while (true) {
final pa = <String, dynamic>{"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 = <String>{};
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 = <SupermarketSpuEntity>[];
final editList = <SupermarketSpuEntity>[];
final detailList = <ServiceSupermarketSpu>[];
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<String> 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<ServiceSupermarketSpu> _filterCaneenSupermarketByCodes(
List<ServiceSupermarketSpu> sourceList,
List<String> codes,
) {
return sourceList
.where((item) => codes.contains(item.id.toString()))
.toList();
/// 按固定大小切分列表。
static List<List<T>> _chunked<T>(List<T> source, int size) {
final result = <List<T>>[];
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<void> _batchAddOrUpdateSupermarketSpuAndCode(
// 重建指定商品的 sku 与辅助条码
static Future<void> _batchSaveSupermarketSkuAndCode(
List<ServiceSupermarketSpu> list,
) async {
if (list.isEmpty) return;
if (ObjectUtil.isEmpty(list)) return;
final app = Get.find<AppControl>();
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<SupermarketCodeEntity> codeList = [];
List<SupermarketSkuEntity> 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<SupermarketCodeEntity> codeList = [];
List<SupermarketSkuEntity> 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<String?> 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<Map<String, String>> _batchTransitionPy(
List<String> 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,
}
+112
View File
@@ -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<UserEntity?> storageUserByCarNumber(String cardNumber) async {
final app = Get.find<AppControl>();
@@ -82,4 +107,91 @@ abstract class SyncUserTool {
}
return userEntity;
}
/// 按设备绑定部门树(或全租户)同步脱敏后的离线人员到本机缓存。
///
/// [isAll] 只允许非生产调试使用;生产调用即使误传也会被服务端收敛为本部门。
/// 服务端返回的记录姓名已脱敏且不含手机号头像;本地已有的完整消费缓存
/// (真实姓名、手机号)优先保留,同步只补齐缺失人员和刷新钱包快照字段。
/// 分页期间快照上界变化会立即中断,已写入的本地数据保持有效。
static Future<OfflineUserSyncSummary> syncOfflineUsers({bool isAll = false}) async {
if (!AppNetworkTool.isAutoNetwork()) {
throw StateError('网络不可用,无法同步离线人员');
}
final app = Get.find<AppControl>();
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;
}
}
+4
View File
@@ -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';
+2 -2
View File
@@ -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,
);
+31 -14
View File
@@ -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(
@@ -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: <Widget>[
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),
<Widget>[
Text("条码:", style: textStyle),
Text(
SearchHighlightTool.richText(
supermarketSpu.barCode ?? "",
style: textStyle,
keyword: searchKeyword,
baseStyle: textStyle,
),
].toRow(mainAxisAlignment: MainAxisAlignment.center),
]
@@ -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<OrderHoldBillEntity> holdBillList = RxList();
Rxn<int> selectedId = Rxn<int>(null);
/// 当前处于行内删除确认的挂单 id;为空表示没有删除确认进行中。
Rxn<int> deletingId = Rxn<int>(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<void> doDateRangeDate(List<DateTime>? 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<void> 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<OrderHoldBillEntity?> selectHoldBillDialog() async {
@@ -146,25 +192,26 @@ Future<OrderHoldBillEntity?> selectHoldBillDialog() async {
].toColumn().width(double.infinity).height(dialogHeight),
actions: [
<Widget>[
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<SelectHoldBillController>();
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<OrderHoldBillEntity?> selectHoldBillDialog() async {
}
Widget _buildHoldBillSearch(BuildContext context) {
final theme = FTheme.of(context);
final holdBillC = Get.find<SelectHoldBillController>();
return <Widget>[
<Widget>[
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 <Widget>[
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<Color?>((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 <Widget>[
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(),
),
);
);
});
},
);
}
+4 -1
View File
@@ -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";
+5
View File
@@ -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: "餐段时间配置",
+1
View File
@@ -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';
@@ -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<AppControl>();
/// 当前分页展示的离线人员缓存。
RxList<UserEntity> 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<void> 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<void> doPage({int? page}) async {
if (page != null) currentPage.value = page;
FocusManager.instance.primaryFocus?.unfocus();
await getUserList();
}
/// 清空搜索条件、回到第一页并重新加载列表。
Future<void> doReset() async {
currentPage.value = 1;
keywordC.clear();
FocusManager.instance.primaryFocus?.unfocus();
await getUserList();
}
/// 删除一条离线人员缓存;删除后断网期间该人员无法再刷卡或刷脸消费。
Future<void> 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<void> 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<void> _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<void> 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;
}
}
}
+2
View File
@@ -108,6 +108,8 @@ class SettingPage extends GetView<SettingController> {
return const SizedBox.shrink();
}
return settingPay(context);
case SettingMenuType.offlineUserSetting:
return settingOfflineUser(context);
case SettingMenuType.faceSetting:
if (!isBand) {
return const SizedBox.shrink();
+1
View File
@@ -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';
+81 -41
View File
@@ -28,21 +28,91 @@ Future<void> _refreshMqAfterBinding() async {
/// 打开服务端设备绑定弹窗;输入框只回显本机当前值,不为调试包注入固定业务终端 ID。
Future<void> _bindServiceDialog(BuildContext context, SysSetting sys) async {
final theme = FTheme.of(context);
// 调试包不得注入固定业务终端 ID,否则不同机器会共用同一套心跳、MQTT 和打印代理身份。
final text = sys.serviceId ?? "";
final uniqueCodeController = TextEditingController(text: text);
await showAppDialog<void>(
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<void> _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<SettingController>();
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<void> _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<SettingController>();
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) {
@@ -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<OfflineUserController>();
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 <Widget>[
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);
}
+20 -1
View File
@@ -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<CashierCheckoutSnapshot?> _changeCheckoutQuantity(
String productServiceUniqueCode,
int quantity,
) async {
final exists = shopCardList.any(
(item) => item.productServiceUniqueCode == productServiceUniqueCode,
);
if (!exists) return _createCheckoutSnapshot();
doComputedInputChange(quantity, productServiceUniqueCode);
return _createCheckoutSnapshot();
}
/// 在打开支付弹框前冻结当前商品、数量和整数分金额,避免背景状态影响本次结算展示。
+5 -1
View File
@@ -120,7 +120,11 @@ class SupermarketOrderPage extends GetView<SupermarketOrderController> {
.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)),
+2
View File
@@ -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<String, _TerminalRouteConfig> _terminalRoutes = {
Get.lazyPut(() => SettingController(), fenix: true);
Get.lazyPut(() => FaceManageController(), fenix: true);
Get.lazyPut(() => FaceSettingController(), fenix: true);
Get.lazyPut(() => OfflineUserController(), fenix: true);
}),
),
RouteNames.settingSyncLogs: _TerminalRouteConfig(
+36
View File
@@ -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": <dynamic>[],
});
expect(msg.spuIds, isNull);
});
}
+62
View File
@@ -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('康师傅红烧方便面', ' '), '康师傅红烧方便面');
});
}
+84
View File
@@ -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 组合输入框与按钮高度不一致',
);
}
}
});
}