From 36e5677b7ca30b59f427f8b22ad0a44bb669e01a Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 5 Jun 2026 21:31:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E5=8F=8D=E9=A6=88=20Bug/?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=BB=BA=E8=AE=AE=20=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E6=8F=90=E4=BA=A4=E5=90=8E=E5=8F=B0=EF=BC=88?= =?UTF-8?q?=E6=96=87=E5=AD=97+=E9=99=84=E5=9B=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feedback_repository + provider:图片上传 /feedback/images、提交 /feedback - 关于我们的反馈入口改为表单弹窗:多行文字 + 选图上传(缩略图/可删,最多9张), 提交到后台,不再走 mailto - 平台/版本随提交上报(web 安全:defaultTargetPlatform,不引 dart:io) Co-Authored-By: Claude Opus 4.8 --- client/lib/providers/feedback_provider.dart | 7 + .../lib/repositories/feedback_repository.dart | 78 +++++++ client/lib/screens/about/about_screen.dart | 209 +++++++++++++++--- 3 files changed, 267 insertions(+), 27 deletions(-) create mode 100644 client/lib/providers/feedback_provider.dart create mode 100644 client/lib/repositories/feedback_repository.dart diff --git a/client/lib/providers/feedback_provider.dart b/client/lib/providers/feedback_provider.dart new file mode 100644 index 0000000..308fb67 --- /dev/null +++ b/client/lib/providers/feedback_provider.dart @@ -0,0 +1,7 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/api/api_client.dart'; +import '../repositories/feedback_repository.dart'; + +final feedbackRepositoryProvider = Provider((ref) { + return FeedbackRepository(ref.watch(apiClientProvider)); +}); diff --git a/client/lib/repositories/feedback_repository.dart b/client/lib/repositories/feedback_repository.dart new file mode 100644 index 0000000..210167e --- /dev/null +++ b/client/lib/repositories/feedback_repository.dart @@ -0,0 +1,78 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import '../core/api/api_client.dart'; +import '../core/exceptions.dart'; + +class FeedbackRepository { + final ApiClient _client; + const FeedbackRepository(this._client); + + /// 上传单张图片,返回服务器相对 URL(/images/feedback/...)。 + Future uploadImage({ + Uint8List? bytes, + String? filePath, + String filename = 'image.jpg', + }) async { + try { + final MultipartFile file; + if (bytes != null) { + file = MultipartFile.fromBytes(bytes, filename: filename); + } else { + file = await MultipartFile.fromFile(filePath!); + } + final formData = FormData.fromMap({'file': file}); + final resp = await _client.post('/feedback/images', data: formData); + return (resp.data as Map)['url'] as String; + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '图片上传失败', + statusCode: e.response?.statusCode, + ); + } + } + + /// 提交反馈(bug / suggestion),文字 + 图片 URL 列表。 + Future submit({ + required String type, + required String content, + required List images, + }) async { + String appVersion = ''; + try { + appVersion = (await PackageInfo.fromPlatform()).version; + } catch (_) {} + try { + await _client.post('/feedback', data: { + 'type': type, + 'content': content, + 'images': images, + 'app_version': appVersion, + 'platform': _platform(), + }); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '提交失败', + statusCode: e.response?.statusCode, + ); + } + } + + String _platform() { + if (kIsWeb) return 'web'; + switch (defaultTargetPlatform) { + case TargetPlatform.windows: + return 'windows'; + case TargetPlatform.macOS: + return 'macos'; + case TargetPlatform.android: + return 'android'; + case TargetPlatform.iOS: + return 'ios'; + case TargetPlatform.linux: + return 'linux'; + default: + return 'unknown'; + } + } +} diff --git a/client/lib/screens/about/about_screen.dart b/client/lib/screens/about/about_screen.dart index b5f3504..d25b57b 100644 --- a/client/lib/screens/about/about_screen.dart +++ b/client/lib/screens/about/about_screen.dart @@ -1,12 +1,15 @@ +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../../core/config/app_config.dart'; import '../../core/config/app_info.dart'; import '../../core/theme/app_theme.dart'; import '../../core/update/app_updater.dart'; import '../../core/utils/dialog_util.dart'; +import '../../providers/feedback_provider.dart'; import '../../providers/license_provider.dart'; import '../../providers/update_provider.dart'; @@ -249,39 +252,191 @@ class _AboutScreenState extends ConsumerState { } void _showFeedbackDialog({required bool isBug}) { - final ctrl = TextEditingController(); showAppDialog( context: context, - builder: (ctx) => AlertDialog( - title: Text(isBug ? '反馈 Bug' : '功能建议'), - content: SizedBox( - width: 400, - child: TextField( - controller: ctrl, - maxLines: 6, - decoration: InputDecoration( - hintText: isBug ? '请描述问题的复现步骤和预期行为…' : '请描述您希望增加的功能…', - border: const OutlineInputBorder(), + builder: (_) => _FeedbackDialog(type: isBug ? 'bug' : 'suggestion'), + ); + } +} + +/// 反馈表单弹窗:文字 + 附图,直接提交后台。 +class _FeedbackDialog extends ConsumerStatefulWidget { + final String type; // bug / suggestion + const _FeedbackDialog({required this.type}); + + @override + ConsumerState<_FeedbackDialog> createState() => _FeedbackDialogState(); +} + +class _FeedbackDialogState extends ConsumerState<_FeedbackDialog> { + final _ctrl = TextEditingController(); + final List _images = []; // 已上传的图片 URL + bool _uploading = false; + bool _submitting = false; + + static const _maxImages = 9; + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + Future _addImages() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.image, + allowMultiple: true, + withData: true, + ); + if (result == null) return; + setState(() => _uploading = true); + try { + final repo = ref.read(feedbackRepositoryProvider); + for (final f in result.files) { + if (_images.length >= _maxImages) break; + if (f.bytes == null) continue; + final url = await repo.uploadImage(bytes: f.bytes!, filename: f.name); + if (!mounted) return; + setState(() => _images.add(url)); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('图片上传失败:$e'))); + } + } finally { + if (mounted) setState(() => _uploading = false); + } + } + + Future _submit() async { + if (_ctrl.text.trim().isEmpty && _images.isEmpty) { + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('请填写反馈内容或添加图片'))); + return; + } + setState(() => _submitting = true); + try { + await ref.read(feedbackRepositoryProvider).submit( + type: widget.type, + content: _ctrl.text.trim(), + images: _images, + ); + if (!mounted) return; + Navigator.pop(context); + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('反馈已提交,感谢您的反馈!'))); + } catch (e) { + if (!mounted) return; + setState(() => _submitting = false); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('提交失败:$e'))); + } + } + + @override + Widget build(BuildContext context) { + final isBug = widget.type == 'bug'; + return AlertDialog( + title: Text(isBug ? '反馈 Bug' : '功能建议'), + content: SizedBox( + width: 440, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _ctrl, + maxLines: 6, + decoration: InputDecoration( + hintText: isBug ? '请描述问题的复现步骤和预期行为…' : '请描述您希望增加的功能…', + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (int i = 0; i < _images.length; i++) _thumb(i), + if (_images.length < _maxImages) _addButton(), + ], + ), + if (_uploading) + const Padding( + padding: EdgeInsets.only(top: 8), + child: Row(children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2)), + SizedBox(width: 8), + Text('图片上传中…', style: TextStyle(fontSize: 12)), + ]), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: _submitting ? null : () => Navigator.pop(context), + child: const Text('取消'), + ), + ElevatedButton( + onPressed: (_submitting || _uploading) ? null : _submit, + child: Text(_submitting ? '提交中…' : '提交'), + ), + ], + ); + } + + Widget _thumb(int i) { + return Stack( + clipBehavior: Clip.none, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.network( + '${AppConfig.baseUrl}${_images[i]}', + width: 64, + height: 64, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + width: 64, + height: 64, + color: Colors.grey.shade200, + child: const Icon(Icons.broken_image, size: 20), ), ), ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('取消'), + Positioned( + right: -8, + top: -8, + child: GestureDetector( + onTap: () => setState(() => _images.removeAt(i)), + child: Container( + decoration: const BoxDecoration( + color: Colors.black54, shape: BoxShape.circle), + padding: const EdgeInsets.all(2), + child: const Icon(Icons.close, size: 14, color: Colors.white), + ), ), - ElevatedButton( - onPressed: () async { - final subject = Uri.encodeComponent(isBug ? 'Bug反馈' : '功能建议'); - final body = Uri.encodeComponent(ctrl.text); - final uri = Uri.parse( - 'mailto:${AppInfo.email}?subject=$subject&body=$body'); - if (await canLaunchUrl(uri)) launchUrl(uri); - if (ctx.mounted) Navigator.pop(ctx); - }, - child: const Text('通过邮件发送'), - ), - ], + ), + ], + ); + } + + Widget _addButton() { + return InkWell( + onTap: _uploading ? null : _addImages, + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(6), + ), + child: const Icon(Icons.add_a_photo_outlined, color: Colors.grey), ), ); }