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:
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user