Browse Source

AI 页闸门提示改走节流,别再连点连弹

两个现象是同一个根因:灰掉的卡片只是画成 0.45 透明度
(ModuleSquareCard 的 enabled 只管 opacity),点击照样走到 launchModule。
用户看到灰卡片会连点几下试,于是每点一下补一条一模一样的 snackbar,
Get 还会把它们排队,得逐条等超时才散得干净。

改的四处闸门,全部走 ToastThrottle + 8 秒窗口:
- 没在通话却点通话翻译/通话录音 → callRequired
- 通话中点现场录音/同传/面对面/多媒体 → inCallBusy
- 没连设备却点依赖设备的功能 → connectDevice(AI 页与翻译中转页共用一把 key,
  否则两个页面来回点还是会各弹各的)
- 开发中的功能 → 按模块分 key,不同功能的提示不该互相节流掉

窗口取 8 秒而不是默认的 3 秒:普通提示(保存失败之类)用户看完就换动作了,
闸门提示的对象是「用户还会再点」的场景,3 秒等于每 3 秒补一条,一样是刷屏。
信息量只在第一条。

保留提示而不是彻底吞掉:吞掉的话用户点灰卡片零反馈,不知道为什么点不动。
第一下解释清楚,后面忽略。

ToastThrottle.show 加可选 window 参数 + gateWindow 常量。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 1 month ago
parent
commit
692b004586
  1. 29
      apps/client/lib/core/utils/toast_throttle.dart
  2. 54
      apps/client/lib/modules/home/controllers/home_controller.dart
  3. 22
      apps/client/lib/modules/main_tab/views/widgets/module_card.dart
  4. 11
      apps/client/lib/modules/translation/middleware/controllers/translation_middleware_controller.dart

29
apps/client/lib/core/utils/toast_throttle.dart

@ -13,11 +13,34 @@ class ToastThrottle {
static final Map<String, DateTime> _last = {};
/// 同一提示的最小间隔。取 3 秒:比 snackbar 默认停留时间略长,
// ===== 设备/通话闸门提示的固定 key =====
//
// ⚠️ 跨页面必须共用同一把 key:AI 页的卡片和翻译中转页弹的是同一句话,
// 各用各的 key 会让用户在两处之间来回点时照样连着弹。
/// 「需要在通话中」——没在通话却点了通话翻译/通话录音
static const String keyCallRequired = 'gate.call_required';
/// 「通话中不可用」——通话里点了现场录音/同传/面对面/多媒体
static const String keyInCallBusy = 'gate.in_call_busy';
/// 「请先连接设备」
static const String keyConnectDevice = 'gate.connect_device';
/// 同一提示的默认最小间隔。取 3 秒:比 snackbar 默认停留时间略长,
/// 保证「上一条还没消失就不会再来一条」。
static const Duration window = Duration(seconds: 3);
/// 「这个功能现在就是用不了」这类闸门提示的间隔,比默认长得多。
///
/// 区别在于用户还会不会再点:普通提示(保存失败之类)用户看完就换动作了,
/// 3 秒够;而灰着的功能卡片用户会连点好几下试,每 3 秒补一条同样的提示
/// 一样是刷屏。这里的信息量只有第一条,后面全是噪音。
static const Duration gateWindow = Duration(seconds: 8);
/// 弹一条受节流的提示。被节流掉时返回 false。
///
/// [window] 不传就用 [ToastThrottle.window];闸门类提示传 [gateWindow]。
static bool show(
String key, {
required String title,
@ -25,10 +48,12 @@ class ToastThrottle {
SnackPosition position = SnackPosition.TOP,
Color? background,
Color? textColor,
Duration? window,
}) {
final gap = window ?? ToastThrottle.window;
final now = DateTime.now();
final last = _last[key];
if (last != null && now.difference(last) < window) return false;
if (last != null && now.difference(last) < gap) return false;
_last[key] = now;
Get.snackbar(
title,

54
apps/client/lib/modules/home/controllers/home_controller.dart

@ -1,6 +1,7 @@
import 'dart:async';
import '/core/utils/id_verify_guard.dart';
import '/core/utils/device_connection.dart';
import '/core/utils/toast_throttle.dart';
import 'package:device_jieli/device_jieli.dart';
import 'package:get/get.dart';
@ -271,25 +272,30 @@ class HomeController extends GetxController {
// 三条链路任意一条连上都算已连接(杰理 / 蓝讯支架 / 恒玄耳机)。
// 之前只看 _isFullyConnected(杰理专用),恒玄耳机连上了照样被拦。
// 通话翻译还要求正处于通话中——耳机得先进通话模式(0xD1)才有双路音频
// ⚠️ 下面三条一律走 ToastThrottle 而不是裸 Get.snackbar:
// 它们拦的都是「这个功能现在就是用不了」,而卡片只是灰着、点得动,
// 用户会连点几下试——每点一下补一条一模一样的提示就把屏幕糊满了,
// 而且 Get 的 snackbar 会排队,要等它们逐条超时才散得干净。
// 信息量只在第一条,后面全是噪音。
if (needBle && DeviceConnection.any && !DeviceConnection.inCall) {
Get.snackbar('callRequiredTitle'.tr, 'callRequiredDesc'.tr,
snackPosition: SnackPosition.TOP,
duration: const Duration(seconds: 2));
ToastThrottle.show(ToastThrottle.keyCallRequired,
title: 'callRequiredTitle'.tr,
message: 'callRequiredDesc'.tr,
window: ToastThrottle.gateWindow);
return;
}
if (needBle && !DeviceConnection.any) {
Get.snackbar(
'connectDevice'.tr, // 连接设备
'pleaseConnectDeepSoundDevice'.tr, // 请连接 Eaimar 设备
snackPosition: SnackPosition.TOP,
duration: const Duration(seconds: 1),
);
ToastThrottle.show(ToastThrottle.keyConnectDevice,
title: 'connectDevice'.tr, // 连接设备
message: 'pleaseConnectDeepSoundDevice'.tr, // 请连接 Eaimar 设备
window: ToastThrottle.gateWindow);
return;
}
if (DeviceConnection.isBlockedByCall(translationId)) {
Get.snackbar('inCallBusyTitle'.tr, 'inCallBusyDesc'.tr,
snackPosition: SnackPosition.TOP,
duration: const Duration(seconds: 2));
ToastThrottle.show(ToastThrottle.keyInCallBusy,
title: 'inCallBusyTitle'.tr,
message: 'inCallBusyDesc'.tr,
window: ToastThrottle.gateWindow);
return;
}
AgentService.interruptCurrentResponse();
@ -301,18 +307,17 @@ class HomeController extends GetxController {
void openCallRecording() {
if (IdVerifyGuard.blocked()) return;
if (DeviceConnection.any && !DeviceConnection.inCall) {
Get.snackbar('callRequiredTitle'.tr, 'callRequiredDesc'.tr,
snackPosition: SnackPosition.TOP,
duration: const Duration(seconds: 2));
ToastThrottle.show(ToastThrottle.keyCallRequired,
title: 'callRequiredTitle'.tr,
message: 'callRequiredDesc'.tr,
window: ToastThrottle.gateWindow);
return;
}
if (!DeviceConnection.any) {
Get.snackbar(
'connectDevice'.tr, // 连接设备
'pleaseConnectDeepSoundDevice'.tr, // 请连接 Eaimar 设备
snackPosition: SnackPosition.TOP,
duration: const Duration(seconds: 1),
);
ToastThrottle.show(ToastThrottle.keyConnectDevice,
title: 'connectDevice'.tr, // 连接设备
message: 'pleaseConnectDeepSoundDevice'.tr, // 请连接 Eaimar 设备
window: ToastThrottle.gateWindow);
return;
}
Get.toNamed(Routes.recording,
@ -325,9 +330,10 @@ class HomeController extends GetxController {
/// (具体分支见 CallRecordingController._startRecording)。
void openFieldRecording() {
if (DeviceConnection.isBlockedByCall('fieldRecording')) {
Get.snackbar('inCallBusyTitle'.tr, 'inCallBusyDesc'.tr,
snackPosition: SnackPosition.TOP,
duration: const Duration(seconds: 2));
ToastThrottle.show(ToastThrottle.keyInCallBusy,
title: 'inCallBusyTitle'.tr,
message: 'inCallBusyDesc'.tr,
window: ToastThrottle.gateWindow);
return;
}
if (IdVerifyGuard.blocked()) return;

22
apps/client/lib/modules/main_tab/views/widgets/module_card.dart

@ -6,6 +6,7 @@ import 'package:get/get.dart';
import '../../../../core/utils/ai_navigation.dart';
import '../../../../core/utils/id_verify_guard.dart';
import '../../../../core/utils/device_connection.dart';
import '../../../../core/utils/toast_throttle.dart';
import '../../../../data/services/bes_bluetooth_service.dart';
import '../../../../data/models/agent_module.dart';
@ -307,19 +308,22 @@ void launchModule(AgentModule module) {
//(不替调用方重放动作——有些功能带副作用)。
if (IdVerifyGuard.blocked()) return;
// 通话中:现场录音/同声翻译/面对面翻译/多媒体翻译 都不可用
// ⚠️ 这里的卡片只是画成灰的(ModuleSquareCard 的 enabled 只管透明度),
// 点击照样走到这儿。用户看到灰卡片会连点几下试,所以这两条必须节流——
// 否则一串一模一样的提示叠在屏幕上,还要逐条等超时才散。
if (DeviceConnection.isBlockedByCall(module.id)) {
Get.snackbar('inCallBusyTitle'.tr, 'inCallBusyDesc'.tr,
snackPosition: SnackPosition.TOP,
duration: const Duration(seconds: 2));
ToastThrottle.show(ToastThrottle.keyInCallBusy,
title: 'inCallBusyTitle'.tr,
message: 'inCallBusyDesc'.tr,
window: ToastThrottle.gateWindow);
return;
}
if (module.comingSoon) {
Get.snackbar(
module.title.tr,
'featureInDev'.tr,
snackPosition: SnackPosition.TOP,
duration: const Duration(seconds: 2),
);
// 按模块分 key:不同功能的「开发中」是不同的话,互相不该被对方节流掉
ToastThrottle.show('gate.coming_soon.${module.id}',
title: module.title.tr,
message: 'featureInDev'.tr,
window: ToastThrottle.gateWindow);
return;
}
// 每次进功能都刷一次耳机状态(版本/按键/EQ)——耳机可能在后台被摘下、

11
apps/client/lib/modules/translation/middleware/controllers/translation_middleware_controller.dart

@ -1,6 +1,7 @@
// import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:get/get.dart';
import 'package:flutter/material.dart';
import '../../../../core/utils/toast_throttle.dart';
import '../../../../routes/app_routes.dart';
import '../../../../data/services/ble_manager.dart';
@ -92,10 +93,12 @@ class TranslationMiddlewareController extends GetxController {
// 不可用则提示用户连接设备
if (!isOptionEnabled(option)) {
// 连接设备 / 请连接 Eaimar 设备
Get.snackbar('connectDevice'.tr,
'pleaseConnectDeepSoundDevice'.tr, // 连接设备 / 请连接 Eaimar 设备
snackPosition: SnackPosition.TOP,
duration: const Duration(seconds: 1));
// 与 AI 页卡片共用同一把 key:两处弹的是同一句话,各用各的 key 会让
// 用户在两个页面之间来回点时照样连着弹。
ToastThrottle.show(ToastThrottle.keyConnectDevice,
title: 'connectDevice'.tr,
message: 'pleaseConnectDeepSoundDevice'.tr, // 请连接 Eaimar 设备
window: ToastThrottle.gateWindow);
//EasyLoading.showInfo('connectDevice'.tr);
return;
}

Loading…
Cancel
Save