From 7bd62147424731c7d4947cd4fe45f5cf4490728c Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Tue, 28 Oct 2025 15:31:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=BF=BB=E8=AF=91):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=A3=80=E6=B5=8B=E8=AF=AD=E7=A7=8D=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现文本翻译时的自动语种检测功能,当用户选择"自动检测"时,会先调用火山引擎API进行语种识别 在翻译服务中添加detectTextLanguage方法,支持频率控制和结果缓存 修改语言选择UI,在源语言列表中添加"自动检测"选项 --- .../services/volcano_translation_service.dart | 177 +++++++++++++++--- .../text_translation_controller.dart | 45 ++++- 2 files changed, 191 insertions(+), 31 deletions(-) diff --git a/lib/data/services/volcano_translation_service.dart b/lib/data/services/volcano_translation_service.dart index dc2127f78..093f58a4b 100644 --- a/lib/data/services/volcano_translation_service.dart +++ b/lib/data/services/volcano_translation_service.dart @@ -1,32 +1,42 @@ import 'dart:convert'; -import 'dart:math'; import 'dart:typed_data'; -import '../../data/models/appconfig.dart'; -import 'package:get_storage/get_storage.dart'; + import 'package:http/http.dart' as http; import 'package:crypto/crypto.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:get/get.dart'; + +import '../../data/models/appconfig.dart'; import '../../core/utils/logger.dart'; import 'language_manager.dart'; import '../../modules/picture_translation/models/picture_translation_models.dart'; //// 火山引擎机器翻译服务 +/// 提供文本翻译功能 +/// 提供图片翻译功能 +/// 提供文本语种检测功能 class VolcanoTranslationService extends GetxService { // API 配置 late String _baseUrl; final String _endpoint = '/'; // 修改为根路径 - late String _accessKey; - late String _secretKey; - late String _region; - late String _service; + late final String _accessKey; + late final String _secretKey; + late final String _region; + late final String _service; // 初始化标志 bool _isInitialized = false; // 语言管理器 final LanguageManager _languageManager = Get.find(); - // final GetStorage _storage = GetStorage(); + + // 文本语种检测频率控制与缓存 + DateTime? _lastDetectTime; // 上次检测时间 + String? _lastDetectTextKey; // 上次检测的文本键 + final Duration _detectMinInterval = + const Duration(milliseconds: 800); // 最小检测间隔,避免过频请求 + final Map _detectCache = {}; // 文本键 -> 检测到的短代码 缓存 + // 构造函数 VolcanoTranslationService(); @override @@ -46,25 +56,13 @@ class VolcanoTranslationService extends GetxService { return true; } try { - // 优先使用后端 AppConfig,其次回退到 .env - final bool appConfigReady = AppConfig.isInitialized(); - - final accessKeyFromConfig = appConfigReady - ? AppConfig.env('VOLCANO_TRANSLATION_ACCESS_KEY') - : null; - final secretKeyFromConfig = appConfigReady - ? AppConfig.env('VOLCANO_TRANSLATION_SECRET_KEY') - : null; - final regionFromConfig = - appConfigReady ? AppConfig.env('VOLCANO_TRANSLATION_REGION') : null; - - _accessKey = accessKeyFromConfig ?? + _accessKey = AppConfig.env('VOLCANO_TRANSLATION_ACCESS_KEY') ?? dotenv.env['VOLCANO_TRANSLATION_ACCESS_KEY'] ?? ''; - _secretKey = secretKeyFromConfig ?? + _secretKey = AppConfig.env('VOLCANO_TRANSLATION_SECRET_KEY') ?? dotenv.env['VOLCANO_TRANSLATION_SECRET_KEY'] ?? ''; - _region = regionFromConfig ?? + _region = AppConfig.env('VOLCANO_TRANSLATION_REGION') ?? dotenv.env['VOLCANO_TRANSLATION_REGION'] ?? 'cn-north-1'; @@ -288,6 +286,136 @@ class VolcanoTranslationService extends GetxService { } } + /// 文本语种检测 + /// 返回值为 ASR 代码(例如 `zh-CN` / `en-US`),若检测失败返回 null。 + /// 频率控制:同一文本键在最小间隔内复用缓存结果,降低请求频率。 + Future detectTextLanguage({ + required String text, + }) async { + if (!_isInitialized) { + Logger.error('语种检测服务未初始化'); + return null; + } + + try { + // 构建文本键用于频率控制与缓存:去除首尾空格并截取前 256 字符 + final normalized = text.trim(); + if (normalized.isEmpty) { + return null; + } + final textKey = + normalized.length > 256 ? normalized.substring(0, 256) : normalized; + + // 命中缓存直接返回,避免重复请求 + final cachedShortCode = _detectCache[textKey]; + if (cachedShortCode != null && cachedShortCode.isNotEmpty) { + final cachedAsr = + _languageManager.getAsrCodeByShortCode(cachedShortCode); + if (cachedAsr != null && cachedAsr.isNotEmpty) { + return cachedAsr; + } + } + + // 频率控制:同一文本键在最小时间间隔内直接复用上次结果或跳过 + final now = DateTime.now(); + if (_lastDetectTime != null && _lastDetectTextKey == textKey) { + final delta = now.difference(_lastDetectTime!); + if (delta < _detectMinInterval) { + Logger.info('语种检测过于频繁,已短路本次请求'); + return null; + } + } + _lastDetectTime = now; + _lastDetectTextKey = textKey; + + // 构建请求体 + final requestBody = { + 'TextList': [normalized], + }; + + // 查询参数(官方文档:Action=LangDetect, Version=2020-06-01) + final queryParams = { + 'Action': 'LangDetect', + 'Version': '2020-06-01', + 'Region': _region, + 'Service': _service, + }; + + // 生成签名与头部 + final headers = _generateSignature('POST', requestBody, queryParams); + + // 拼接请求 URL + final queryString = Uri(queryParameters: queryParams).query; + final url = '$_baseUrl/?$queryString'; + + // 发送请求,增加网络超时保护 + final response = await http + .post( + Uri.parse(url), + headers: headers, + body: json.encode(requestBody), + ) + .timeout(const Duration(seconds: 8)); + + if (response.statusCode == 200) { + final responseData = json.decode(response.body); + + // 有效性校验:必须存在 DetectedLanguageList 且包含 Language/Confidence + if (responseData is! Map || + responseData['DetectedLanguageList'] is! List || + (responseData['DetectedLanguageList'] as List).isEmpty) { + Logger.error('语种检测响应无有效结果: ${response.body}'); + return null; + } + + // 选择置信度最高的结果 + String? bestShortCode; + double bestConfidence = -1; + for (final item in (responseData['DetectedLanguageList'] as List)) { + if (item is Map) { + final lang = item['Language']?.toString(); + final conf = (item['Confidence'] is num) + ? (item['Confidence'] as num).toDouble() + : null; + if (lang != null && lang.isNotEmpty && conf != null) { + if (conf > bestConfidence) { + bestConfidence = conf; + bestShortCode = lang; + } + } + } + } + + // 置信度阈值校验:过低则视为失败 + if (bestShortCode == null || bestConfidence < 0.6) { + Logger.info('语种检测置信度不足: code=$bestShortCode, conf=$bestConfidence'); + return null; + } + + // 映射为 ASR 代码 + final asrCode = _languageManager.getAsrCodeByShortCode(bestShortCode); + if (asrCode == null || asrCode.isEmpty) { + Logger.error('语种检测返回短代码无法映射为ASR: $bestShortCode'); + return null; + } + + // 写入缓存(短代码缓存,便于后续复用) + _detectCache[textKey] = bestShortCode; + + Logger.info( + 'lxm---语种检测成功: $bestShortCode (conf=$bestConfidence) -> $asrCode'); + return asrCode; + } + + Logger.error( + '语种检测HTTP错误: status=${response.statusCode}, body=${response.body}'); + return null; + } catch (e) { + Logger.error('语种检测请求异常: $e'); + return null; + } + } + /// 图片翻译(整图识别+回贴翻译) Future translateImage({ required Uint8List imageBytes, @@ -345,7 +473,8 @@ class VolcanoTranslationService extends GetxService { responseData['ResponseMetadata'] is Map && (responseData['ResponseMetadata']['Error'] != null)) { final err = responseData['ResponseMetadata']['Error']; - Logger.error('图片翻译错误: code=${err['Code']}, message=${err['Message']}'); + Logger.error( + '图片翻译错误: code=${err['Code']}, message=${err['Message']}'); return null; } diff --git a/lib/modules/text_translation/controllers/text_translation_controller.dart b/lib/modules/text_translation/controllers/text_translation_controller.dart index 00f86503d..1fcbb6a2c 100644 --- a/lib/modules/text_translation/controllers/text_translation_controller.dart +++ b/lib/modules/text_translation/controllers/text_translation_controller.dart @@ -129,10 +129,14 @@ class TextTranslationController extends GetxController { if (language != sourceLanguageChineseName.value) { sourceLanguageChineseName.value = language; - // 同时更新语言代码 - final asrCode = _languageManager.getAsrCodeByChineseName(language); - if (asrCode != null) { - sourceLanguageCode.value = asrCode; + // 同时更新语言代码;当选择“自动检测”时,将源语言代码置空,后续翻译流程会进行语种检测 + if (language == '自动检测') { + sourceLanguageCode.value = ''; + } else { + final asrCode = _languageManager.getAsrCodeByChineseName(language); + if (asrCode != null) { + sourceLanguageCode.value = asrCode; + } } _saveLanguageSettings(); @@ -242,7 +246,28 @@ class TextTranslationController extends GetxController { Logger.info('调用翻译服务...'); - // 调用翻译服务,直接使用维护的语言代码 + // 若选择了“自动检测”,先进行语种检测 + if (sourceLanguageChineseName.value == '自动检测' || + sourceLanguageCode.value.isEmpty) { + Logger.info('源语言为自动检测,开始语种检测…'); + final detectedAsrCode = + await _translationService.detectTextLanguage(text: text); + if (detectedAsrCode == null || detectedAsrCode.isEmpty) { + Logger.error('语种检测失败'); + Get.snackbar( + '错误', // 翻译键代表的中文文本内容 + '语种检测失败,请手动选择源语言或稍后重试', // 翻译键代表的中文文本内容 + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.red.withOpacity(0.9), + colorText: Colors.white, + ); + return; + } + sourceLanguageCode.value = detectedAsrCode; + Logger.info('语种检测结果: $detectedAsrCode'); + } + + // 调用翻译服务,使用检测或手动选择的源语言代码 final result = await _translationService.translateText( text: text, sourceLanguageCode: sourceLanguageCode.value, @@ -328,9 +353,15 @@ class TextTranslationController extends GetxController { const Divider(height: 1), Expanded( child: ListView.builder( - itemCount: supportedLanguages.length, + itemCount: (isSourceLanguage + ? (supportedLanguages.length + 1) + : supportedLanguages.length), itemBuilder: (context, index) { - final language = supportedLanguages[index]; + final language = isSourceLanguage + ? (index == 0 + ? '自动检测' + : supportedLanguages[index - 1]) + : supportedLanguages[index]; final isSelected = isSourceLanguage ? language == sourceLanguageChineseName.value : language == targetLanguageChineseName.value;