11
This commit is contained in:
1 parent
2e0036af45
commit
c75b441dea
37 files changed
+1894
-271
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || '%') )
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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: "餐段时间配置",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/// 在打开支付弹框前冻结当前商品、数量和整数分金额,避免背景状态影响本次结算展示。
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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('康师傅红烧方便面', ' '), '康师傅红烧方便面');
|
||||
});
|
||||
}
|
||||
@@ -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 组合输入框与按钮高度不一致',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user