This commit is contained in:
dzc committed 2026-09-11 00:22:34 +08:00
1 parent c4806fbaf2
commit d1cb2273d6
17 files changed
+450 -238

No files matched your search

+1 -1
View File
@@ -7,7 +7,7 @@ flutter pub run build_runner build --delete-conflicting-outputs~
## flutter打包
```
flutter build apk --build-name 2.0.3 --build-number 203
flutter build apk --build-name 2.1.2 --build-number 212
--build-name为版本号(展示)
--build-number为版本号(内部比较)
```
@@ -178,6 +178,38 @@ object KioskModeManager {
return provisionResult(false, code, message)
}
/**
* 由 Device Owner 应用自身解除设备所有者身份,使本应用恢复为普通应用、可正常卸载。
*
* 仅在用户主动触发后执行:先关闭自动进入并退出当前 Lock Task,再清除 Owner 身份。
* 清除成功后 Kiosk、开机自启能力随之失效,需重新 Root 配置才能恢复。
*/
fun clearDeviceOwner(activity: Activity): Map<String, Any> {
val policyManager = policyManager(activity)
if (!policyManager.isDeviceOwnerApp(activity.packageName)) {
return provisionResult(false, "NOT_OWNER", "当前应用不是 Device Owner,无需解除")
}
val autoEnterWasEnabled = isAutoEnterEnabled(activity)
preferences(activity).edit().putBoolean(AUTO_ENTER_ENABLED_KEY, false).commit()
exitCurrentKiosk(activity)
return try {
@Suppress("DEPRECATION")
policyManager.clearDeviceOwnerApp(activity.packageName)
if (policyManager.isDeviceOwnerApp(activity.packageName)) {
// 厂商固件可能静默拒绝清除;回滚自动进入配置并返回明确错误。
preferences(activity).edit()
.putBoolean(AUTO_ENTER_ENABLED_KEY, autoEnterWasEnabled).commit()
provisionResult(false, "OEM_REFUSED", "系统拒绝解除 Device Owner,请恢复出厂设置处理")
} else {
provisionResult(true, "OK", "Device Owner 已解除")
}
} catch (error: Exception) {
preferences(activity).edit()
.putBoolean(AUTO_ENTER_ENABLED_KEY, autoEnterWasEnabled).commit()
provisionResult(false, "CLEAR_FAILED", error.message ?: "解除 Device Owner 失败")
}
}
/** 窗口重新获得焦点时恢复 Kiosk 系统栏策略,防止临时系统界面留下可退出入口。 */
fun reapplySystemBars(activity: Activity) {
if (isKioskActive(activity)) hideSystemBars(activity)
@@ -146,6 +146,11 @@ class MainActivity : FlutterActivity() {
}
}
"exitKioskToHome" -> result.success(KioskModeManager.exitToHome(this))
"clearKioskDeviceOwner" -> result.success(
// 清除必须先在 UI 线程退出 Lock Task,操作本身是快速本地调用。
KioskModeManager.clearDeviceOwner(this) +
("status" to KioskModeManager.status(this))
)
"validateApk" -> {
val path = call.argument<String>("path")
val expectedVersionCode = call.argument<Number>("expectedVersionCode")?.toLong()
@@ -197,6 +197,7 @@ enum ProductHourType implements BaseEnum {
enum WalletType implements BaseEnum {
EAT("1", "饭卡钱包"),
PAY("2", "消费钱包"),
TOPUP("3", "充值钱包"),
;
@override
@@ -71,6 +71,25 @@ class FaceSyncSummary {
}
}
/// 人脸同步过程上报的最新进度快照,供手动同步界面展示进度条。
class FaceSyncProgress {
/// 创建进度快照;[total] 在清单分页获取阶段为空,比对处理阶段为已知总数。
const FaceSyncProgress({required this.processed, this.total});
/// 已获取的清单条数或已处理的记录数,含义随阶段变化但始终单调递增。
final int processed;
/// 本次需要处理的总记录数;清单仍在分页获取时尚不确定,为空。
final int? total;
/// 已处理记录占比;总数未知或非法时返回空,由调用方展示不定态样式。
double? get ratio {
final value = total;
if (value == null || value <= 0) return null;
return (processed / value).clamp(0.0, 1.0);
}
}
/// V3 快照不完整、游标倒退或样本标识冲突时抛出的整次同步异常。
class FaceSyncSnapshotException implements Exception {
/// 使用不包含图片地址和人员信息的安全摘要创建快照异常。
@@ -391,8 +391,20 @@ class FaceRecognitionSessionService extends GetxService {
}) async {
final sessionId = _activeSessionId;
final scene = _activeScene;
if (sessionId == null || scene == null || !AppSubScreenTool.isAvailable) {
return;
if (sessionId == null || scene == null) return;
if (!AppSubScreenTool.isAvailable) {
// isAvailable 启动默认 false,全工程只有每分钟的设备状态定时任务会把它
// 置 true;收银页进页直接调用插件 doubleScreenShow 并不经过本工具。若在
// 首次定时探测前发起人脸支付,本会话全部副屏消息会被这里静默丢弃,
// 副屏便一直停留在购物车列表。发送前按需补一次轻量探测自愈该盲区。
await AppSubScreenTool.refreshAvailability(reportStatus: false);
if (!AppSubScreenTool.isAvailable) {
logger.w(
'|人脸识别|副屏不可用,跳过副屏状态通知|sessionId:$sessionId|'
'action:${action.code}|',
);
return;
}
}
if (_lastViceAction == action && _lastViceMessage == message) return;
final revision = ++_revision;
@@ -462,6 +462,25 @@ abstract class AppRuntimeTool {
}
}
/// 用户确认后解除当前应用的 Device Owner 身份;解除后恢复为普通应用、可正常卸载。
static Future<AppKioskProvisionResult> clearKioskDeviceOwner() async {
try {
final data = await _channel.invokeMapMethod<dynamic, dynamic>(
'clearKioskDeviceOwner',
);
final cleared = AppKioskProvisionResult.fromMap(data);
_kioskStatus = cleared.status;
return cleared;
} on PlatformException catch (error) {
return AppKioskProvisionResult(
success: false,
code: error.code,
message: error.message ?? '解除 Device Owner 失败,请稍后重试',
status: _kioskStatus,
);
}
}
/// 执行带原生 5 秒超时的 `su -c id`,只有退出码为零且输出确认 uid=0 才返回可用。
static Future<RootAccessCheckResult> checkRootAccess() async {
try {
@@ -34,6 +34,9 @@ class SyncCheckTool {
/// 最近一次终端日志上传统计,供设置页展示扫描、跳过、成功和失败数量。
final terminalLogSummary = const TerminalLogUploadSummary().obs;
/// 人脸同步任务执行中的最新进度快照,任务结束后清空;仅驱动界面展示。
final departmentFaceSyncProgress = Rxn<FaceSyncProgress>();
/// 当前进程内 Cron 调度器;任务全部停止后置空并关闭其内部计时资源。
Cron? _cron;
@@ -728,6 +731,9 @@ class SyncCheckTool {
}
} finally {
if (heavy) SyncIdleTool.instance.endHeavyTask();
if (type == ScheduledTaskType.departmentFaceSync) {
departmentFaceSyncProgress.value = null;
}
runningTasks.remove(type);
final nextRequest = _pendingRequests[type];
if (nextRequest != null) {
@@ -835,6 +841,12 @@ class SyncCheckTool {
final summary = await SyncFaceTool.sync(
mode: mode,
userIds: request.faceUserIds,
onProgress: (processed, total) {
departmentFaceSyncProgress.value = FaceSyncProgress(
processed: processed,
total: total,
);
},
);
if (summary.changed > 0 &&
request.triggerType == ScheduledTaskTriggerType.mqtt &&
+2 -2
View File
@@ -25,9 +25,9 @@ class Constants {
static const pwdEncKey = 'pigxpigxpigxpigx';
/// 服务 url 线上
// static const apiUrl = 'https://www.pjzhhq.cn/api';
static const apiUrl = 'https://www.pjzhhq.cn/api';
static const apiUrl = 'https://dzc-test-1.fy.takin.cc';
// static const apiUrl = 'https://dzc-test-1.fy.takin.cc';
/// 静态资源 url
static const ossUrl = apiUrl;
@@ -5,6 +5,10 @@ import 'package:forui/forui.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
/// 商品卡片
///
/// 图片用 Expanded 弹性占满剩余高度,下方文字按自然高度排列,
/// 全局字号(typography.scale)变化时由图片伸缩吸收,不会再溢出。
/// 要求父级提供有界高度(网格 mainAxisExtent / 显式 .height)。
Widget orderCaneenSpuCard(
BuildContext context, {
String? image = "",
@@ -12,24 +16,30 @@ Widget orderCaneenSpuCard(
num price = 0.0,
}) {
final theme = FTheme.of(context);
return FCard(
style: theme.cardStyle.copyWith(
contentStyle: theme.cardStyle.contentStyle.copyWith(
padding: EdgeInsets.all(0),
return FCard.raw(
style: theme.cardStyle,
child: <Widget>[
Expanded(
child: ClipRRect(
borderRadius: theme.style.borderRadius,
child: appImg(image, width: double.infinity, height: double.infinity),
),
),
),
image: appImg(image, width: double.infinity, height: 150.h),
subtitle: <Widget>[
// 与 FCard 内置图文间距一致(常量 10,不缩放)
const SizedBox(height: 10),
Text(
name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: theme.typography.base.copyWith(height: 1),
),
SizedBox(height: 5.h),
appPrice(context, price),
// 底部余量,边框完整包住价格
SizedBox(height: 8.h),
]
.toColumn(crossAxisAlignment: CrossAxisAlignment.center)
.width(double.infinity),
).width(double.infinity);
);
}
@@ -2902,6 +2902,12 @@ class _PersonWalletSection extends StatelessWidget {
return wallets.first;
}
/// 按后端钱包类型枚举编码解析展示名;食堂内嵌钱包没有类型编码,按饭卡展示。
String _walletTypeName(String? walletTypeUniqueCode) {
if (walletTypeUniqueCode == null) return WalletType.EAT.title;
return WalletType.values.fromCode(walletTypeUniqueCode)?.title ?? '未知钱包';
}
/// 构建钱包摘要和账户列表,冻结及余额不足都使用文字与颜色双重提示。
@override
Widget build(BuildContext context) {
@@ -2928,6 +2934,7 @@ class _PersonWalletSection extends StatelessWidget {
? <Widget>[
_PersonWalletRow(
key: const ValueKey('recognized-user-embedded-wallet'),
typeName: _walletTypeName(null),
balance: user.walletBalance,
cardNumber: user.walletCardNumber,
frozen: user.walletIsFreeze,
@@ -2939,6 +2946,7 @@ class _PersonWalletSection extends StatelessWidget {
.map(
(wallet) => _PersonWalletRow(
key: ValueKey('recognized-user-wallet-${wallet.id}'),
typeName: _walletTypeName(wallet.walletTypeUniqueCode),
balance: wallet.balance,
cardNumber: wallet.cardNumber,
frozen: wallet.isFreeze,
@@ -2992,7 +3000,8 @@ class _PersonWalletSection extends StatelessWidget {
].toRow(crossAxisAlignment: CrossAxisAlignment.end),
SizedBox(height: 8.h),
Text(
'饭卡 ${_maskCardNumber(cardNumber)}',
'${_walletTypeName(activeWallet?.walletTypeUniqueCode)}'
' ${_maskCardNumber(cardNumber)}',
style: theme.typography.sm.copyWith(color: XfjDesignTokens.muted),
),
SizedBox(height: 12.h),
@@ -3031,6 +3040,7 @@ class _PersonWalletSection extends StatelessWidget {
class _PersonWalletRow extends StatelessWidget {
/// 创建一个只读或可切换的钱包账户行。
const _PersonWalletRow({
required this.typeName,
required this.balance,
required this.cardNumber,
required this.frozen,
@@ -3039,6 +3049,9 @@ class _PersonWalletRow extends StatelessWidget {
super.key,
});
/// 钱包类型名称,如饭卡钱包、消费钱包、充值钱包。
final String typeName;
/// 钱包余额,单位为分;为空表示接口未返回。
final int? balance;
@@ -3076,13 +3089,20 @@ class _PersonWalletRow extends StatelessWidget {
),
SizedBox(width: 9.w),
<Widget>[
Text(
_maskCardNumber(cardNumber),
style: theme.typography.sm.copyWith(
color: XfjDesignTokens.ink,
fontWeight: FontWeight.w700,
<Widget>[
Text(
typeName,
style: theme.typography.sm.copyWith(
color: XfjDesignTokens.ink,
fontWeight: FontWeight.w700,
),
),
),
SizedBox(width: 8.w),
Text(
_maskCardNumber(cardNumber),
style: theme.typography.xs.copyWith(color: XfjDesignTokens.muted),
),
].toRow(crossAxisAlignment: CrossAxisAlignment.center),
Text(
stateText,
style: theme.typography.xs.copyWith(color: stateColor),
+29 -32
View File
@@ -89,38 +89,35 @@ class CanteenProductPage extends GetView<CanteenProductController> {
}
Widget _buildSortProduct() {
return LayoutBuilder(
builder: (BuildContext _, BoxConstraints constraints) {
const spacing = 10;
const runSpacing = 10;
double maxHeight = constraints.maxHeight;
double maxWidth = constraints.maxWidth;
double itemWidth = (maxWidth - spacing * 6) / 7;
double itemHeight = (maxHeight - (runSpacing + 30) * 3) / 3;
return Obx(
() => GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 7,
mainAxisSpacing: 10.w,
crossAxisSpacing: 10.w,
childAspectRatio: 0.8,
),
itemCount: controller.canteenSpuList.length,
itemBuilder: (context, index) {
final product = controller.canteenSpuList[index];
return orderCaneenSpuCard(
context,
image: product.image,
name: product.name,
price: product.price,
)
.width(itemWidth)
.height(itemHeight)
.onTap(() => controller.doProduct(product));
},
),
);
},
final theme = FTheme.of(Get.context!);
// 卡片高度 = 图片 150.h + 图文间距 10 + 两行名称(base) + 间距 5.h
// + 价格行(xl) + 底部余量 8.h。字号受全局字号设置(typography.scale)
// 影响,必须运行时从主题读取,不能用固定常数。
final cardHeight = 150.h +
10 +
(theme.typography.base.fontSize ?? 16) * 2 +
5.h +
(theme.typography.xl.fontSize ?? 20) +
8.h;
return Obx(
() => GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 7,
mainAxisSpacing: 10.w,
crossAxisSpacing: 10.w,
mainAxisExtent: cardHeight,
),
itemCount: controller.canteenSpuList.length,
itemBuilder: (context, index) {
final product = controller.canteenSpuList[index];
return orderCaneenSpuCard(
context,
image: product.image,
name: product.name,
price: product.price,
).onTap(() => controller.doProduct(product));
},
),
);
}
+37
View File
@@ -28,6 +28,9 @@ class SettingController extends GetxController {
/// Root 一键配置 Device Owner 是否正在执行,命令期间禁止重复提交。
final kioskProvisioning = false.obs;
/// 解除 Device Owner 是否正在执行,防止连点重复触发清除。
final kioskClearing = false.obs;
/// Kiosk 开关是否正在同步原生 Lock Task,防止连续点击形成状态反转。
final kioskSwitching = false.obs;
@@ -653,6 +656,40 @@ class SettingController extends GetxController {
}
}
/// 经用户二次确认后解除 Device Owner;解除后本应用恢复为普通应用、可正常卸载。
Future<void> clearKioskDeviceOwner() async {
if (kioskClearing.value || !kioskStatus.value.deviceOwner) return;
final confirmed = await appMessageDialog(
Get.context!,
'解除后 Kiosk 锁定与开机自启立即失效,本应用恢复为普通应用,随后即可正常卸载。是否继续?',
);
if (!confirmed) return;
kioskClearing.value = true;
EasyLoading.show(
status: '正在解除设备所有者...',
maskType: EasyLoadingMaskType.black,
);
try {
final result = await AppRuntimeTool.clearKioskDeviceOwner();
kioskStatus.value = result.status;
if (result.success) {
kioskProvisionMessage.value = '';
Toast.success(msg: '已解除设备所有者,现在可以正常卸载本应用');
} else {
kioskProvisionMessage.value = result.message;
Toast.error(msg: result.message);
}
} catch (e, stack) {
logger.e('|$_tag|解除DeviceOwner失败error:$e|stack:$stack|');
kioskProvisionMessage.value = '解除失败,请稍后重试';
Toast.error(msg: kioskProvisionMessage.value);
} finally {
EasyLoading.dismiss();
kioskClearing.value = false;
}
}
/// 切换主控手动硬件输入;虚拟机强制开启,开启前需操作员确认。
Future<void> setManualHardwareInput(bool enabled) async {
if (AppRuntimeTool.isEmulator && !enabled) {
+39 -3
View File
@@ -160,6 +160,7 @@ class FaceManageController extends GetxController {
///
/// 队列会先避开支付、人脸会话和业务弹框;只有任务已经实际开始时才显示遮罩,
/// 避免遮罩本身被空闲检测识别为业务占用而导致任务无法启动。
/// 遮罩会跟随调度器上报的同步进度在转圈与百分比进度条之间切换。
Future<void> asyncFace({bool isAll = false}) async {
if (operationInProgress) return;
final isNetwork = AppNetworkTool.isAutoNetworkMsg();
@@ -194,11 +195,26 @@ class FaceManageController extends GetxController {
);
await Future<void>.delayed(Duration.zero);
var overlayVisible = false;
Worker? progressWorker;
if (scheduler.runningTasks.contains(ScheduledTaskType.departmentFaceSync)) {
overlayVisible = true;
EasyLoading.show(
status: '同步中...',
maskType: EasyLoadingMaskType.black,
_showFaceSyncProgress(null);
var lastPaintAt = DateTime.now();
progressWorker = ever<FaceSyncProgress?>(
scheduler.departmentFaceSyncProgress,
(progress) {
if (progress == null) return;
final finished =
progress.total != null && progress.processed >= progress.total!;
// 跳过等记录处理极快,逐条重绘 loading 会掉帧,按 100 毫秒节流收敛。
if (!finished &&
DateTime.now().difference(lastPaintAt) <
const Duration(milliseconds: 100)) {
return;
}
lastPaintAt = DateTime.now();
_showFaceSyncProgress(progress);
},
);
}
try {
@@ -220,12 +236,32 @@ class FaceManageController extends GetxController {
Toast.error(msg: '同步人脸失败,请稍后重试');
logger.e('|人脸管理|同步失败error:$e|stack:$stack|');
} finally {
progressWorker?.dispose();
currentPage.value = 1;
isSyncing.value = false;
if (overlayVisible) EasyLoading.dismiss();
}
}
/// 按最新进度刷新同步遮罩:清单分页阶段用转圈,比对阶段切为百分比进度条。
void _showFaceSyncProgress(FaceSyncProgress? progress) {
final ratio = progress?.ratio;
if (ratio == null || progress == null) {
EasyLoading.show(
status: progress == null
? '同步中...'
: '正在获取人脸清单 ${progress.processed} 条',
maskType: EasyLoadingMaskType.black,
);
return;
}
EasyLoading.showProgress(
ratio,
status: '人脸同步中 ${progress.processed}/${progress.total}',
maskType: EasyLoadingMaskType.black,
);
}
/// 重新读取 Android 人脸配置并按新阈值立即重查低质量分类。
Future<void> refreshQualityThreshold() async {
try {
@@ -275,6 +275,18 @@ Widget settingBaseInfo(BuildContext context) {
),
),
),
if (controller.kioskStatus.value.deviceOwner)
SettingInfoRow(
icon: FAssets.icons.shieldAlert,
label: '解除设备所有者',
subtitle: '解除后 Kiosk 锁定与开机自启立即失效,本应用恢复为普通应用,可正常卸载',
trailing: SettingActionButton(
label: controller.kioskClearing.value ? '正在解除' : '解除并允许卸载',
danger: true,
enabled: !controller.kioskClearing.value,
onTap: controller.clearKioskDeviceOwner,
),
),
SettingInfoRow(
icon: FAssets.icons.keyboard,
label: '设置为主控',
+182 -182
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,7 +1,7 @@
name: pj_supermarket_cloud
description: "智慧后勤-测试"
publish_to: "none"
version: 2.0.3+203
version: 2.1.2+212
environment:
sdk: ^3.6.0