Files
biz_xfj_cloud/lib/pages/terminal/setting/widgets/setting_baseInfo.dart
T
2026-09-23 17:34:01 +08:00

837 lines
31 KiB
Dart

import 'dart:async';
import 'package:common_utils/common_utils.dart';
import 'package:ducafe_ui_core/ducafe_ui_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:forui/forui.dart';
import 'package:get/get.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
import '../controller.dart';
import 'setting_components.dart';
/// 绑定接口成功即结束主操作,消息服务连接失败由后台定时任务继续恢复,不能反向判定设备绑定失败。
Future<void> _refreshMqAfterBinding() async {
try {
await AppMqTool.mqRefresh();
} catch (e, stack) {
logger.e('|绑定后连接消息服务失败error:|$e|stack:|$stack|');
}
if (MqManager.isConnected) return;
final reason = MqManager.connectionFailureReason ?? '消息服务暂不可用';
logger.w('|绑定成功但消息服务未连接reason:|$reason|');
Toast.error(msg: '绑定成功,消息服务暂未连接');
}
/// 打开服务端设备绑定弹窗;输入框只回显本机当前值,不为调试包注入固定业务终端 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(
title: Text('绑定唯一编号', style: theme.typography.xl2),
width: context.width * 0.55,
maxWidth: context.width * 0.55,
onClose: () => Navigator.of(dialogContext).pop(),
semanticLabel: '绑定设备唯一编号弹框',
content: SizedBox(
width: double.infinity,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 设备唯一编号为纯数字;系统软键盘在定制 ROM 上不可用,弹框
// 高度有限,键盘直接内嵌跟随展示,实体键盘输入不受影响。
FTextField(
keyboardType: TextInputType.none,
controller: uniqueCodeController,
hint: '请输入设备唯一编号',
maxLines: 1,
).width(double.infinity),
SizedBox(height: 16.h),
AppSoftKeyboardPanel(
controller: uniqueCodeController,
mode: AppSoftKeyboardMode.digits,
allowPattern: RegExp('[0-9]'),
semanticLabel: '设备唯一编号数字键盘',
),
],
),
).marginSymmetric(vertical: 20.h),
actions: [
FButton(
style: FButtonStyle.outline,
label: Text('取消', style: theme.typography.base),
onPress: () => Navigator.of(dialogContext).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();
}
},
),
],
),
);
uniqueCodeController.dispose();
}
Widget settingBaseInfo(BuildContext context) {
final controller = Get.find<SettingController>();
return Obx(() {
final sys = controller.sysSetting.value;
if (sys == null) return const SettingEmpty(message: '暂无终端信息');
return SingleChildScrollView(
child: Column(
children: [
SettingGroup(
title: '基本信息',
child: Column(
children: [
_copyRow(
icon: FAssets.icons.store,
label: '设备类型',
value: sys.sysType.cnStr,
),
_copyRow(
icon: FAssets.icons.hardDrive,
label: '设备名称',
value: sys.serviceName,
),
_copyRow(
icon: FAssets.icons.idCard,
label: 'Android ID',
value: sys.androidId,
masked: true,
),
SettingInfoRow(
icon: FAssets.icons.idCard,
label: '唯一编号',
value: _maskIdentifier(sys.serviceId),
actionLabel:
ObjectUtil.isEmpty(sys.serviceId) ? '立即绑定' : '重新绑定',
onTap: () => _bindServiceDialog(context, sys),
),
_copyRow(
icon: FAssets.icons.store,
label: '商户编号',
value: sys.serviceMerchantId,
masked: true,
),
_copyRow(
icon: FAssets.icons.building,
label: '商户部门编号',
value: sys.merchantDeptId,
masked: true,
showDivider: false,
),
],
),
),
SizedBox(height: XfjDesignTokens.cardGap.h),
SettingGroup(
title: '运行环境',
child: Column(
children: [
SettingInfoRow(
icon: FAssets.icons.monitor,
label: '当前设备',
subtitle: '检测到 ${AppRuntimeTool.displayCount} 个显示屏',
value: AppRuntimeTool.isEmulator
? 'Android 虚拟机'
: 'Android 真实设备',
),
SettingInfoRow(
icon: FAssets.icons.smartphone,
label: 'Android 版本',
value: AppRuntimeTool.androidVersionLabel,
),
SettingInfoRow(
icon: FAssets.icons.package,
label: 'APK 版本',
value: AppRuntimeTool.apkVersionLabel,
),
SettingInfoRow(
icon: sys.rootAccessStatus == RootAccessStatus.available
? FAssets.icons.shieldCheck
: FAssets.icons.shieldAlert,
label: 'Root 权限',
subtitle: _rootAccessDescription(sys),
value: _rootAccessLabel(sys.rootAccessStatus),
trailing: Tooltip(
message: '重新检查 Root 权限',
child: IconButton(
tooltip: '重新检查 Root 权限',
onPressed: controller.rootRefreshing.value
? null
: controller.refreshRootAccess,
icon: controller.rootRefreshing.value
? SizedBox.square(
dimension: 20.w,
child: const CircularProgressIndicator(
strokeWidth: 2,
),
)
: FIcon(
FAssets.icons.refreshCw,
color: XfjDesignTokens.primary,
size: 20.sp,
),
),
),
),
SettingInfoRow(
icon: controller.kioskStatus.value.available
? FAssets.icons.shieldCheck
: FAssets.icons.circleAlert,
label: '专用设备 / Kiosk',
subtitle: controller.kioskProvisionMessage.isNotEmpty
? controller.kioskProvisionMessage.value
: controller.kioskStatus.value.description,
value: controller.kioskStatus.value.statusLabel,
trailing: controller.kioskStatus.value.available
? FSwitch(
semanticLabel: '立即开启或关闭 Kiosk',
value: controller.kioskStatus.value.autoEnterEnabled,
enabled: !controller.kioskSwitching.value,
onChange: controller.setKioskAutoEnterEnabled,
)
: Tooltip(
message:
sys.rootAccessStatus == RootAccessStatus.available
? '使用 Root 配置 Device Owner 并进入 Kiosk'
: 'Root 不可用,无法自动配置 Device Owner',
child: SettingActionButton(
label: controller.kioskProvisioning.value
? '正在启用'
: '启用 Kiosk',
icon: FAssets.icons.shieldCheck,
primary: true,
enabled: sys.rootAccessStatus ==
RootAccessStatus.available &&
!controller.kioskProvisioning.value,
onTap: controller.enableKioskWithRoot,
),
),
),
SettingInfoRow(
icon: FAssets.icons.keyboard,
label: '设置为主控',
subtitle: AppRuntimeTool.isEmulator
? '虚拟机强制使用人工输入,不连接扫码、饭卡和人脸硬件'
: '开启后扫码、饭卡读写和人脸识别由操作员输入结果',
showDivider: false,
trailing: FSwitch(
semanticLabel: '设置为主控手动硬件输入模式',
value: controller.manualHardwareInput.value,
enabled: !AppRuntimeTool.isEmulator,
onChange: controller.setManualHardwareInput,
),
),
],
),
),
SizedBox(height: XfjDesignTokens.cardGap.h),
_loginPasswordPolicySetting(),
SizedBox(height: XfjDesignTokens.cardGap.h),
_terminalDeviceStatusVisibilitySetting(),
SizedBox(height: XfjDesignTokens.cardGap.h),
SettingGroup(
title: '主题样式',
child: Column(
children: [
SettingSegmentedSelector<SettingGlobalFontSize>(
label: '字体大小',
value: sys.globalFontSize ?? SettingGlobalFontSize.xl3,
options: const {
SettingGlobalFontSize.xl2: '紧凑',
SettingGlobalFontSize.xl3: '标准',
SettingGlobalFontSize.xl4: '大号',
},
onChanged: controller.setGlobalFontSize,
),
SettingSegmentedSelector<SettingButtonRadius>(
label: '按钮圆角',
value: sys.buttonRadius,
options: const {
SettingButtonRadius.compact: '紧凑',
SettingButtonRadius.standard: '标准',
SettingButtonRadius.large: '大号',
},
onChanged: controller.setButtonRadius,
),
SettingSegmentedSelector<SettingCardRadius>(
label: '卡片圆角',
value: sys.cardRadius,
options: const {
SettingCardRadius.compact: '紧凑',
SettingCardRadius.standard: '标准',
SettingCardRadius.large: '大号',
},
onChanged: controller.setCardRadius,
),
SettingSegmentedSelector<SettingButtonSize>(
label: '按钮大小',
value: sys.buttonSize,
options: const {
SettingButtonSize.compact: '紧凑',
SettingButtonSize.standard: '标准',
SettingButtonSize.large: '大号',
},
onChanged: controller.setButtonSize,
),
const _TerminalMenuEditor(),
],
),
),
],
),
);
});
}
/// 登录密码的两个忘记时机开关,均默认开启,并提供一键恢复默认。
/// 忘记密码只清除已保存的密码,用户名仍会在登录页回填。
Widget _loginPasswordPolicySetting() {
final controller = Get.find<SettingController>();
return SettingGroup(
title: '登录密码',
child: Column(
children: [
Obx(() {
final enabled = controller.forgetPasswordOnManualLogout.value;
return SettingInfoRow(
icon: FAssets.icons.logOut,
label: '手动退出时忘记密码',
subtitle: '开启后退出登录将清除已保存的密码,下次登录需重新输入;离线退出时仍保留以便断网重进。',
value: enabled ? '已开启' : '已关闭',
trailing: FSwitch(
key: const ValueKey('setting-forget-password-manual-logout'),
semanticLabel: '手动退出时忘记登录密码',
value: enabled,
onChange: (value) => unawaited(
controller.setForgetPasswordOnManualLogout(value),
),
),
);
}),
Obx(() {
final enabled = controller.forgetPasswordOnPowerLoss.value;
return SettingInfoRow(
icon: FAssets.icons.powerOff,
label: '突然关机时忘记密码',
subtitle: '开启后断电或重启再次启动时需重新输入密码,防止他人开机后直接进入系统。',
value: enabled ? '已开启' : '已关闭',
trailing: FSwitch(
key: const ValueKey('setting-forget-password-power-loss'),
semanticLabel: '突然关机时忘记登录密码',
value: enabled,
onChange: (value) => unawaited(
controller.setForgetPasswordOnPowerLoss(value),
),
),
);
}),
SettingInfoRow(
icon: FAssets.icons.rotateCcw,
label: '恢复默认',
subtitle: '把两个忘记密码开关都恢复为默认开启。',
showDivider: false,
trailing: SettingActionButton(
label: '恢复默认',
icon: FAssets.icons.rotateCcw,
primary: true,
onTap: () => unawaited(controller.resetForgetPasswordPolicies()),
),
),
],
),
);
}
/// 以数据驱动的五行开关提供顶部设备状态逐项配置,并与详情弹框共享应用级响应状态。
Widget _terminalDeviceStatusVisibilitySetting() {
final control = Get.find<TerminalDeviceStatusControl>();
final devices = TerminalDeviceType.values;
return SettingGroup(
title: '顶部状态栏',
child: Column(
children: [
for (var index = 0; index < devices.length; index++)
Obx(() {
final device = devices[index];
final visible = control.isHeaderDeviceVisible(device);
return SettingInfoRow(
icon: _terminalDeviceStatusIcon(device),
label: device.label,
subtitle: '控制${device.label}状态是否显示在所有业务页面顶部。',
value: visible ? '已显示' : '已隐藏',
showDivider: index < devices.length - 1,
trailing: FSwitch(
key: ValueKey(
'setting-terminal-device-status-${device.code}-switch',
),
semanticLabel: '在页面顶部显示${device.label}状态',
value: visible,
onChange: (value) => unawaited(
control.setHeaderDeviceVisible(device, value),
),
),
);
}),
],
),
);
}
/// 返回设备状态配置行使用的统一 Forui 图标,映射顺序与终端顶部五类设备保持一致。
SvgAsset _terminalDeviceStatusIcon(TerminalDeviceType type) => switch (type) {
TerminalDeviceType.printer => FAssets.icons.printer,
TerminalDeviceType.steelyard => FAssets.icons.scale,
TerminalDeviceType.cardReader => FAssets.icons.idCard,
TerminalDeviceType.subScreen => FAssets.icons.monitor,
TerminalDeviceType.camera => FAssets.icons.camera,
};
/// 将 Root 持久化状态转换为基本信息页短标签。
String _rootAccessLabel(RootAccessStatus status) => switch (status) {
RootAccessStatus.unknown => '未检查',
RootAccessStatus.available => '可用',
RootAccessStatus.unavailable => '不可用',
};
/// 组合 Root 最近检查时间和诊断摘要,旧配置保持明确的未检查提示。
String _rootAccessDescription(SysSetting sys) {
final checkedAt = sys.rootAccessCheckedAt;
final checkedLabel = checkedAt == null
? '尚未执行真实 Root 命令检查'
: '最近检查 ${DateUtil.formatDate(checkedAt, format: DateFormats.full)}';
final diagnostic = sys.rootAccessDiagnostic?.trim();
return diagnostic == null || diagnostic.isEmpty
? checkedLabel
: '$checkedLabel · $diagnostic';
}
/// 左侧菜单只允许从手柄直接拖动,并提供上下移动按钮作为精确操作和无障碍替代。
class _TerminalMenuEditor extends StatelessWidget {
const _TerminalMenuEditor();
@override
Widget build(BuildContext context) {
final theme = FTheme.of(context);
final menuController = Get.find<TerminalMenuControl>();
return Container(
padding: EdgeInsets.fromLTRB(20.w, 14.h, 20.w, 20.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'左侧菜单',
style: theme.typography.base.copyWith(
color: XfjDesignTokens.ink,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 4.h),
Text(
'拖动手柄调整顺序,退出固定在底部;仅剩一个业务入口时会自动收起侧栏并打开该页面。',
style: theme.typography.sm.copyWith(
color: XfjDesignTokens.muted,
),
),
],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: EdgeInsets.symmetric(
horizontal: 12.w * XfjDesignTokens.buttonSizeScale,
vertical: 8.h * XfjDesignTokens.buttonSizeScale,
),
decoration: BoxDecoration(
color: XfjDesignTokens.primarySoft,
borderRadius: BorderRadius.circular(
XfjDesignTokens.controlRadius,
),
),
child: Row(
children: [
FIcon(
FAssets.icons.gripVertical,
color: XfjDesignTokens.primary,
size: 18.sp,
),
SizedBox(width: 8.w),
Text(
'拖动手柄',
style: theme.typography.sm.copyWith(
color: XfjDesignTokens.primary,
fontWeight: FontWeight.w700,
),
),
],
),
),
SizedBox(width: 8.w),
FButton(
style: FButtonStyle.outline,
prefix: FIcon(
FAssets.icons.rotateCcw,
color: XfjDesignTokens.ink,
size: 18.sp,
),
label: Text(
'还原',
style: theme.typography.sm.copyWith(
color: XfjDesignTokens.ink,
fontWeight: FontWeight.w700,
),
),
onPress: () {
menuController.reset();
Toast.success(msg: '左侧菜单已还原');
},
),
],
),
],
),
SizedBox(height: 12.h),
Obx(
() => ReorderableListView.builder(
shrinkWrap: true,
primary: false,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
itemCount: menuController.menuOrder.length,
onReorder: menuController.reorder,
proxyDecorator: (child, _, animation) => AnimatedBuilder(
animation: animation,
builder: (context, child) => Material(
color: Colors.transparent,
elevation: 6 * animation.value,
borderRadius: BorderRadius.circular(
XfjDesignTokens.controlRadius,
),
child: child,
),
child: child,
),
itemBuilder: (context, index) {
final item = menuController.menuOrder[index];
return Padding(
key: ValueKey(item.code),
padding: EdgeInsets.only(
bottom: index == menuController.menuOrder.length - 1
? 0
: XfjDesignTokens.cardGap.h,
),
child: Obx(
() => _TerminalMenuRow(
item: item,
index: index,
visible: menuController.isVisible(item),
reorderable: true,
visibilityEnabled: true,
onVisibleChanged: (visible) =>
menuController.setVisible(item, visible),
onMoveUp: index == 0
? null
: () => menuController.reorder(index, index - 1),
onMoveDown: index == menuController.menuOrder.length - 1
? null
: () => menuController.reorder(index, index + 2),
),
),
);
},
),
),
SizedBox(height: XfjDesignTokens.cardGap.h),
Obx(
() => _TerminalMenuRow(
item: TerminalMenuItemType.logout,
index: menuController.menuOrder.length,
visible: menuController.isVisible(TerminalMenuItemType.logout),
reorderable: false,
visibilityEnabled: true,
onVisibleChanged: (visible) => menuController.setVisible(
TerminalMenuItemType.logout,
visible,
),
),
),
],
),
);
}
}
class _TerminalMenuRow extends StatelessWidget {
const _TerminalMenuRow({
required this.item,
required this.index,
required this.visible,
required this.reorderable,
required this.visibilityEnabled,
required this.onVisibleChanged,
this.onMoveUp,
this.onMoveDown,
});
final TerminalMenuItemType item;
final int index;
final bool visible;
final bool reorderable;
final bool visibilityEnabled;
final ValueChanged<bool> onVisibleChanged;
final VoidCallback? onMoveUp;
final VoidCallback? onMoveDown;
@override
Widget build(BuildContext context) {
final theme = FTheme.of(context);
final leadingHandle = SizedBox(
width: 48.w,
height: 48.h,
child: FIcon(
reorderable ? FAssets.icons.gripVertical : FAssets.icons.lock,
color: reorderable ? XfjDesignTokens.muted : XfjDesignTokens.primary,
size: 22.sp,
).center(),
);
return Container(
constraints: BoxConstraints(minHeight: XfjDesignTokens.controlHeight.h),
padding: EdgeInsets.symmetric(horizontal: 12.w),
decoration: BoxDecoration(
color: visible ? XfjDesignTokens.surface : XfjDesignTokens.control,
border: Border.all(color: XfjDesignTokens.line),
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
),
child: Row(
children: [
if (reorderable)
ReorderableDragStartListener(index: index, child: leadingHandle)
else
leadingHandle,
SizedBox(width: 8.w),
if (reorderable) ...[
Container(
width: 32.w,
height: 32.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: visible
? XfjDesignTokens.primarySoft
: XfjDesignTokens.surface,
borderRadius: BorderRadius.circular(
XfjDesignTokens.controlRadius,
),
),
child: Text(
'${index + 1}',
style: theme.typography.sm.copyWith(
color:
visible ? XfjDesignTokens.primary : XfjDesignTokens.muted,
fontWeight: FontWeight.w700,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
],
SizedBox(width: 12.w),
FIcon(
item.icon,
color: visible ? XfjDesignTokens.ink : XfjDesignTokens.muted,
size: 22.sp,
),
SizedBox(width: 10.w),
Expanded(
child: Text(
item.title,
style: theme.typography.base.copyWith(
color: visible ? XfjDesignTokens.ink : XfjDesignTokens.muted,
fontWeight: FontWeight.w700,
),
),
),
if (reorderable) ...[
_MenuMoveButton(
semanticLabel: '${item.title}上移',
icon: FAssets.icons.chevronUp,
onTap: onMoveUp,
),
SizedBox(width: 8.w),
_MenuMoveButton(
semanticLabel: '${item.title}下移',
icon: FAssets.icons.chevronDown,
onTap: onMoveDown,
),
SizedBox(width: 16.w),
] else ...[
Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h),
decoration: BoxDecoration(
color: XfjDesignTokens.primarySoft,
borderRadius: BorderRadius.circular(
XfjDesignTokens.controlRadius,
),
),
child: Text(
'固定底部',
style: theme.typography.sm.copyWith(
color: XfjDesignTokens.primary,
fontWeight: FontWeight.w700,
),
),
),
SizedBox(width: 16.w),
],
SizedBox(
width: 64.w,
child: Text(
visible ? '显示' : '隐藏',
textAlign: TextAlign.right,
style: theme.typography.sm.copyWith(
color:
visible ? XfjDesignTokens.success : XfjDesignTokens.muted,
fontWeight: FontWeight.w700,
),
),
),
SizedBox(width: 12.w),
FSwitch(
semanticLabel: '${item.title}${visible ? '已显示' : '已隐藏'}',
value: visible,
enabled: visibilityEnabled,
onChange: onVisibleChanged,
),
],
),
);
}
}
class _MenuMoveButton extends StatelessWidget {
const _MenuMoveButton({
required this.semanticLabel,
required this.icon,
this.onTap,
});
final String semanticLabel;
final SvgAsset icon;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final enabled = onTap != null;
final buttonSize =
(48 * XfjDesignTokens.buttonSizeScale).clamp(48, 60).toDouble();
return Semantics(
button: true,
enabled: enabled,
label: semanticLabel,
child: Opacity(
opacity: enabled ? 1 : 0.35,
child: Material(
color: XfjDesignTokens.surface,
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
child: Container(
width: buttonSize.w,
height: buttonSize.h,
decoration: BoxDecoration(
border: Border.all(color: XfjDesignTokens.line),
borderRadius: BorderRadius.circular(
XfjDesignTokens.controlRadius,
),
),
child: FIcon(
icon,
color: XfjDesignTokens.ink,
size: 20.sp,
).center(),
),
),
),
),
);
}
}
Widget _copyRow({
required SvgAsset icon,
required String label,
String? value,
bool masked = false,
bool showDivider = true,
}) {
return SettingInfoRow(
icon: icon,
label: label,
value: masked ? _maskIdentifier(value) : _safeValue(value),
actionLabel: '复制',
showDivider: showDivider,
onTap: ObjectUtil.isEmpty(value) ? null : () => KeywordTool.copy(value),
);
}
String _safeValue(String? value) {
return ObjectUtil.isEmpty(value) ? '--' : value!;
}
String _maskIdentifier(String? value) {
if (ObjectUtil.isEmpty(value)) return '--';
final text = value!;
if (text.length <= 8) return '${text.substring(0, 2)}••••';
final prefixLength = text.length >= 16 ? 4 : 3;
final suffixLength = text.length >= 16 ? 4 : 3;
return '${text.substring(0, prefixLength)}••••••••${text.substring(text.length - suffixLength)}';
}