feat(client): 反馈 Bug/功能建议 改为直接提交后台(文字+附图)

- feedback_repository + provider:图片上传 /feedback/images、提交 /feedback
- 关于我们的反馈入口改为表单弹窗:多行文字 + 选图上传(缩略图/可删,最多9张),
  提交到后台,不再走 mailto
- 平台/版本随提交上报(web 安全:defaultTargetPlatform,不引 dart:io)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-05 21:31:46 +08:00
parent 7a1d8465e5
commit 36e5677b7c
3 changed files with 267 additions and 27 deletions
@@ -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<FeedbackRepository>((ref) {
return FeedbackRepository(ref.watch(apiClientProvider));
});
@@ -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<String> 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<String, dynamic>)['url'] as String;
} on DioException catch (e) {
throw AppException(
e.response?.data?['error'] as String? ?? '图片上传失败',
statusCode: e.response?.statusCode,
);
}
}
/// 提交反馈(bug / suggestion),文字 + 图片 URL 列表。
Future<void> submit({
required String type,
required String content,
required List<String> 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';
}
}
}
+182 -27
View File
@@ -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<AboutScreen> {
}
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<String> _images = []; // 已上传的图片 URL
bool _uploading = false;
bool _submitting = false;
static const _maxImages = 9;
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
Future<void> _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<void> _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),
),
);
}