From 786182501661a07ed8451aac04172b79627c13f5 Mon Sep 17 00:00:00 2001 From: CNDY1390 Date: Mon, 9 Nov 2026 15:37:21 +0800 Subject: [PATCH 1/7] feat(feedback): add in-app feedback form --- lib/pages/feedback_page.dart | 126 +++++++++++++++++++++++++++++ lib/pages/settings_page.dart | 18 +++++ lib/services/feedback_service.dart | 50 ++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 lib/pages/feedback_page.dart create mode 100644 lib/services/feedback_service.dart diff --git a/lib/pages/feedback_page.dart b/lib/pages/feedback_page.dart new file mode 100644 index 00000000..f156b398 --- /dev/null +++ b/lib/pages/feedback_page.dart @@ -0,0 +1,126 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../services/feedback_service.dart'; +import '../utils/platform.dart'; +import '../widgets/adaptive_button.dart'; +import '../widgets/adaptive_page_navigation.dart'; +import '../widgets/app_shell/app_shell_metrics.dart'; +import '../widgets/blurred_app_bar.dart'; +import '../widgets/ios/ios_native_navigation_bar.dart'; + +class FeedbackPage extends StatefulWidget { + const FeedbackPage({super.key, required this.service}); + final FeedbackService service; + + @override + State createState() => _FeedbackPageState(); +} + +class _FeedbackPageState extends State { + final _message = TextEditingController(); + bool _busy = false; + bool _sent = false; + String? _error; + + @override + void dispose() { + _message.dispose(); + super.dispose(); + } + + Future _submit() async { + if (_busy || _sent) return; + FocusScope.of(context).unfocus(); + setState(() { + _busy = true; + _error = null; + }); + try { + await widget.service.submit(_message.text); + if (mounted) setState(() => _sent = true); + } catch (error) { + if (mounted) { + setState(() { + _error = error is TimeoutException + ? '未能确认送达,内容已保留,请稍后再试' + : error is Exception && error.toString().startsWith('Exception: ') + ? error.toString().substring(11) + : '反馈未发送成功,内容已保留'; + }); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final useIosChrome = isIos(); + final topInset = useIosChrome || usesLegacyIosChrome() + ? 0.0 + : adaptiveTopBarHeight() + MediaQuery.viewPaddingOf(context).top; + return Scaffold( + extendBodyBehindAppBar: !useIosChrome && !usesLegacyIosChrome(), + appBar: useIosChrome + ? IosNativeNavigationBar( + title: '问题反馈 / 功能建议', + leadingItems: const [ + IosNativeNavigationBarItem( + id: 'back', + title: 'Settings', + sfSymbol: 'chevron.left', + accessibilityLabel: '返回 Settings', + placementGroup: 'leading-main', + ), + ], + onItemPressed: (_) => unawaited(maybePopAdaptivePage(context)), + ) + : const BlurredAppBar(title: Text('问题反馈 / 功能建议')), + body: ListView( + padding: EdgeInsets.fromLTRB( + 20, + topInset + 20, + 20, + AppShellMetrics.bottomContentPaddingOf(context) + 20, + ), + children: _sent + ? const [ + SizedBox(height: 40), + Icon(Icons.check_circle_outline, size: 64), + SizedBox(height: 20), + Text('反馈已发送', textAlign: TextAlign.center), + ] + : [ + TextField( + controller: _message, + enabled: !_busy, + minLines: 6, + maxLines: 12, + maxLength: 2000, + decoration: const InputDecoration( + hintText: '遇到了什么问题,或希望增加什么功能?需要回复可留下联系方式。', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + const Text('发送给 TechPie 运维群,附账号 ID、版本与平台;请勿填写密码。'), + if (_error != null) ...[ + const SizedBox(height: 16), + Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], + const SizedBox(height: 20), + AdaptiveButton( + label: '提交反馈', + icon: Icons.send_outlined, + sfSymbol: 'paperplane', + role: AdaptiveButtonRole.prominent, + loading: _busy, + onPressed: _busy ? null : () => unawaited(_submit()), + ), + ], + ), + ); + } +} diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 599e8cd4..18a42df3 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -8,6 +8,8 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:techpie/services/auth_service.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../services/api_base_url.dart'; +import '../services/feedback_service.dart'; import '../services/service_provider.dart'; import '../services/sync_service.dart'; import '../services/theme_service.dart'; @@ -32,6 +34,7 @@ import 'account_deletion_page.dart'; import 'debug_log_page.dart'; import 'debug_webview_page.dart'; import 'developer_lab_page.dart'; +import 'feedback_page.dart'; import 'sync_settings_page.dart'; import 'third_party_accounts_page.dart'; import 'watch_settings_page.dart'; @@ -331,6 +334,21 @@ class _SettingsPageState extends State { ), const Divider(), + ListTile( + leading: const Icon(Icons.feedback_outlined), + title: const Text('问题反馈 / 功能建议'), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + if (!auth.isLoggedIn) await startUniAuthLogin(context); + if (!context.mounted || !auth.isLoggedIn) return; + await pushAdaptivePage( + context, + builder: (_) => FeedbackPage( + service: FeedbackService(auth, apiBaseUrl(sp.storageService)), + ), + ); + }, + ), ListTile( leading: const Icon(Icons.privacy_tip_outlined), title: const Text('隐私政策与使用支持'), diff --git a/lib/services/feedback_service.dart b/lib/services/feedback_service.dart new file mode 100644 index 00000000..3999de8e --- /dev/null +++ b/lib/services/feedback_service.dart @@ -0,0 +1,50 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:package_info_plus/package_info_plus.dart'; + +import 'auth_service.dart'; + +class FeedbackService { + FeedbackService(this._auth, this._baseUrl); + + final AuthService _auth; + final String _baseUrl; + + Future submit(String message) async { + final text = message.trim(); + if (text.isEmpty || text.length > 2000) { + throw Exception('请填写 1–2000 字的反馈内容'); + } + final userId = _auth.session?.userId; + if (userId == null || userId.isEmpty) throw Exception('请先登录 TechPie'); + final info = await PackageInfo.fromPlatform(); + await _auth.tryRenewSession(); + final session = _auth.session; + final token = session?.geekpieToken; + if (session?.userId != userId || token == null || token.isEmpty) { + throw Exception('登录已变化,请重新登录后提交'); + } + // Feedback is user-authored private text; keep it out of diagnostic logs. + final response = await http + .post( + Uri.parse('$_baseUrl/feedback'), + headers: { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + }, + body: jsonEncode({ + 'message': text, + 'version': '${info.version}+${info.buildNumber}', + 'platform': kIsWeb ? 'web' : defaultTargetPlatform.name, + }), + ) + .timeout(const Duration(seconds: 25)); + if (response.statusCode == 401) throw Exception('登录已失效,请重新登录'); + if (response.statusCode == 429) throw Exception('提交太频繁,请稍后再试'); + if (response.statusCode != 200 || jsonDecode(response.body)['success'] != true) { + throw Exception('反馈未发送成功,请稍后再试'); + } + } +} From 2b9dd8e25c0e6f73513a3f5b093be20e22c96998 Mon Sep 17 00:00:00 2001 From: CNDY1390 Date: Mon, 9 Nov 2026 15:39:17 +0800 Subject: [PATCH 2/7] style(feedback): shorten submission copy --- lib/pages/feedback_page.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pages/feedback_page.dart b/lib/pages/feedback_page.dart index f156b398..fce309f7 100644 --- a/lib/pages/feedback_page.dart +++ b/lib/pages/feedback_page.dart @@ -100,12 +100,12 @@ class _FeedbackPageState extends State { maxLines: 12, maxLength: 2000, decoration: const InputDecoration( - hintText: '遇到了什么问题,或希望增加什么功能?需要回复可留下联系方式。', + hintText: '描述问题或建议,需要回复可留下联系方式。', border: OutlineInputBorder(), ), ), const SizedBox(height: 12), - const Text('发送给 TechPie 运维群,附账号 ID、版本与平台;请勿填写密码。'), + const Text('提交时附带账号 ID、App 版本和平台。'), if (_error != null) ...[ const SizedBox(height: 16), Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), From 12bd0c27232a62f0dd3a1db27df70e6242cbf265 Mon Sep 17 00:00:00 2001 From: CNDY1390 Date: Mon, 9 Nov 2026 15:56:25 +0800 Subject: [PATCH 3/7] feat(feedback): make profile sharing opt-in --- lib/pages/feedback_page.dart | 39 +++++++++++++++++++++++++++--- lib/services/feedback_service.dart | 11 ++++++++- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/lib/pages/feedback_page.dart b/lib/pages/feedback_page.dart index fce309f7..7c5bf8cb 100644 --- a/lib/pages/feedback_page.dart +++ b/lib/pages/feedback_page.dart @@ -20,6 +20,9 @@ class FeedbackPage extends StatefulWidget { class _FeedbackPageState extends State { final _message = TextEditingController(); + final _contact = TextEditingController(); + bool _includeUsername = false; + bool _includeEmail = false; bool _busy = false; bool _sent = false; String? _error; @@ -27,6 +30,7 @@ class _FeedbackPageState extends State { @override void dispose() { _message.dispose(); + _contact.dispose(); super.dispose(); } @@ -38,7 +42,12 @@ class _FeedbackPageState extends State { _error = null; }); try { - await widget.service.submit(_message.text); + await widget.service.submit( + _message.text, + includeUsername: _includeUsername, + includeEmail: _includeEmail, + contact: _contact.text, + ); if (mounted) setState(() => _sent = true); } catch (error) { if (mounted) { @@ -100,12 +109,36 @@ class _FeedbackPageState extends State { maxLines: 12, maxLength: 2000, decoration: const InputDecoration( - hintText: '描述问题或建议,需要回复可留下联系方式。', + hintText: '描述问题或建议', border: OutlineInputBorder(), ), ), const SizedBox(height: 12), - const Text('提交时附带账号 ID、App 版本和平台。'), + CheckboxListTile( + title: const Text('发送用户名'), + value: _includeUsername, + onChanged: _busy ? null : (value) => setState(() => _includeUsername = value!), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ), + CheckboxListTile( + title: const Text('发送邮箱'), + value: _includeEmail, + onChanged: _busy ? null : (value) => setState(() => _includeEmail = value!), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ), + TextField( + controller: _contact, + enabled: !_busy, + maxLength: 200, + decoration: const InputDecoration( + labelText: '补充联系方式(选填)', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + const Text('自动附带 App 版本和平台。'), if (_error != null) ...[ const SizedBox(height: 16), Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), diff --git a/lib/services/feedback_service.dart b/lib/services/feedback_service.dart index 3999de8e..263c970f 100644 --- a/lib/services/feedback_service.dart +++ b/lib/services/feedback_service.dart @@ -12,11 +12,17 @@ class FeedbackService { final AuthService _auth; final String _baseUrl; - Future submit(String message) async { + Future submit( + String message, { + bool includeUsername = false, + bool includeEmail = false, + String contact = '', + }) async { final text = message.trim(); if (text.isEmpty || text.length > 2000) { throw Exception('请填写 1–2000 字的反馈内容'); } + if (contact.trim().length > 200) throw Exception('联系方式请勿超过 200 字'); final userId = _auth.session?.userId; if (userId == null || userId.isEmpty) throw Exception('请先登录 TechPie'); final info = await PackageInfo.fromPlatform(); @@ -38,6 +44,9 @@ class FeedbackService { 'message': text, 'version': '${info.version}+${info.buildNumber}', 'platform': kIsWeb ? 'web' : defaultTargetPlatform.name, + 'includeUsername': includeUsername, + 'includeEmail': includeEmail, + 'contact': contact.trim(), }), ) .timeout(const Duration(seconds: 25)); From b95a9c5ac0e422df46ebcb26389cf660a9de9ac7 Mon Sep 17 00:00:00 2001 From: CNDY1390 Date: Mon, 9 Nov 2026 16:23:38 +0800 Subject: [PATCH 4/7] feat(feedback): add optional image attachments --- lib/pages/feedback_page.dart | 77 +++++++++++++++++++++++++- lib/services/feedback_service.dart | 29 +++++++++- macos/Runner/DebugProfile.entitlements | 2 + macos/Runner/Release.entitlements | 2 + 4 files changed, 105 insertions(+), 5 deletions(-) diff --git a/lib/pages/feedback_page.dart b/lib/pages/feedback_page.dart index 7c5bf8cb..534b5b8b 100644 --- a/lib/pages/feedback_page.dart +++ b/lib/pages/feedback_page.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; import '../services/feedback_service.dart'; import '../utils/platform.dart'; @@ -21,6 +23,8 @@ class FeedbackPage extends StatefulWidget { class _FeedbackPageState extends State { final _message = TextEditingController(); final _contact = TextEditingController(); + final _images = []; + bool _picking = false; bool _includeUsername = false; bool _includeEmail = false; bool _busy = false; @@ -35,7 +39,7 @@ class _FeedbackPageState extends State { } Future _submit() async { - if (_busy || _sent) return; + if (_busy || _picking || _sent) return; FocusScope.of(context).unfocus(); setState(() { _busy = true; @@ -47,6 +51,7 @@ class _FeedbackPageState extends State { includeUsername: _includeUsername, includeEmail: _includeEmail, contact: _contact.text, + images: _images, ); if (mounted) setState(() => _sent = true); } catch (error) { @@ -64,6 +69,41 @@ class _FeedbackPageState extends State { } } + Future _pickImages() async { + if (_picking || _busy || _images.length >= 9) return; + setState(() { + _picking = true; + _error = null; + }); + try { + final files = await ImagePicker().pickMultiImage( + maxWidth: 1920, + maxHeight: 1920, + imageQuality: 85, + requestFullMetadata: false, + ); + final selected = []; + for (final file in files.take(9 - _images.length)) { + if (await file.length() > 4 * 1024 * 1024) throw Exception('每张图片请勿超过 4 MB'); + final bytes = await file.readAsBytes(); + final decoded = await decodeImageFromList(bytes); + decoded.dispose(); + selected.add(bytes); + } + if (mounted) setState(() => _images.addAll(selected)); + } catch (error) { + if (mounted) { + setState( + () => _error = error is Exception && error.toString().startsWith('Exception: ') + ? error.toString().substring(11) + : '未能添加图片,请重试', + ); + } + } finally { + if (mounted) setState(() => _picking = false); + } + } + @override Widget build(BuildContext context) { final useIosChrome = isIos(); @@ -114,6 +154,39 @@ class _FeedbackPageState extends State { ), ), const SizedBox(height: 12), + if (_images.isNotEmpty) + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (var i = 0; i < _images.length; i++) + Stack( + children: [ + Image.memory(_images[i], width: 88, height: 88, fit: BoxFit.cover), + Positioned( + top: 0, + right: 0, + child: IconButton.filledTonal( + tooltip: '移除第 ${i + 1} 张图片', + icon: const Icon(Icons.close, size: 18), + onPressed: _busy || _picking + ? null + : () => setState(() => _images.removeAt(i)), + ), + ), + ], + ), + ], + ), + AdaptiveButton( + label: '添加图片(${_images.length}/9)', + icon: Icons.add_photo_alternate_outlined, + sfSymbol: 'photo.badge.plus', + loading: _picking, + onPressed: _busy || _picking || _images.length >= 9 + ? null + : () => unawaited(_pickImages()), + ), CheckboxListTile( title: const Text('发送用户名'), value: _includeUsername, @@ -150,7 +223,7 @@ class _FeedbackPageState extends State { sfSymbol: 'paperplane', role: AdaptiveButtonRole.prominent, loading: _busy, - onPressed: _busy ? null : () => unawaited(_submit()), + onPressed: _busy || _picking ? null : () => unawaited(_submit()), ), ], ), diff --git a/lib/services/feedback_service.dart b/lib/services/feedback_service.dart index 263c970f..7b6e42c6 100644 --- a/lib/services/feedback_service.dart +++ b/lib/services/feedback_service.dart @@ -17,12 +17,17 @@ class FeedbackService { bool includeUsername = false, bool includeEmail = false, String contact = '', + List images = const [], }) async { final text = message.trim(); if (text.isEmpty || text.length > 2000) { throw Exception('请填写 1–2000 字的反馈内容'); } if (contact.trim().length > 200) throw Exception('联系方式请勿超过 200 字'); + if (images.length > 9) throw Exception('最多添加 9 张图片'); + if (images.any((image) => image.isEmpty || image.length > 4 * 1024 * 1024)) { + throw Exception('每张图片需在 4 MB 以内且不能为空'); + } final userId = _auth.session?.userId; if (userId == null || userId.isEmpty) throw Exception('请先登录 TechPie'); final info = await PackageInfo.fromPlatform(); @@ -33,6 +38,17 @@ class FeedbackService { throw Exception('登录已变化,请重新登录后提交'); } // Feedback is user-authored private text; keep it out of diagnostic logs. + final uploaded = >[]; + for (final image in images) { + if (_auth.session?.userId != userId) throw Exception('登录已变化,请重新提交'); + final request = http.MultipartRequest('POST', Uri.parse('$_baseUrl/feedback/images')) + ..headers['Authorization'] = 'Bearer $token' + ..files.add(http.MultipartFile.fromBytes('image', image, filename: 'feedback-image')); + final response = + await request.send().then(http.Response.fromStream).timeout(const Duration(seconds: 35)); + uploaded.add(_result(response)['data'] as Map); + } + if (_auth.session?.userId != userId) throw Exception('登录已变化,请重新提交'); final response = await http .post( Uri.parse('$_baseUrl/feedback'), @@ -47,13 +63,20 @@ class FeedbackService { 'includeUsername': includeUsername, 'includeEmail': includeEmail, 'contact': contact.trim(), + 'images': uploaded, }), ) .timeout(const Duration(seconds: 25)); + _result(response); + } + + Map _result(http.Response response) { if (response.statusCode == 401) throw Exception('登录已失效,请重新登录'); - if (response.statusCode == 429) throw Exception('提交太频繁,请稍后再试'); - if (response.statusCode != 200 || jsonDecode(response.body)['success'] != true) { - throw Exception('反馈未发送成功,请稍后再试'); + if (response.statusCode == 413) throw Exception('每张图片请勿超过 4 MB'); + final body = jsonDecode(response.body) as Map; + if (response.statusCode != 200 || body['success'] != true) { + throw Exception(body['error'] as String? ?? '反馈未发送成功,请稍后再试'); } + return body; } } diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 8f2eb20e..17a3b37e 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -12,6 +12,8 @@ com.apple.security.network.client + com.apple.security.files.user-selected.read-only + keychain-access-groups $(AppIdentifierPrefix)$(CFBundleIdentifier) diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index a042f0a3..3ecf203f 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -8,6 +8,8 @@ com.apple.security.network.client + com.apple.security.files.user-selected.read-only + keychain-access-groups $(AppIdentifierPrefix)$(CFBundleIdentifier) From 9e6f72f68a60d8f68f8b1516f544bec72751b6aa Mon Sep 17 00:00:00 2001 From: CNDY1390 Date: Mon, 9 Nov 2026 16:36:16 +0800 Subject: [PATCH 5/7] fix(feedback): retain image uploads and allow individual retries --- lib/pages/feedback_page.dart | 57 +++++++++++++++++--- lib/services/feedback_service.dart | 85 +++++++++++++++++++++++------- 2 files changed, 116 insertions(+), 26 deletions(-) diff --git a/lib/pages/feedback_page.dart b/lib/pages/feedback_page.dart index 534b5b8b..888c6ca0 100644 --- a/lib/pages/feedback_page.dart +++ b/lib/pages/feedback_page.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; @@ -23,7 +22,7 @@ class FeedbackPage extends StatefulWidget { class _FeedbackPageState extends State { final _message = TextEditingController(); final _contact = TextEditingController(); - final _images = []; + final _images = []; bool _picking = false; bool _includeUsername = false; bool _includeEmail = false; @@ -52,6 +51,7 @@ class _FeedbackPageState extends State { includeEmail: _includeEmail, contact: _contact.text, images: _images, + onImageChanged: _refreshImages, ); if (mounted) setState(() => _sent = true); } catch (error) { @@ -82,13 +82,13 @@ class _FeedbackPageState extends State { imageQuality: 85, requestFullMetadata: false, ); - final selected = []; + final selected = []; for (final file in files.take(9 - _images.length)) { - if (await file.length() > 4 * 1024 * 1024) throw Exception('每张图片请勿超过 4 MB'); + if (await file.length() > FeedbackService.imageLimit) throw Exception('每张图片请勿超过 4 MB'); final bytes = await file.readAsBytes(); final decoded = await decodeImageFromList(bytes); decoded.dispose(); - selected.add(bytes); + selected.add(FeedbackImage(bytes)); } if (mounted) setState(() => _images.addAll(selected)); } catch (error) { @@ -104,6 +104,25 @@ class _FeedbackPageState extends State { } } + void _refreshImages() { + if (mounted) setState(() {}); + } + + Future _retryImage(FeedbackImage image) async { + if (_busy || _picking) return; + setState(() { + _busy = true; + _error = null; + }); + try { + await widget.service.uploadImage(image, onChanged: _refreshImages); + } catch (_) { + if (mounted) setState(() => _error = '图片上传失败,可重试或移除'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + @override Widget build(BuildContext context) { final useIosChrome = isIos(); @@ -162,7 +181,33 @@ class _FeedbackPageState extends State { for (var i = 0; i < _images.length; i++) Stack( children: [ - Image.memory(_images[i], width: 88, height: 88, fit: BoxFit.cover), + Image.memory( + _images[i].bytes, + width: 88, + height: 88, + fit: BoxFit.cover, + ), + if (_images[i].uploading) + const Positioned.fill( + child: Center(child: CircularProgressIndicator()), + ), + if (_images[i].failed) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: FilledButton( + onPressed: + _busy ? null : () => unawaited(_retryImage(_images[i])), + child: const Text('重试'), + ), + ) + else if (_images[i].uploaded) + const Positioned( + bottom: 0, + left: 0, + child: Icon(Icons.check_circle, color: Colors.green), + ), Positioned( top: 0, right: 0, diff --git a/lib/services/feedback_service.dart b/lib/services/feedback_service.dart index 7b6e42c6..16ff5ca6 100644 --- a/lib/services/feedback_service.dart +++ b/lib/services/feedback_service.dart @@ -6,7 +6,18 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'auth_service.dart'; +class FeedbackImage { + FeedbackImage(this.bytes); + final Uint8List bytes; + bool uploading = false; + bool failed = false; + String? _userId; + Map? _receipt; + bool get uploaded => _receipt != null; +} + class FeedbackService { + static const imageLimit = 4000000; FeedbackService(this._auth, this._baseUrl); final AuthService _auth; @@ -17,7 +28,8 @@ class FeedbackService { bool includeUsername = false, bool includeEmail = false, String contact = '', - List images = const [], + List images = const [], + VoidCallback? onImageChanged, }) async { final text = message.trim(); if (text.isEmpty || text.length > 2000) { @@ -25,30 +37,17 @@ class FeedbackService { } if (contact.trim().length > 200) throw Exception('联系方式请勿超过 200 字'); if (images.length > 9) throw Exception('最多添加 9 张图片'); - if (images.any((image) => image.isEmpty || image.length > 4 * 1024 * 1024)) { + if (images.any((image) => image.bytes.isEmpty || image.bytes.length > imageLimit)) { throw Exception('每张图片需在 4 MB 以内且不能为空'); } - final userId = _auth.session?.userId; - if (userId == null || userId.isEmpty) throw Exception('请先登录 TechPie'); + final userId = _userId(); final info = await PackageInfo.fromPlatform(); - await _auth.tryRenewSession(); - final session = _auth.session; - final token = session?.geekpieToken; - if (session?.userId != userId || token == null || token.isEmpty) { - throw Exception('登录已变化,请重新登录后提交'); - } - // Feedback is user-authored private text; keep it out of diagnostic logs. - final uploaded = >[]; for (final image in images) { if (_auth.session?.userId != userId) throw Exception('登录已变化,请重新提交'); - final request = http.MultipartRequest('POST', Uri.parse('$_baseUrl/feedback/images')) - ..headers['Authorization'] = 'Bearer $token' - ..files.add(http.MultipartFile.fromBytes('image', image, filename: 'feedback-image')); - final response = - await request.send().then(http.Response.fromStream).timeout(const Duration(seconds: 35)); - uploaded.add(_result(response)['data'] as Map); + await uploadImage(image, onChanged: onImageChanged); } - if (_auth.session?.userId != userId) throw Exception('登录已变化,请重新提交'); + final token = await _token(userId); + // Feedback is user-authored private text; keep it out of diagnostic logs. final response = await http .post( Uri.parse('$_baseUrl/feedback'), @@ -63,13 +62,59 @@ class FeedbackService { 'includeUsername': includeUsername, 'includeEmail': includeEmail, 'contact': contact.trim(), - 'images': uploaded, + 'images': images.map((image) => image._receipt).toList(), }), ) .timeout(const Duration(seconds: 25)); _result(response); } + Future uploadImage(FeedbackImage image, {VoidCallback? onChanged}) async { + final userId = _userId(); + if (image._userId == userId && image.uploaded) return; + if (image.bytes.isEmpty || image.bytes.length > imageLimit) { + throw Exception('每张图片需在 4 MB 以内且不能为空'); + } + image._receipt = null; + image.failed = false; + image.uploading = true; + onChanged?.call(); + try { + final token = await _token(userId); + final request = http.MultipartRequest('POST', Uri.parse('$_baseUrl/feedback/images')) + ..headers['Authorization'] = 'Bearer $token' + ..files.add(http.MultipartFile.fromBytes('image', image.bytes, filename: 'feedback-image')); + final response = + await request.send().then(http.Response.fromStream).timeout(const Duration(seconds: 35)); + final receipt = _result(response)['data'] as Map; + if (_auth.session?.userId != userId) throw Exception('登录已变化,请重新提交'); + image._userId = userId; + image._receipt = receipt; + } catch (_) { + image.failed = true; + rethrow; + } finally { + image.uploading = false; + onChanged?.call(); + } + } + + String _userId() { + final userId = _auth.session?.userId; + if (userId == null || userId.isEmpty) throw Exception('请先登录 TechPie'); + return userId; + } + + Future _token(String userId) async { + await _auth.tryRenewSession(); + final session = _auth.session; + final token = session?.geekpieToken; + if (session?.userId != userId || token == null || token.isEmpty) { + throw Exception('登录已变化,请重新登录后提交'); + } + return token; + } + Map _result(http.Response response) { if (response.statusCode == 401) throw Exception('登录已失效,请重新登录'); if (response.statusCode == 413) throw Exception('每张图片请勿超过 4 MB'); From 5cbf292df37f3eca251275b9a9ddc36f8c2e6383 Mon Sep 17 00:00:00 2001 From: CNDY1390 Date: Mon, 9 Nov 2026 19:34:16 +0800 Subject: [PATCH 6/7] fix(feedback): explain invalid and excess image selections --- lib/pages/feedback_page.dart | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/pages/feedback_page.dart b/lib/pages/feedback_page.dart index 888c6ca0..ea4888e6 100644 --- a/lib/pages/feedback_page.dart +++ b/lib/pages/feedback_page.dart @@ -82,12 +82,17 @@ class _FeedbackPageState extends State { imageQuality: 85, requestFullMetadata: false, ); + if (files.length > 9 - _images.length) throw Exception('最多添加 9 张图片'); final selected = []; - for (final file in files.take(9 - _images.length)) { + for (final file in files) { if (await file.length() > FeedbackService.imageLimit) throw Exception('每张图片请勿超过 4 MB'); final bytes = await file.readAsBytes(); - final decoded = await decodeImageFromList(bytes); - decoded.dispose(); + try { + final decoded = await decodeImageFromList(bytes); + decoded.dispose(); + } catch (_) { + throw Exception('图片无法读取,请换一张图片'); + } selected.add(FeedbackImage(bytes)); } if (mounted) setState(() => _images.addAll(selected)); From 904dadb0281912afdc470dcd893b96d7684485f1 Mon Sep 17 00:00:00 2001 From: CNDY1390 Date: Mon, 9 Nov 2026 20:45:40 +0800 Subject: [PATCH 7/7] fix(feedback): preserve backend authentication errors --- lib/services/feedback_service.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/services/feedback_service.dart b/lib/services/feedback_service.dart index 16ff5ca6..0ddd4963 100644 --- a/lib/services/feedback_service.dart +++ b/lib/services/feedback_service.dart @@ -116,7 +116,6 @@ class FeedbackService { } Map _result(http.Response response) { - if (response.statusCode == 401) throw Exception('登录已失效,请重新登录'); if (response.statusCode == 413) throw Exception('每张图片请勿超过 4 MB'); final body = jsonDecode(response.body) as Map; if (response.statusCode != 200 || body['success'] != true) {