Browse Source
移除原有的反馈控制器、绑定及表单视图,新增基于 cryptography 的加密 URL 生成模块和 WebView 页面。用户反馈功能现通过加密参数跳转到统一的 App Comment 服务端页面,提升安全性与维护性。newdev_shunjiawei
7 changed files with 304 additions and 237 deletions
@ -1,9 +0,0 @@ |
|||||
import 'package:get/get.dart'; |
|
||||
import '../controllers/feedback_controller.dart'; |
|
||||
|
|
||||
class FeedbackBinding extends Bindings { |
|
||||
@override |
|
||||
void dependencies() { |
|
||||
Get.lazyPut<FeedbackController>(() => FeedbackController()); |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,86 @@ |
|||||
|
import 'dart:convert'; |
||||
|
import 'dart:math'; |
||||
|
|
||||
|
import 'package:cryptography/cryptography.dart'; |
||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
||||
|
|
||||
|
import '../../data/models/appconfig.dart'; |
||||
|
|
||||
|
/// 生成加密评论页 URL(与 App Comment 服务端 SIGN_SECRET 一致) |
||||
|
/// |
||||
|
/// [userId]、[packageName]、[appName]、[appVersion] 必填;[userName]、[lang] 可选。 |
||||
|
Future<String> buildCommentUrl({ |
||||
|
required String userId, |
||||
|
String? userName, |
||||
|
required String packageName, |
||||
|
required String appName, |
||||
|
required String appVersion, |
||||
|
String lang = 'en', |
||||
|
}) async { |
||||
|
final signSecret = _commentSignSecret(); |
||||
|
var commentServer = _commentServerUrl(); |
||||
|
|
||||
|
if (signSecret.isEmpty || commentServer.isEmpty) { |
||||
|
throw Exception( |
||||
|
'COMMENT_SIGN_SECRET / COMMENT_SERVER_URL 未配置:' |
||||
|
'请在 getAppConfig 的 env 中下发,或在 .env 中填写(本地调试)', |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
commentServer = commentServer.trim().replaceAll(RegExp(r'/$'), ''); |
||||
|
|
||||
|
final payload = { |
||||
|
'userId': userId, |
||||
|
'userName': userName ?? userId, |
||||
|
'packageName': packageName, |
||||
|
'appName': appName, |
||||
|
'appVersion': appVersion, |
||||
|
'lang': lang, |
||||
|
'ts': DateTime.now().millisecondsSinceEpoch ~/ 1000, |
||||
|
'nonce': _randomHex(8), |
||||
|
}; |
||||
|
|
||||
|
final algo = AesGcm.with256bits(); |
||||
|
final key = await algo.newSecretKeyFromBytes(_hexDecode(signSecret)); |
||||
|
final nonce = algo.newNonce(); |
||||
|
final box = await algo.encrypt( |
||||
|
utf8.encode(jsonEncode(payload)), |
||||
|
secretKey: key, |
||||
|
nonce: nonce, |
||||
|
); |
||||
|
|
||||
|
// 格式: iv(12 bytes) + tag(16 bytes) + ciphertext |
||||
|
final blob = <int>[...nonce, ...box.mac.bytes, ...box.cipherText]; |
||||
|
final token = base64Url.encode(blob).replaceAll('=', ''); |
||||
|
return '$commentServer/?d=$token'; |
||||
|
} |
||||
|
|
||||
|
/// 优先使用服务端 [getAppConfig] 下发的 `env`,否则回退 `.env`(本地调试)。 |
||||
|
String _commentSignSecret() { |
||||
|
if (AppConfig.isInitialized()) { |
||||
|
final v = AppConfig.env('COMMENT_SIGN_SECRET'); |
||||
|
if (v != null && v.isNotEmpty) return v; |
||||
|
} |
||||
|
return dotenv.env['COMMENT_SIGN_SECRET'] ?? ''; |
||||
|
} |
||||
|
|
||||
|
String _commentServerUrl() { |
||||
|
if (AppConfig.isInitialized()) { |
||||
|
final v = AppConfig.env('COMMENT_SERVER_URL'); |
||||
|
if (v != null && v.isNotEmpty) return v; |
||||
|
} |
||||
|
return dotenv.env['COMMENT_SERVER_URL'] ?? ''; |
||||
|
} |
||||
|
|
||||
|
List<int> _hexDecode(String hex) => List.generate( |
||||
|
hex.length ~/ 2, |
||||
|
(i) => int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16), |
||||
|
); |
||||
|
|
||||
|
String _randomHex(int byteLen) { |
||||
|
final r = Random.secure(); |
||||
|
return List.generate( |
||||
|
byteLen, |
||||
|
(_) => r.nextInt(256).toRadixString(16).padLeft(2, '0'), |
||||
|
).join(); |
||||
|
} |
||||
@ -1,59 +0,0 @@ |
|||||
import 'package:flutter/material.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import '../../../data/services/network/api.dart'; |
|
||||
|
|
||||
class FeedbackController extends GetxController { |
|
||||
final TextEditingController feedbackController = TextEditingController(); |
|
||||
//final TextEditingController contactController = TextEditingController(); |
|
||||
|
|
||||
final RxBool isSubmitting = false.obs; |
|
||||
final GlobalKey<FormState> formKey = GlobalKey<FormState>(); |
|
||||
|
|
||||
@override |
|
||||
void onClose() { |
|
||||
feedbackController.dispose(); |
|
||||
//contactController.dispose(); |
|
||||
super.onClose(); |
|
||||
} |
|
||||
|
|
||||
// 提交反馈 |
|
||||
Future<void> submitFeedback() async { |
|
||||
if (!formKey.currentState!.validate()) { |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
isSubmitting.value = true; |
|
||||
|
|
||||
try { |
|
||||
final feedbackText = feedbackController.text; |
|
||||
Map<String, dynamic> data = { |
|
||||
"message": feedbackText, // 反馈内容 |
|
||||
}; |
|
||||
|
|
||||
final response = await Api.feedback(data); |
|
||||
|
|
||||
if (response != null) { |
|
||||
Get.back(); // 返回上一页 |
|
||||
Get.snackbar( |
|
||||
'success'.tr, // 成功 |
|
||||
'feedbackSubmitSuccess'.tr, // 反馈提交成功 |
|
||||
snackPosition: SnackPosition.BOTTOM, |
|
||||
backgroundColor: Colors.green.withOpacity(0.7), |
|
||||
colorText: Colors.white, |
|
||||
margin: const EdgeInsets.all(10), |
|
||||
); |
|
||||
} |
|
||||
} catch (e) { |
|
||||
Get.snackbar( |
|
||||
'error'.tr, // 错误 |
|
||||
'feedbackSubmitFailed'.tr, // 反馈提交失败 |
|
||||
snackPosition: SnackPosition.BOTTOM, |
|
||||
backgroundColor: Colors.red.withOpacity(0.7), |
|
||||
colorText: Colors.white, |
|
||||
margin: const EdgeInsets.all(10), |
|
||||
); |
|
||||
} finally { |
|
||||
isSubmitting.value = false; |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,211 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:get_storage/get_storage.dart'; |
||||
|
import 'package:package_info_plus/package_info_plus.dart'; |
||||
|
import 'package:uuid/uuid.dart'; |
||||
|
import 'package:webview_flutter/webview_flutter.dart'; |
||||
|
|
||||
|
import '../../../core/theme/app_colors.dart'; |
||||
|
import '../../../core/utils/logger.dart'; |
||||
|
import '../../../data/models/user_Info.dart'; |
||||
|
import '../comment_url.dart'; |
||||
|
|
||||
|
/// 用户反馈:加载加密后的 App Comment 评论页(每次进入重新生成 URL) |
||||
|
class CommentWebView extends StatefulWidget { |
||||
|
const CommentWebView({super.key}); |
||||
|
|
||||
|
@override |
||||
|
State<CommentWebView> createState() => _CommentWebViewState(); |
||||
|
} |
||||
|
|
||||
|
class _CommentWebViewState extends State<CommentWebView> { |
||||
|
WebViewController? _controller; |
||||
|
bool _isLoading = true; |
||||
|
String? _error; |
||||
|
|
||||
|
static const _anonStorageKey = 'comment_feedback_anon_id'; |
||||
|
|
||||
|
@override |
||||
|
void initState() { |
||||
|
super.initState(); |
||||
|
_loadUrl(); |
||||
|
} |
||||
|
|
||||
|
Future<void> _loadUrl() async { |
||||
|
try { |
||||
|
final packageInfo = await PackageInfo.fromPlatform(); |
||||
|
final lang = Get.locale?.languageCode ?? 'en'; |
||||
|
|
||||
|
final String userId; |
||||
|
final String? userName; |
||||
|
if (User.isLoggedIn()) { |
||||
|
userId = User.instance.uid.isNotEmpty |
||||
|
? User.instance.uid |
||||
|
: _ensureAnonymousUserId(); |
||||
|
userName = User.instance.name.isNotEmpty ? User.instance.name : null; |
||||
|
} else { |
||||
|
userId = _ensureAnonymousUserId(); |
||||
|
userName = null; |
||||
|
} |
||||
|
|
||||
|
final url = await buildCommentUrl( |
||||
|
userId: userId, |
||||
|
userName: userName, |
||||
|
packageName: packageInfo.packageName, |
||||
|
appName: packageInfo.appName, |
||||
|
appVersion: '${packageInfo.version}+${packageInfo.buildNumber}', |
||||
|
lang: lang, |
||||
|
); |
||||
|
|
||||
|
Logger.debug('评论页 URL 已生成'); |
||||
|
|
||||
|
if (!mounted) return; |
||||
|
|
||||
|
setState(() { |
||||
|
_controller = WebViewController() |
||||
|
..setJavaScriptMode(JavaScriptMode.unrestricted) |
||||
|
..setBackgroundColor(Colors.white) |
||||
|
..setNavigationDelegate( |
||||
|
NavigationDelegate( |
||||
|
onPageFinished: (_) { |
||||
|
Logger.debug('WebView 页面加载完成'); |
||||
|
if (mounted) setState(() => _isLoading = false); |
||||
|
}, |
||||
|
onWebResourceError: (error) { |
||||
|
Logger.error( |
||||
|
'WebView 资源错误: code=${error.errorCode}, ' |
||||
|
'type=${error.errorType}, desc=${error.description}, ' |
||||
|
'mainFrame=${error.isForMainFrame}', |
||||
|
); |
||||
|
if (error.isForMainFrame == true && mounted) { |
||||
|
setState(() { |
||||
|
_isLoading = false; |
||||
|
_error = error.description; |
||||
|
}); |
||||
|
} |
||||
|
}, |
||||
|
onHttpError: (error) { |
||||
|
Logger.error( |
||||
|
'WebView HTTP 错误: status=${error.response?.statusCode}, ' |
||||
|
'uri=${error.request?.uri}', |
||||
|
); |
||||
|
}, |
||||
|
), |
||||
|
) |
||||
|
..setOnConsoleMessage((message) { |
||||
|
Logger.debug( |
||||
|
'[WebView Console] ${message.level.name}: ${message.message}', |
||||
|
); |
||||
|
}) |
||||
|
..loadRequest(Uri.parse(url)); |
||||
|
}); |
||||
|
} catch (e, st) { |
||||
|
Logger.error('生成评论页 URL 失败', e, st); |
||||
|
if (mounted) { |
||||
|
setState(() { |
||||
|
_isLoading = false; |
||||
|
_error = e.toString(); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
String _ensureAnonymousUserId() { |
||||
|
final storage = GetStorage(); |
||||
|
var id = storage.read<String>(_anonStorageKey); |
||||
|
if (id == null || id.isEmpty) { |
||||
|
id = const Uuid().v4(); |
||||
|
storage.write(_anonStorageKey, id); |
||||
|
} |
||||
|
return id; |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
||||
|
|
||||
|
return Scaffold( |
||||
|
backgroundColor: isDarkMode |
||||
|
? AppColors.darkBackground |
||||
|
: Theme.of(context).scaffoldBackgroundColor, |
||||
|
appBar: AppBar( |
||||
|
elevation: 0, |
||||
|
backgroundColor: isDarkMode ? AppColors.darkBackground : Colors.white, |
||||
|
leading: IconButton( |
||||
|
icon: Icon( |
||||
|
Icons.arrow_back_ios, |
||||
|
size: 20.sp, |
||||
|
color: isDarkMode ? AppColors.textPrimary : Colors.black87, |
||||
|
), |
||||
|
onPressed: () => Get.back(), |
||||
|
), |
||||
|
title: Text( |
||||
|
'feedback'.tr, |
||||
|
style: TextStyle( |
||||
|
fontSize: 17.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: isDarkMode ? AppColors.textPrimary : Colors.black87, |
||||
|
), |
||||
|
), |
||||
|
centerTitle: true, |
||||
|
), |
||||
|
body: _buildBody(isDarkMode), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildBody(bool isDarkMode) { |
||||
|
if (_error != null) { |
||||
|
final msgColor = |
||||
|
isDarkMode ? AppColors.textSecondary : Colors.black54; |
||||
|
return Center( |
||||
|
child: Padding( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 24.w), |
||||
|
child: Column( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
Icon( |
||||
|
Icons.error_outline, |
||||
|
size: 48.sp, |
||||
|
color: msgColor, |
||||
|
), |
||||
|
SizedBox(height: 16.h), |
||||
|
Text( |
||||
|
'loadFailed'.tr, |
||||
|
style: TextStyle(fontSize: 16.sp, color: msgColor), |
||||
|
textAlign: TextAlign.center, |
||||
|
), |
||||
|
SizedBox(height: 8.h), |
||||
|
Text( |
||||
|
_error!, |
||||
|
style: TextStyle(fontSize: 12.sp, color: msgColor), |
||||
|
textAlign: TextAlign.center, |
||||
|
maxLines: 4, |
||||
|
overflow: TextOverflow.ellipsis, |
||||
|
), |
||||
|
SizedBox(height: 16.h), |
||||
|
ElevatedButton( |
||||
|
onPressed: () { |
||||
|
setState(() { |
||||
|
_error = null; |
||||
|
_isLoading = true; |
||||
|
_controller = null; |
||||
|
}); |
||||
|
_loadUrl(); |
||||
|
}, |
||||
|
child: Text('retry'.tr), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return Stack( |
||||
|
children: [ |
||||
|
if (_controller != null) WebViewWidget(controller: _controller!), |
||||
|
if (_isLoading) const Center(child: CircularProgressIndicator()), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -1,174 +1,13 @@ |
|||||
import 'package:flutter/material.dart'; |
import 'package:flutter/material.dart'; |
||||
import 'package:get/get.dart'; |
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
||||
import '../controllers/feedback_controller.dart'; |
|
||||
|
|
||||
class FeedbackView extends GetView<FeedbackController> { |
import 'comment_webview.dart'; |
||||
const FeedbackView({Key? key}) : super(key: key); |
|
||||
|
/// 用户反馈:嵌入 App Comment 加密评论页(WebView) |
||||
|
class FeedbackView extends StatelessWidget { |
||||
|
const FeedbackView({super.key}); |
||||
|
|
||||
@override |
@override |
||||
Widget build(BuildContext context) { |
Widget build(BuildContext context) { |
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
return const CommentWebView(); |
||||
|
|
||||
return Scaffold( |
|
||||
backgroundColor: isDarkMode ? Colors.black : Colors.grey[100], |
|
||||
appBar: AppBar( |
|
||||
elevation: 0, |
|
||||
backgroundColor: isDarkMode ? Colors.black : Colors.white, |
|
||||
leading: IconButton( |
|
||||
icon: Icon( |
|
||||
Icons.arrow_back_ios, |
|
||||
size: 20.sp, |
|
||||
color: isDarkMode ? Colors.white : Colors.black, |
|
||||
), |
|
||||
onPressed: () => Get.back(), |
|
||||
), |
|
||||
title: Text( |
|
||||
'feedback'.tr, // 帮助与反馈 |
|
||||
style: TextStyle( |
|
||||
fontSize: 17.sp, |
|
||||
fontWeight: FontWeight.w600, |
|
||||
color: isDarkMode ? Colors.white : Colors.black, |
|
||||
), |
|
||||
), |
|
||||
centerTitle: true, |
|
||||
), |
|
||||
body: GestureDetector( |
|
||||
onTap: () => FocusScope.of(context).unfocus(), |
|
||||
child: Form( |
|
||||
key: controller.formKey, |
|
||||
child: ListView( |
|
||||
padding: EdgeInsets.all(16.w), |
|
||||
children: [ |
|
||||
// 顶部提示文字 |
|
||||
Text( |
|
||||
'feedbackDescription'.tr, // 请描述您遇到的问题或建议 |
|
||||
style: TextStyle( |
|
||||
fontSize: 14.sp, |
|
||||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|
||||
), |
|
||||
), |
|
||||
SizedBox(height: 16.h), |
|
||||
|
|
||||
// 反馈内容输入框 - 统一容器样式 |
|
||||
Container( |
|
||||
decoration: BoxDecoration( |
|
||||
// 保持适合各自模式的背景色 |
|
||||
color: isDarkMode ? Colors.grey[800] : Colors.white, |
|
||||
// 统一圆角 |
|
||||
borderRadius: BorderRadius.circular(16.r), |
|
||||
// 统一阴影效果 - 关键改进点 |
|
||||
boxShadow: [ |
|
||||
BoxShadow( |
|
||||
color: isDarkMode |
|
||||
? Colors.black.withOpacity(0.3) |
|
||||
: Colors.black.withOpacity(0.08), |
|
||||
blurRadius: 12, |
|
||||
offset: const Offset(0, 4), |
|
||||
spreadRadius: 0, |
|
||||
), |
|
||||
], |
|
||||
// 统一边框样式 - 关键改进点 |
|
||||
border: Border.all( |
|
||||
color: isDarkMode ? Colors.grey[700]! : Colors.grey[200]!, |
|
||||
width: 1.5, |
|
||||
), |
|
||||
), |
|
||||
child: Padding( |
|
||||
padding: |
|
||||
EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), |
|
||||
child: TextFormField( |
|
||||
controller: controller.feedbackController, |
|
||||
maxLines: 8, |
|
||||
maxLength: 500, |
|
||||
style: TextStyle( |
|
||||
color: isDarkMode ? Colors.white : Colors.black87, |
|
||||
fontSize: 14.sp, |
|
||||
height: 1.4, |
|
||||
), |
|
||||
decoration: InputDecoration( |
|
||||
hintText: 'feedbackHint'.tr, // 请输入您的反馈内容... |
|
||||
hintStyle: TextStyle( |
|
||||
color: isDarkMode ? Colors.grey[400] : Colors.grey[500], |
|
||||
fontSize: 14.sp, |
|
||||
), |
|
||||
border: InputBorder.none, |
|
||||
focusedBorder: InputBorder.none, |
|
||||
counterStyle: TextStyle( |
|
||||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
|
||||
fontSize: 12.sp, |
|
||||
), |
|
||||
contentPadding: EdgeInsets.zero, |
|
||||
fillColor: Colors.transparent, |
|
||||
filled: true, |
|
||||
), |
|
||||
validator: (value) { |
|
||||
if (value == null || value.trim().isEmpty) { |
|
||||
return 'feedbackRequired'.tr; // 请输入反馈内容 |
|
||||
} |
|
||||
return null; |
|
||||
}, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
SizedBox(height: 40.h), |
|
||||
|
|
||||
// 提交按钮 - 统一容器样式 |
|
||||
Obx(() => Container( |
|
||||
// 统一按钮容器装饰 |
|
||||
decoration: BoxDecoration( |
|
||||
borderRadius: BorderRadius.circular(16.r), |
|
||||
// 统一阴影效果 |
|
||||
boxShadow: controller.isSubmitting.value |
|
||||
? [] |
|
||||
: [ |
|
||||
BoxShadow( |
|
||||
color: Colors.blue.withOpacity(0.3), |
|
||||
blurRadius: 8, |
|
||||
offset: const Offset(0, 4), |
|
||||
spreadRadius: 0, |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
child: ElevatedButton( |
|
||||
onPressed: controller.isSubmitting.value |
|
||||
? null |
|
||||
: controller.submitFeedback, |
|
||||
style: ElevatedButton.styleFrom( |
|
||||
backgroundColor: controller.isSubmitting.value |
|
||||
? Colors.grey[400] |
|
||||
: Colors.blue[600], |
|
||||
padding: EdgeInsets.symmetric(vertical: 14.h), |
|
||||
shape: RoundedRectangleBorder( |
|
||||
borderRadius: BorderRadius.circular(16.r), |
|
||||
), |
|
||||
elevation: 0, |
|
||||
shadowColor: Colors.transparent, |
|
||||
), |
|
||||
child: controller.isSubmitting.value |
|
||||
? SizedBox( |
|
||||
height: 20.h, |
|
||||
width: 20.h, |
|
||||
child: CircularProgressIndicator( |
|
||||
strokeWidth: 2.w, |
|
||||
valueColor: const AlwaysStoppedAnimation<Color>( |
|
||||
Colors.white), |
|
||||
), |
|
||||
) |
|
||||
: Text( |
|
||||
'submit'.tr, // 提交 |
|
||||
style: TextStyle( |
|
||||
fontSize: 16.sp, |
|
||||
fontWeight: FontWeight.w600, |
|
||||
color: Colors.white, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
)), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
} |
||||
} |
} |
||||
|
|||||
Loading…
Reference in new issue