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/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import '../../core/config/app_config.dart';
|
||||||
import '../../core/config/app_info.dart';
|
import '../../core/config/app_info.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../core/update/app_updater.dart';
|
import '../../core/update/app_updater.dart';
|
||||||
import '../../core/utils/dialog_util.dart';
|
import '../../core/utils/dialog_util.dart';
|
||||||
|
import '../../providers/feedback_provider.dart';
|
||||||
import '../../providers/license_provider.dart';
|
import '../../providers/license_provider.dart';
|
||||||
import '../../providers/update_provider.dart';
|
import '../../providers/update_provider.dart';
|
||||||
|
|
||||||
@@ -249,39 +252,191 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showFeedbackDialog({required bool isBug}) {
|
void _showFeedbackDialog({required bool isBug}) {
|
||||||
final ctrl = TextEditingController();
|
|
||||||
showAppDialog(
|
showAppDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (_) => _FeedbackDialog(type: isBug ? 'bug' : 'suggestion'),
|
||||||
title: Text(isBug ? '反馈 Bug' : '功能建议'),
|
);
|
||||||
content: SizedBox(
|
}
|
||||||
width: 400,
|
}
|
||||||
child: TextField(
|
|
||||||
controller: ctrl,
|
/// 反馈表单弹窗:文字 + 附图,直接提交后台。
|
||||||
maxLines: 6,
|
class _FeedbackDialog extends ConsumerStatefulWidget {
|
||||||
decoration: InputDecoration(
|
final String type; // bug / suggestion
|
||||||
hintText: isBug ? '请描述问题的复现步骤和预期行为…' : '请描述您希望增加的功能…',
|
const _FeedbackDialog({required this.type});
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
|
@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: [
|
Positioned(
|
||||||
TextButton(
|
right: -8,
|
||||||
onPressed: () => Navigator.pop(ctx),
|
top: -8,
|
||||||
child: const Text('取消'),
|
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');
|
Widget _addButton() {
|
||||||
if (await canLaunchUrl(uri)) launchUrl(uri);
|
return InkWell(
|
||||||
if (ctx.mounted) Navigator.pop(ctx);
|
onTap: _uploading ? null : _addImages,
|
||||||
},
|
child: Container(
|
||||||
child: const Text('通过邮件发送'),
|
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