From 59ca6c06e33c4c8f8e096a72d7cd55ee427a044e Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Mon, 27 Oct 2025 20:39:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=9B=BE=E7=89=87=E7=BF=BB=E8=AF=91):=20?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=9B=BE=E7=89=87=E7=BF=BB=E8=AF=91=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=B9=B6=E6=B7=BB=E5=8A=A0=E7=BB=93=E6=9E=9C=E9=A2=84?= =?UTF-8?q?=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加图片翻译API调用功能,包括: 1. 新增图片翻译相关模型类(Point2D, ImageTextBlock, ImageTranslationResult) 2. 实现火山引擎图片翻译API调用逻辑 3. 在界面中添加翻译结果图片预览区域 4. 优化翻译服务初始化逻辑避免重复初始化 5. 适配暗黑模式下的UI显示 --- .../services/volcano_translation_service.dart | 132 +++++++++++++++++- .../picture_translation_controller.dart | 87 +++++++----- .../models/picture_translation_models.dart | 61 ++++++++ .../views/picture_translation_view.dart | 69 ++++++++- .../text_translation_controller.dart | 81 +++++++---- .../views/text_translation_view.dart | 3 +- 6 files changed, 358 insertions(+), 75 deletions(-) diff --git a/lib/data/services/volcano_translation_service.dart b/lib/data/services/volcano_translation_service.dart index 14af7c856..dc2127f78 100644 --- a/lib/data/services/volcano_translation_service.dart +++ b/lib/data/services/volcano_translation_service.dart @@ -1,5 +1,6 @@ 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; @@ -8,16 +9,17 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:get/get.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 final String _accessKey; - late final String _secretKey; - late final String _region; - late final String _service; + late String _accessKey; + late String _secretKey; + late String _region; + late String _service; // 初始化标志 bool _isInitialized = false; @@ -27,9 +29,22 @@ class VolcanoTranslationService extends GetxService { // final GetStorage _storage = GetStorage(); // 构造函数 VolcanoTranslationService(); + @override + Future onInit() async { + await initialize(); + super.onInit(); + } + + // 对外暴露初始化状态,便于控制器判断 + bool get isInitialized => _isInitialized; // 初始化服务 Future initialize() async { + // 已初始化则直接返回,避免重复初始化 + if (_isInitialized) { + Logger.info('翻译服务已初始化,跳过重复初始化'); + return true; + } try { // 优先使用后端 AppConfig,其次回退到 .env final bool appConfigReady = AppConfig.isInitialized(); @@ -63,7 +78,8 @@ class VolcanoTranslationService extends GetxService { } _isInitialized = true; - Logger.info('lxm---Volcano Translation Service initialized成功'); + Logger.info( + 'lxm---Volcano Translation Service initialized成功, accessKey: $_accessKey, region: $_region, service: $_service,secretKey: $_secretKey'); return true; } catch (e) { Logger.error('lxm---Volcano Translation Service initialized失败,$e'); @@ -271,4 +287,110 @@ class VolcanoTranslationService extends GetxService { return null; } } + + /// 图片翻译(整图识别+回贴翻译) + Future translateImage({ + required Uint8List imageBytes, + required String targetLanguageCode, // ASR代码,如 zh-CN / en-US + }) async { + if (!_isInitialized) { + Logger.error('图片翻译服务未初始化'); + return null; + } + + try { + // 目标语言短代码(例如 zh / en) + String targetCode = 'zh'; + final targetShortCode = + _languageManager.getShortCodeByAsrCode(targetLanguageCode); + if (targetShortCode != null && targetShortCode.isNotEmpty) { + targetCode = targetShortCode; + } + + // 将图片二进制转为base64字符串(不带前缀) + final imgBase64 = base64.encode(imageBytes); + + // 构建请求体(根据官方文档) + final requestBody = { + 'Image': imgBase64, + 'TargetLanguage': targetCode, + }; + + // 查询参数(官方示例仅包含 Action/Version;为保持与文本翻译一致,保留 Region/Service) + final queryParams = { + 'Action': 'TranslateImage', + 'Version': '2020-07-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), + ); + + if (response.statusCode == 200) { + final responseData = json.decode(response.body); + + // 错误元数据检测 + if (responseData is Map && + responseData['ResponseMetadata'] is Map && + (responseData['ResponseMetadata']['Error'] != null)) { + final err = responseData['ResponseMetadata']['Error']; + Logger.error('图片翻译错误: code=${err['Code']}, message=${err['Message']}'); + return null; + } + + // 解析回贴图片 + Uint8List? translatedImage; + if (responseData['Image'] is String) { + try { + translatedImage = base64.decode(responseData['Image']); + } catch (_) { + translatedImage = null; + } + } + + // 解析文本块 + final List blocks = []; + if (responseData['TextBlocks'] is List) { + for (final item in (responseData['TextBlocks'] as List)) { + if (item is Map) { + blocks.add(ImageTextBlock.fromJson(item)); + } else if (item is Map) { + blocks.add(ImageTextBlock.fromJson( + item.map((k, v) => MapEntry(k.toString(), v)))); + } + } + } + + // 提取一个整体检测语种(若存在文本块) + String? detectedLang; + if (blocks.isNotEmpty && blocks.first.detectedLanguage.isNotEmpty) { + detectedLang = blocks.first.detectedLanguage; + } + + return ImageTranslationResult( + translatedImageBytes: translatedImage, + blocks: blocks, + detectedLanguage: detectedLang, + ); + } + + Logger.error( + '图片翻译HTTP错误: status=${response.statusCode}, body=${response.body}'); + return null; + } catch (e) { + Logger.error('图片翻译请求异常: $e'); + return null; + } + } } diff --git a/lib/modules/picture_translation/controllers/picture_translation_controller.dart b/lib/modules/picture_translation/controllers/picture_translation_controller.dart index 5c8fed3fe..52684ab48 100644 --- a/lib/modules/picture_translation/controllers/picture_translation_controller.dart +++ b/lib/modules/picture_translation/controllers/picture_translation_controller.dart @@ -30,6 +30,7 @@ class PictureTranslationController extends GetxController { final targetLanguageChineseName = '英语'.obs; final sourceText = ''.obs; final translatedText = ''.obs; + final translatedImageBytes = Rx(null); // 回贴后的图片二进制 final isProcessing = false.obs; final pickedImageFile = Rx(null); final currentStep = ProcessingStep( @@ -215,27 +216,11 @@ class PictureTranslationController extends GetxController { file = await _preprocessImage(file); pickedImageFile.value = file; - _updateStep('识别文本', '正在分析图片中的文本...', ProcessingStatus.recognizing, - progress: 0.3); + // 直接调用图片翻译API(包含识别+回贴) + _updateStep('翻译图片', '正在调用图片翻译服务...', ProcessingStatus.translating, + progress: 0.6); - // 文本识别 - // final inputImage = InputImage.fromFile(file); - // final RecognizedText recognizedText = - // await _textRecognizer.processImage(inputImage); - - // sourceText.value = recognizedText.text.trim(); - - if (sourceText.value.isEmpty) { - _updateStep('识别完成', '未识别到任何文本', ProcessingStatus.idle); - translatedText.value = ''; - return; - } - - _updateStep('翻译文本', '正在翻译识别的文本...', ProcessingStatus.translating, - progress: 0.7); - - // 执行翻译 - await _performTranslation(); + await _performImageTranslation(file); // 保存历史记录 if (sourceText.value.isNotEmpty && translatedText.value.isNotEmpty) { @@ -293,22 +278,53 @@ class PictureTranslationController extends GetxController { } } - /// 执行翻译 - Future _performTranslation() async { - final sourceCode = languageManager - .getAsrCodeByChineseName(sourceLanguageChineseName.value) ?? - 'zh-CN'; - final targetCode = languageManager - .getAsrCodeByChineseName(targetLanguageChineseName.value) ?? - 'en-US'; - - final result = await _translationService.translateText( - text: sourceText.value, - sourceLanguageCode: sourceCode, - targetLanguageCode: targetCode, - ); + /// 执行图片翻译(调用火山引擎图片翻译API) + Future _performImageTranslation(File imageFile) async { + try { + final bytes = await imageFile.readAsBytes(); - translatedText.value = result ?? ''; + // 图片大小限制:4MB(文档要求) + const int maxSize = 4 * 1024 * 1024; + if (bytes.lengthInBytes > maxSize) { + _updateStep('处理失败', '图片大小超过4MB限制', ProcessingStatus.error); + _showErrorDialog('处理失败', '图片大小超过4MB限制,请降低质量或尺寸后重试'); + return; + } + + final targetCode = languageManager + .getAsrCodeByChineseName(targetLanguageChineseName.value) ?? + 'zh-CN'; + + final result = await _translationService.translateImage( + imageBytes: bytes, + targetLanguageCode: targetCode, + ); + + if (result == null) { + _updateStep('处理失败', '图片翻译接口返回空结果', ProcessingStatus.error); + _showErrorDialog('处理失败', '图片翻译失败,可能的原因:网络异常或接口限流,请稍后重试'); + return; + } + + // 更新回贴图片 + translatedImageBytes.value = result.translatedImageBytes != null + ? Uint8List.fromList(result.translatedImageBytes!) + : null; + + // 将文本块汇总为识别文本与翻译文本,便于展示与复制 + if (result.blocks.isNotEmpty) { + sourceText.value = result.blocks.map((b) => b.text).join('\n'); + translatedText.value = + result.blocks.map((b) => b.translation).join('\n'); + } else { + sourceText.value = ''; + translatedText.value = ''; + } + } catch (e) { + Logger.error('PictureTranslation', '图片翻译调用失败: $e'); + _updateStep('处理失败', e.toString(), ProcessingStatus.error); + _showErrorDialog('处理失败', '图片翻译接口调用异常,请检查网络后重试'); + } } /// 保存翻译结果 @@ -395,6 +411,7 @@ class PictureTranslationController extends GetxController { void reset() { sourceText.value = ''; translatedText.value = ''; + translatedImageBytes.value = null; pickedImageFile.value = null; _updateStep('等待操作', '请选择图片开始翻译', ProcessingStatus.idle); } diff --git a/lib/modules/picture_translation/models/picture_translation_models.dart b/lib/modules/picture_translation/models/picture_translation_models.dart index f0f24fadd..cd0340735 100644 --- a/lib/modules/picture_translation/models/picture_translation_models.dart +++ b/lib/modules/picture_translation/models/picture_translation_models.dart @@ -132,3 +132,64 @@ class ProcessingStep { ); } } + +/// 图片翻译返回的坐标点模型(用于文本块四个点) +class Point2D { + final int x; + final int y; + + Point2D({required this.x, required this.y}); + + /// 从JSON创建 + factory Point2D.fromJson(Map json) { + return Point2D( + x: (json['X'] ?? 0) as int, + y: (json['Y'] ?? 0) as int, + ); + } +} + +/// 图片翻译返回的文本块模型 +class ImageTextBlock { + final List points; // 文本块四点坐标 + final String detectedLanguage; // 检测到的语种 + final String text; // 识别内容 + final String translation; // 翻译结果 + final List? foreColor; // 文字平均色 RGBA + final List? backColor; // 背景平均色 RGBA + + ImageTextBlock({ + required this.points, + required this.detectedLanguage, + required this.text, + required this.translation, + this.foreColor, + this.backColor, + }); + + /// 从JSON创建 + factory ImageTextBlock.fromJson(Map json) { + final List pts = (json['Points'] ?? []) as List; + return ImageTextBlock( + points: pts.map((e) => Point2D.fromJson(e as Map)).toList(), + detectedLanguage: (json['DetectedLanguage'] ?? '') as String, + text: (json['Text'] ?? '') as String, + translation: (json['Translation'] ?? '') as String, + foreColor: (json['ForeColor'] as List?)?.map((e) => e as int).toList(), + backColor: (json['BackColor'] as List?)?.map((e) => e as int).toList(), + ); + } +} + +/// 图片翻译结果模型(包含回贴后的图片和文本块列表) +class ImageTranslationResult { + final List? translatedImageBytes; // 回贴后的图片二进制(Uint8List) + final List blocks; // 文本块列表 + final String? detectedLanguage; // 可选:整体检测到的语种 + + ImageTranslationResult({ + required this.translatedImageBytes, + required this.blocks, + this.detectedLanguage, + }); +} diff --git a/lib/modules/picture_translation/views/picture_translation_view.dart b/lib/modules/picture_translation/views/picture_translation_view.dart index 26897ff3e..e4ff23b48 100644 --- a/lib/modules/picture_translation/views/picture_translation_view.dart +++ b/lib/modules/picture_translation/views/picture_translation_view.dart @@ -15,9 +15,10 @@ class PictureTranslationView extends GetView { @override Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; return Scaffold( backgroundColor: Colors.grey[50], - appBar: _buildAppBar(), + appBar: _buildAppBar(isDarkMode), body: SafeArea( child: Obx(() { return CustomScrollView( @@ -53,20 +54,27 @@ class PictureTranslationView extends GetView { } /// 构建应用栏 - PreferredSizeWidget _buildAppBar() { + PreferredSizeWidget _buildAppBar(bool isDarkMode) { return AppBar( leading: IconButton( icon: Icon( Icons.arrow_back_ios, size: 20.w, - color: Colors.black87, + color: isDarkMode ? Colors.white : Colors.black87, ), onPressed: () => Get.back(), ), centerTitle: true, - title: const Text('图片翻译'), - backgroundColor: Colors.white, - foregroundColor: Colors.black87, + title: Text( + 'pictureTranslation'.tr, + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + color: isDarkMode ? Colors.white : Colors.black, + ), + ), + backgroundColor: isDarkMode ? Colors.black : Colors.white, + foregroundColor: isDarkMode ? Colors.white : Colors.black87, elevation: 0, actions: [ IconButton( @@ -492,6 +500,55 @@ class PictureTranslationView extends GetView { controller.targetLanguageChineseName.value, Colors.green, ), + if (controller.translatedImageBytes.value != null) ...[ + Padding( + padding: EdgeInsets.symmetric(horizontal: 16.w), + child: Text( + '翻译后的图片预览', + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + ), + SizedBox(height: 8.h), + Container( + height: 200.h, + margin: EdgeInsets.symmetric(horizontal: 16.w) + .copyWith(bottom: 16.h), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8.r), + border: Border.all(color: Colors.grey[300]!), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8.r), + child: Image.memory( + controller.translatedImageBytes.value!, + fit: BoxFit.cover, + width: double.infinity, + errorBuilder: (context, error, stackTrace) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, + size: 48.sp, color: Colors.red[400]), + SizedBox(height: 8.h), + Text( + '翻译图片加载失败', + style: TextStyle( + fontSize: 14.sp, color: Colors.red[600]), + ), + ], + ), + ); + }, + ), + ), + ), + ], ], ), ); diff --git a/lib/modules/text_translation/controllers/text_translation_controller.dart b/lib/modules/text_translation/controllers/text_translation_controller.dart index e8ed2e33d..00f86503d 100644 --- a/lib/modules/text_translation/controllers/text_translation_controller.dart +++ b/lib/modules/text_translation/controllers/text_translation_controller.dart @@ -10,7 +10,8 @@ import '../../../core/utils/logger.dart'; /// 文本翻译控制器 class TextTranslationController extends GetxController { // 依赖服务 - final VolcanoTranslationService _translationService = Get.find(); + final VolcanoTranslationService _translationService = + Get.find(); final LanguageManager _languageManager = Get.find(); final GetStorage _storage = GetStorage(); @@ -47,6 +48,12 @@ class TextTranslationController extends GetxController { /// 初始化翻译服务 Future _initializeTranslationService() async { try { + // 已初始化则跳过 + if (_translationService.isInitialized) { + Logger.info('翻译服务已初始化'); + return; + } + // final initialized = final initialized = await _translationService.initialize(); if (!initialized) { Logger.error('翻译服务初始化失败'); @@ -74,22 +81,28 @@ class TextTranslationController extends GetxController { /// 加载保存的语言设置 void _loadSavedLanguages() { try { - final savedSourceLanguage = _storage.read('text_translation_source_language'); - final savedTargetLanguage = _storage.read('text_translation_target_language'); + final savedSourceLanguage = + _storage.read('text_translation_source_language'); + final savedTargetLanguage = + _storage.read('text_translation_target_language'); - if (savedSourceLanguage != null && supportedLanguages.contains(savedSourceLanguage)) { + if (savedSourceLanguage != null && + supportedLanguages.contains(savedSourceLanguage)) { sourceLanguageChineseName.value = savedSourceLanguage; // 同时更新语言代码 - final asrCode = _languageManager.getAsrCodeByChineseName(savedSourceLanguage); + final asrCode = + _languageManager.getAsrCodeByChineseName(savedSourceLanguage); if (asrCode != null) { sourceLanguageCode.value = asrCode; } } - if (savedTargetLanguage != null && supportedLanguages.contains(savedTargetLanguage)) { + if (savedTargetLanguage != null && + supportedLanguages.contains(savedTargetLanguage)) { targetLanguageChineseName.value = savedTargetLanguage; // 同时更新语言代码 - final asrCode = _languageManager.getAsrCodeByChineseName(savedTargetLanguage); + final asrCode = + _languageManager.getAsrCodeByChineseName(savedTargetLanguage); if (asrCode != null) { targetLanguageCode.value = asrCode; } @@ -102,8 +115,10 @@ class TextTranslationController extends GetxController { /// 保存语言设置 void _saveLanguageSettings() { try { - _storage.write('text_translation_source_language', sourceLanguageChineseName.value); - _storage.write('text_translation_target_language', targetLanguageChineseName.value); + _storage.write( + 'text_translation_source_language', sourceLanguageChineseName.value); + _storage.write( + 'text_translation_target_language', targetLanguageChineseName.value); } catch (e) { Logger.error('保存语言设置失败: $e'); } @@ -173,20 +188,21 @@ class TextTranslationController extends GetxController { /// 执行翻译 Future translateText() async { final text = textController.text.trim(); - + // 验证输入 if (text.isEmpty) { - Get.snackbar( - 'error'.tr, - 'pleaseInputText'.tr, - snackPosition: SnackPosition.TOP, - backgroundColor: Colors.orange.withOpacity(0.9), - colorText: Colors.white, - ); + // Get.snackbar( + // 'error'.tr, + // 'pleaseInputText'.tr, + // snackPosition: SnackPosition.TOP, + // backgroundColor: Colors.orange.withOpacity(0.9), + // colorText: Colors.white, + // ); return; } - - if (text.length > 1000) { + + if (text.length > 5000) { + // 5000 字符限制 Get.snackbar( 'error'.tr, 'textTooLong'.tr, @@ -196,24 +212,29 @@ class TextTranslationController extends GetxController { ); return; } - + // 检查源语言和目标语言是否相同 if (sourceLanguageChineseName.value == targetLanguageChineseName.value) { translatedText.value = text; return; } - + isTranslating.value = true; translatedText.value = ''; - + try { Logger.info('开始翻译: $text'); - Logger.info('源语言: ${sourceLanguageChineseName.value} -> ${sourceLanguageCode.value}'); - Logger.info('目标语言: ${targetLanguageChineseName.value} -> ${targetLanguageCode.value}'); + Logger.info( + '源语言: ${sourceLanguageChineseName.value} -> ${sourceLanguageCode.value}'); + Logger.info( + '目标语言: ${targetLanguageChineseName.value} -> ${targetLanguageCode.value}'); // 确保翻译服务已初始化 Logger.info('确保翻译服务已初始化...'); - final initialized = await _translationService.initialize(); + bool initialized = _translationService.isInitialized; + if (!initialized) { + initialized = await _translationService.initialize(); + } Logger.info('翻译服务初始化结果: $initialized'); if (!initialized) { throw Exception('翻译服务初始化失败'); @@ -289,7 +310,9 @@ class TextTranslationController extends GetxController { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - isSourceLanguage ? 'sourceLanguage'.tr : 'targetLanguage'.tr, + isSourceLanguage + ? 'sourceLanguage'.tr + : 'targetLanguage'.tr, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, @@ -311,10 +334,12 @@ class TextTranslationController extends GetxController { final isSelected = isSourceLanguage ? language == sourceLanguageChineseName.value : language == targetLanguageChineseName.value; - + return ListTile( title: Text(language), - trailing: isSelected ? const Icon(Icons.check, color: Colors.blue) : null, + trailing: isSelected + ? const Icon(Icons.check, color: Colors.blue) + : null, onTap: () { if (isSourceLanguage) { setSourceLanguage(language); diff --git a/lib/modules/text_translation/views/text_translation_view.dart b/lib/modules/text_translation/views/text_translation_view.dart index c652f1cd6..561c7d2f5 100644 --- a/lib/modules/text_translation/views/text_translation_view.dart +++ b/lib/modules/text_translation/views/text_translation_view.dart @@ -55,12 +55,13 @@ class TextTranslationView extends GetView { ), onPressed: () => Get.back(), ), + centerTitle: true, title: Text( 'textTranslation'.tr, style: TextStyle( - color: isDarkMode ? Colors.white : Colors.black, fontSize: 18.sp, fontWeight: FontWeight.w600, + color: isDarkMode ? Colors.white : Colors.black, ), ), actions: [