diff --git a/lib/pages/home/home_page.dart b/lib/pages/home/home_page.dart index 539f3d7..7f25b79 100644 --- a/lib/pages/home/home_page.dart +++ b/lib/pages/home/home_page.dart @@ -3,6 +3,7 @@ import 'package:unionapp/pages/music/music_page.dart'; import '../../service/recommendation_helper.dart'; import '../../service/song_service.dart'; import '../../tool/gradientText.dart'; +import '../score/updateScorePage.dart'; import '../user/userpage.dart'; import '../scorelist.dart'; import 'package:provider/provider.dart'; @@ -182,7 +183,7 @@ class HomePage extends StatelessWidget { const SizedBox(width: 4), Expanded( flex: 6, - child: _buildQuickActionButtons(), // 快捷按钮组件 + child: _buildQuickActionButtons(context), // 快捷按钮组件 ), const SizedBox(width: 10), // 左右间距 ], @@ -195,7 +196,7 @@ class HomePage extends StatelessWidget { ); } // 右侧快捷按钮区域(你可以自由修改图标、文字、点击事件) - Widget _buildQuickActionButtons() { + Widget _buildQuickActionButtons(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -225,7 +226,12 @@ class HomePage extends StatelessWidget { end: Alignment.bottomRight, ), color: Colors.pinkAccent.shade100, - onTap: () {}, + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const BindAccountPage()), + ); + }, ), _quickActionItem( icon: Icons.stars_outlined, diff --git a/lib/pages/score/updateScorePage.dart b/lib/pages/score/updateScorePage.dart index acfd8c1..cb05dab 100644 --- a/lib/pages/score/updateScorePage.dart +++ b/lib/pages/score/updateScorePage.dart @@ -7,29 +7,29 @@ import 'package:provider/provider.dart'; import '../../providers/user_provider.dart'; -class UpdateScorePage extends StatefulWidget { - const UpdateScorePage({super.key}); +class BindAccountPage extends StatefulWidget { + const BindAccountPage({super.key}); @override - State createState() => _UpdateScorePageState(); + State createState() => _BindAccountPageState(); } -class _UpdateScorePageState extends State { - final TextEditingController _segaIdController = TextEditingController(); +class _BindAccountPageState extends State { + final TextEditingController _qrContentController = TextEditingController(); final ImagePicker _picker = ImagePicker(); bool _isProcessing = false; String? _statusMessage; - File? _selectedImageFile; // 用于预览 + File? _selectedImageFile; // 用户选择的原始二维码截图 - // 条码扫描器 (支持 QR Code, Data Matrix, Aztec 等) + // 条码扫描器 final BarcodeScanner _barcodeScanner = BarcodeScanner( - formats: [BarcodeFormat.qrCode, BarcodeFormat.dataMatrix, BarcodeFormat.aztec], + formats: [BarcodeFormat.qrCode], ); @override void dispose() { - _segaIdController.dispose(); + _qrContentController.dispose(); _barcodeScanner.close(); super.dispose(); } @@ -46,7 +46,6 @@ class _UpdateScorePageState extends State { _selectedImageFile = File(pickedFile.path); }); - // 执行二维码识别 final inputImage = InputImage.fromFilePath(pickedFile.path); final List barcodes = await _barcodeScanner.processImage(inputImage); @@ -54,23 +53,15 @@ class _UpdateScorePageState extends State { _isProcessing = false; }); - if (barcodes.isNotEmpty) { - // 取第一个识别到的二维码内容 - final String code = barcodes.first.rawValue ?? ""; - - if (code.isNotEmpty) { - setState(() { - _segaIdController.text = code; - _statusMessage = "识别成功: $code"; - }); - } else { - setState(() { - _statusMessage = "二维码内容为空"; - }); - } + if (barcodes.isNotEmpty && barcodes.first.rawValue != null) { + final String code = barcodes.first.rawValue!; + setState(() { + _qrContentController.text = code; + _statusMessage = "识别成功"; + }); } else { setState(() { - _statusMessage = "未在图中发现二维码,请手动输入"; + _statusMessage = "未识别到二维码,请手动输入"; }); } @@ -82,13 +73,13 @@ class _UpdateScorePageState extends State { } } - // 2. 执行隐写上传 - Future _handleUpload() async { - final segaId = _segaIdController.text.trim(); + // 2. 执行隐写并上传 (触发后端绑定/登录逻辑) + Future _handleBind() async { + final qrContent = _qrContentController.text.trim(); - if (segaId.isEmpty) { + if (qrContent.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("请输入或扫描二维码"), backgroundColor: Colors.orange), + const SnackBar(content: Text("请输入或扫描二维码内容"), backgroundColor: Colors.orange), ); return; } @@ -97,58 +88,54 @@ class _UpdateScorePageState extends State { setState(() { _isProcessing = true; - _statusMessage = "正在准备图片..."; + _statusMessage = "正在处理图片..."; }); try { - // 策略:优先使用用户选择的截图作为载体。 - // 如果用户没选图,或者选图后删除了,我们可以下载默认背景图作为载体。 - if (_selectedImageFile != null) { - imageBytes = await _selectedImageFile!.readAsBytes(); - } else { - // 下载默认背景图作为隐写载体 - final httpClient = HttpClient(); - final request = await httpClient.getUrl(Uri.parse('https://union.godserver.cn/assets/jpeg/20180621142015_5FmGZ-wYXkyL4y.jpeg')); - final response = await request.close(); - - // 辅助函数:将 HttpClientResponse 转为 Uint8List - final bytes = []; - await for (final chunk in response) { - bytes.addAll(chunk); - } - imageBytes = Uint8List.fromList(bytes); + final httpClient = HttpClient(); + final request = await httpClient.getUrl(Uri.parse('https://union.godserver.cn/assets/jpeg/20180621142015_5FmGZ-wYXkyL4y.jpeg')); + final response = await request.close(); + final bytes = []; + await for (final chunk in response) { + bytes.addAll(chunk); } + imageBytes = Uint8List.fromList(bytes); - if (imageBytes == null || imageBytes.isEmpty) { - throw Exception("图片数据无效"); - } + if (imageBytes == null || imageBytes.isEmpty) throw Exception("图片数据无效"); setState(() { - _statusMessage = "正在隐写并上传..."; + _statusMessage = "正在提交绑定请求..."; }); - // 调用 Provider 上传 + // 调用 Provider + // 注意:这里 segaId 参数其实传的是 QR Code 的内容 final userProvider = context.read(); - final result = await userProvider.uploadStegImage(imageBytes, segaId: segaId); + final result = await userProvider.uploadStegImage(imageBytes, segaId: qrContent); setState(() { _isProcessing = false; - _statusMessage = "上传成功!"; }); + // 处理后端返回 if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("上传成功: ${result['msg']}"), backgroundColor: Colors.green), - ); - // 可选:清空表单 - // _segaIdController.clear(); - // _selectedImageFile = null; + if (result['code'] == 200) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("✅ ${result['msg']}"), backgroundColor: Colors.green, duration: const Duration(seconds: 5)), + ); + // 绑定成功后,通常建议刷新用户信息 + await userProvider.initUser(); + } else { + // 后端返回 500 或其他错误 + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("❌ ${result['msg']}"), backgroundColor: Colors.red, duration: const Duration(seconds: 5)), + ); + } } } catch (e) { setState(() { _isProcessing = false; - _statusMessage = "上传失败: $e"; + _statusMessage = "网络或处理错误: $e"; }); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -161,10 +148,15 @@ class _UpdateScorePageState extends State { @override Widget build(BuildContext context) { return Scaffold( + appBar: AppBar( + title: const Text("绑定 / 刷新 Sega 账号"), + backgroundColor: Colors.black.withOpacity(0.5), + elevation: 0, + ), body: Stack( fit: StackFit.expand, children: [ - // 1. 背景图片 + // 背景图 Image.network( 'https://union.godserver.cn/assets/jpeg/20180621142015_5FmGZ-wYXkyL4y.jpeg', fit: BoxFit.cover, @@ -172,15 +164,13 @@ class _UpdateScorePageState extends State { if (loadingProgress == null) return child; return Container(color: Colors.black); }, - errorBuilder: (context, error, stackTrace) { - return Container(color: Colors.grey[900]); - }, + errorBuilder: (context, error, stackTrace) => Container(color: Colors.grey[900]), ), - // 2. 深色遮罩 - Container(color: Colors.black.withOpacity(0.7)), + // 遮罩 + Container(color: Colors.black.withOpacity(0.75)), - // 3. 主体内容 + // 内容 SafeArea( child: Center( child: SingleChildScrollView( @@ -188,37 +178,39 @@ class _UpdateScorePageState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - // 标题 + const Icon(Icons.qr_code_scanner, size: 60, color: Colors.white70), + const SizedBox(height: 16), const Text( - "更新成绩 / 二维码", - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Colors.white, - ), + "请提供 Sega 登录二维码", + style: TextStyle(fontSize: 18, color: Colors.white, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + const Text( + "支持识别截图或手动输入二维码内容\n用于绑定账号或刷新登录状态", + style: TextStyle(fontSize: 14, color: Colors.white60), + textAlign: TextAlign.center, ), const SizedBox(height: 30), - // 卡片容器 + // 输入卡片 Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( - color: Colors.white.withOpacity(0.15), + color: Colors.white.withOpacity(0.1), borderRadius: BorderRadius.circular(16), border: Border.all(color: Colors.white.withOpacity(0.2)), ), child: Column( children: [ - // 输入框 TextField( - controller: _segaIdController, + controller: _qrContentController, style: const TextStyle(color: Colors.white), + maxLines: 10, decoration: InputDecoration( - labelText: "Sega ID / 二维码内容", + labelText: "二维码内容 (QR Data)", labelStyle: const TextStyle(color: Colors.white70), - hintText: "手动输入或点击下方按钮识别", + hintText: "例如: SGWCMAID...", hintStyle: const TextStyle(color: Colors.white38), - prefixIcon: const Icon(Icons.qr_code, color: Colors.white70), enabledBorder: OutlineInputBorder( borderSide: const BorderSide(color: Colors.white38), borderRadius: BorderRadius.circular(12), @@ -232,60 +224,42 @@ class _UpdateScorePageState extends State { const SizedBox(height: 20), - // 图片预览区域 (如果有) + // 预览图 if (_selectedImageFile != null) ClipRRect( borderRadius: BorderRadius.circular(8), - child: Image.file( - _selectedImageFile!, - height: 150, - fit: BoxFit.contain, - ), + child: Image.file(_selectedImageFile!, height: 120, fit: BoxFit.contain), ), if (_selectedImageFile != null) const SizedBox(height: 15), - // 操作按钮组 + // 按钮组 Row( children: [ - // 识别二维码按钮 Expanded( child: ElevatedButton.icon( onPressed: _isProcessing ? null : _pickImageAndScan, - icon: const Icon(Icons.camera_alt), + icon: const Icon(Icons.image_search), label: const Text("识别截图"), style: ElevatedButton.styleFrom( backgroundColor: Colors.blueAccent, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), ), ), ), - - const SizedBox(width: 10), - - // 上传按钮 + const SizedBox(width: 12), Expanded( - flex: 1, child: ElevatedButton.icon( - onPressed: _isProcessing ? null : _handleUpload, + onPressed: _isProcessing ? null : _handleBind, icon: _isProcessing - ? const SizedBox( - width: 16, height: 16, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), - ) - : const Icon(Icons.cloud_upload), - label: Text(_isProcessing ? "处理中..." : "上传"), + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.login), + label: Text(_isProcessing ? "处理中" : "绑定/刷新"), style: ElevatedButton.styleFrom( backgroundColor: Colors.green, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), ), ), ), @@ -295,17 +269,14 @@ class _UpdateScorePageState extends State { ), ), - // 状态提示 + // 状态消息 if (_statusMessage != null) Padding( padding: const EdgeInsets.only(top: 20), child: Text( _statusMessage!, style: TextStyle( - color: _statusMessage!.contains("失败") || _statusMessage!.contains("错误") - ? Colors.redAccent - : Colors.white70, - fontSize: 14, + color: _statusMessage!.contains("失败") || _statusMessage!.contains("错误") ? Colors.redAccent : Colors.white70, ), textAlign: TextAlign.center, ), diff --git a/lib/service/steganography_util.dart b/lib/service/steganography_util.dart index c7b23e2..dca01ce 100644 --- a/lib/service/steganography_util.dart +++ b/lib/service/steganography_util.dart @@ -1,114 +1,104 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:image/image.dart' as img; -import '../tool/encryption_util.dart'; class SteganographyUtil { static const String MAGIC_HEADER = 'SGWCMAID'; - /// 图片隐写:将数据藏进图片 R 通道最低位 + /// 图片隐写:写入数据 static Uint8List hideDataInImage(Uint8List imageBytes, String secretData) { - if (!secretData.startsWith(MAGIC_HEADER)) { - throw Exception('数据格式不正确,必须以 $MAGIC_HEADER 开头'); - } - - // 1. 加密 + 压缩 - final encryptedString = EncryptionUtil.encryptAndCompress(secretData); - - // 2. 转为字节并添加 \0 结束符 - final dataBytes = utf8.encode(encryptedString); - final payload = Uint8List(dataBytes.length + 1); - payload.setRange(0, dataBytes.length, dataBytes); - payload[dataBytes.length] = 0; // \0 - - // 3. 解码图片 + // 1. 解码图片 final image = img.decodeImage(imageBytes); if (image == null) throw Exception('图片解码失败'); - // 4. 获取像素数据 - final imageData = image.data; - if (imageData == null) throw Exception('图片数据为空'); + // 2. 准备数据:必须以 MAGIC_HEADER 开头,并以 \0 结尾 + final dataBytes = utf8.encode(secretData); + final payload = Uint8List(dataBytes.length + 1); + payload.setRange(0, dataBytes.length, dataBytes); + payload[dataBytes.length] = 0; // 终止符 - // 【关键修复】ByteBuffer 必须转换为 Uint8List 才能进行 [] 操作和获取 length - // 注意:asUint8List() 返回的是原 buffer 的视图,修改它会直接修改 image 数据 - final Uint8List pixels = imageData.buffer.asUint8List(); - - // 检查容量 (R通道只有 width * height 个字节可用) - // pixels.length 是总字节数 (width * height * 4) - final maxBits = pixels.length ~/ 4; - if (payload.length * 8 > maxBits) { - throw Exception('图片容量不足!需要 ${payload.length * 8} bits,图片仅提供 ${maxBits} bits'); + // 检查容量 + if (payload.length * 8 > image.width * image.height) { + throw Exception('图片像素不足以容纳数据'); } - // 5. 执行 LSB 隐写 - _writeLsbRChannel(pixels, payload); + int payloadIndex = 0; + int bitIndex = 7; // MSB First (最高位优先) - // 6. 导出 PNG - return img.encodePng(image); + // ✅ 修复:必须严格按照 Java 的 (y, x) 顺序遍历,确保坐标对齐 + bool finished = false; + for (int y = 0; y < image.height; y++) { + for (int x = 0; x < image.width; x++) { + if (payloadIndex >= payload.length) { + finished = true; + break; + } + + // 获取当前坐标的像素 + final pixel = image.getPixel(x, y); + + // 取出当前字节的特定位 + int currentByte = payload[payloadIndex]; + int bit = (currentByte >> bitIndex) & 1; + + // ✅ 修复:通过 red 分量操作,屏蔽不同图片格式(RGBA/BGRA)的底层差异 + int r = pixel.r.toInt(); + int g = pixel.g.toInt(); + int b = pixel.b.toInt(); + int a = pixel.a.toInt(); + + // 修改 R 通道最低位 + r = (r & 0xFE) | bit; + + // 写回像素(保持其他通道不变) + image.setPixel(x, y, img.ColorRgba8(r, g, b, a)); + + bitIndex--; + if (bitIndex < 0) { + bitIndex = 7; + payloadIndex++; + } + } + if (finished) break; + } + + // ✅ 必须导出为 PNG,防止 JPEG 压缩破坏 LSB 位 + return Uint8List.fromList(img.encodePng(image)); } - /// 从图片中提取数据 + /// 提取数据(本地调试用,逻辑需与写入完全对称) static String? extractDataFromImage(Uint8List imageBytes) { final image = img.decodeImage(imageBytes); if (image == null) return null; - final imageData = image.data; - if (imageData == null) return null; - - // 【关键修复】转换为 Uint8List - final Uint8List pixels = imageData.buffer.asUint8List(); - - final List extractedBytes = []; - int currentByteValue = 0; + List extractedBytes = []; + int currentByte = 0; int bitCount = 0; - // 遍历像素 (RGBA, 步长 4) - for (int i = 0; i < pixels.length; i += 4) { - // 只取 R 通道 (index i) 的最低位 - int bit = pixels[i] & 1; + outer: + for (int y = 0; y < image.height; y++) { + for (int x = 0; x < image.width; x++) { + final pixel = image.getPixel(x, y); + int bit = pixel.r.toInt() & 1; - currentByteValue = (currentByteValue << 1) | bit; - bitCount++; + // MSB First 组装字节 + currentByte = (currentByte << 1) | bit; + bitCount++; - if (bitCount == 8) { - if (currentByteValue == 0) { - break; // 遇到 \0 停止 + if (bitCount == 8) { + if (currentByte == 0) break outer; // 结束符 + extractedBytes.add(currentByte); + currentByte = 0; + bitCount = 0; } - extractedBytes.add(currentByteValue); - currentByteValue = 0; - bitCount = 0; } } if (extractedBytes.isEmpty) return null; - try { return utf8.decode(extractedBytes); } catch (e) { return null; } } - - /// 核心写入:仅修改 R 通道 - static void _writeLsbRChannel(Uint8List pixels, Uint8List payload) { - int payloadIndex = 0; - int bitIndex = 0; // 0-7 - - for (int i = 0; i < pixels.length; i += 4) { - if (payloadIndex >= payload.length) break; - - int currentByte = payload[payloadIndex]; - // 取当前字节的第 bitIndex 位 (从高位到低位) - int bit = (currentByte >> (7 - bitIndex)) & 1; - - // 修改 R 通道: 清除最低位,写入新位 - pixels[i] = (pixels[i] & 0xFE) | bit; - - bitIndex++; - if (bitIndex > 7) { - bitIndex = 0; - payloadIndex++; - } - } - } } \ No newline at end of file diff --git a/lib/service/user_service.dart b/lib/service/user_service.dart index 462a55c..71404ed 100644 --- a/lib/service/user_service.dart +++ b/lib/service/user_service.dart @@ -350,7 +350,8 @@ class UserService { }) async { try { // 1. 拼接隐写数据(SGWCMAID 开头) - final secretData = 'SGWCMAID$segaId'; + final secretData = '$segaId'; + print(secretData); // 2. 执行隐写 + 加密 // 使用 Uint8List.fromList 确保类型兼容,避免 typed_data_patch 错误 @@ -378,14 +379,11 @@ class UserService { // 5. 【核心上传】隐写图片 final res = await _dio.post( - '$baseUrl/api/sega/steg', + '$baseUrl/api/sega/phone', data: formData, options: Options( headers: { - "Authorization": token, - // Dio 会自动处理 multipart/form-data 的 boundary,通常不需要手动指定 Content-Type - // 但为了严格对齐 JS,如果后端强校验,可以保留,不过 Dio 推荐让它在 FormData 时自动设置 - }, + "Authorization": token,}, ), ); @@ -405,6 +403,7 @@ class UserService { options: Options(headers: {"Authorization": token}), ); + print(res.data); // 返回最终结果 if (res.data is Map) { return Map.from(res.data);