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:get/get.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import '../controllers/feedback_controller.dart'; |
|||
|
|||
class FeedbackView extends GetView<FeedbackController> { |
|||
const FeedbackView({Key? key}) : super(key: key); |
|||
import 'comment_webview.dart'; |
|||
|
|||
/// 用户反馈:嵌入 App Comment 加密评论页(WebView) |
|||
class FeedbackView extends StatelessWidget { |
|||
const FeedbackView({super.key}); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
|||
|
|||
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, |
|||
), |
|||
), |
|||
), |
|||
)), |
|||
], |
|||
), |
|||
), |
|||
), |
|||
); |
|||
return const CommentWebView(); |
|||
} |
|||
} |
|||
|
|||
Loading…
Reference in new issue