16 changed files with 250 additions and 89 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

+1 -1
View File
@@ -5,4 +5,4 @@ flutter build apk --release --split-per-abi
# echo "构建完成时间: $(date '+%Y-%m-%d %H:%M:%S')"
pgyer upload build/app/outputs/flutter-apk/app-arm64-v8a-release.apk --build-update-description "新增可视化请求插件"
pgyer upload build/app/outputs/flutter-apk/app-arm64-v8a-release.apk --build-update-description "主裁判 APP $(date '+%Y-%m-%d %H:%M:%S')"
+3
View File
@@ -17,6 +17,9 @@ enum AuthApi {
/// 人工设置晋级/淘汰
setTeamStatus('/api/events/device/schedule/team/score'),
/// 获取登录设备信息
getLoginDeviceInfo('/api/events/device/nas/info'),
/// 获取选手的赛事信息
playerRegistrationList('/api/events/device/player/registration/list');
+17 -3
View File
@@ -38,11 +38,15 @@ class ApiClient {
return _parseData<T>(raw, parser);
}
if (raw is! Map<String, dynamic>) {
final payload = raw is Map ? Map<String, dynamic>.from(raw) : null;
if (payload == null ||
!(payload.containsKey('code') || payload.containsKey('message'))) {
return _parseData<T>(raw, parser);
}
final wrapped = ApiResponse<T>.fromJson(raw, fromJsonT: parser);
// 先按 {code,message,data} 解包,再用业务 parser 解析 data
// 避免把 Map 直接强转成业务模型。
final wrapped = ApiResponse<dynamic>.fromJson(payload);
if (!wrapped.isSuccess) {
throw ApiException(
code: wrapped.code,
@@ -52,7 +56,17 @@ class ApiClient {
);
}
return wrapped.data as T;
if (wrapped.data == null) {
if (null is T) return null as T;
throw ApiException(
code: wrapped.code,
statusCode: response.statusCode,
message: '响应 data 为空',
details: raw,
);
}
return _parseData<T>(wrapped.data, parser);
} on DioException catch (error) {
throw _mapDioException(error);
}
+17 -1
View File
@@ -11,10 +11,26 @@ class ApiResponse<T> {
Map<String, dynamic> json, {
T Function(dynamic json)? fromJsonT,
}) {
final rawData = json['data'];
T? data;
if (rawData != null && fromJsonT != null) {
data = fromJsonT(rawData);
} else if (rawData != null && fromJsonT == null) {
// 无 parser 时仅在类型已匹配时接收,避免 Map 被强转为业务模型。
if (rawData is T) {
data = rawData;
} else {
throw FormatException(
'ApiResponse data 类型不匹配,且未提供 fromJsonT;'
'期望 $T,实际 ${rawData.runtimeType}',
);
}
}
return ApiResponse<T>(
code: (json['code'] as num?)?.toInt() ?? 200,
message: (json['message'] ?? json['msg'] ?? '').toString(),
data: fromJsonT == null ? json['data'] as T? : fromJsonT(json['data']),
data: data,
);
}
}
+4
View File
@@ -18,6 +18,7 @@ class JwtDecodedData {
String? oId;
List<String>? oIds;
String? organizerId;
String? nasIpAddr;
JwtDecodedData({
this.authType,
@@ -28,6 +29,7 @@ class JwtDecodedData {
this.oId,
this.oIds,
this.organizerId,
this.nasIpAddr,
});
factory JwtDecodedData.fromJson(Map<String, dynamic> json) => JwtDecodedData(
@@ -41,6 +43,7 @@ class JwtDecodedData {
? []
: List<String>.from(json['oIds']!.map((x) => x?.toString())),
organizerId: json['organizerId']?.toString(),
nasIpAddr: json['nasIpAddr']?.toString(),
);
Map<String, dynamic> toJson() => {
@@ -52,6 +55,7 @@ class JwtDecodedData {
'oId': oId,
'oIds': oIds == null ? [] : List<dynamic>.from(oIds!.map((x) => x)),
'organizerId': organizerId,
'nasIpAddr': nasIpAddr,
};
}
+41 -21
View File
@@ -9,7 +9,6 @@ import 'package:recording_tool/core/utils/device_utils.dart';
import 'package:recording_tool/features/auth/view_model_auth/view_model_auth.dart';
import 'package:recording_tool/features/scan_qrcode/pages/page_scan_qrcode.dart';
import 'package:recording_tool/gen/assets.gen.dart';
import 'package:recording_tool/shared/widgets/app_button.dart';
import 'package:recording_tool/shared/widgets/app_dialog.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart';
@@ -23,14 +22,22 @@ class AuthPageWidget extends ConsumerStatefulWidget {
class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
late final TextEditingController _controller;
/// 记录点击次数
int _clickCount = 0;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: '905758');
_controller = TextEditingController(text: '');
WidgetsBinding.instance.addPostFrameCallback((_) async {
final token = AppStorage.getString(StorageKeys.authToken);
if (token?.isNotEmpty ?? false) {
final success = await ref
.read(authProvider.notifier)
.parseTokenSetState();
if (!success) {
return;
}
AppNavigator.push(const ScanQrCodePage());
}
});
@@ -42,6 +49,12 @@ class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
super.dispose();
}
void _showDeviceCodeDialog() async {
final deviceCode = await DeviceUtils.deviceCode();
if (!mounted) return;
AppDialog.confirm(context, title: '设备码:$deviceCode');
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
@@ -55,14 +68,11 @@ class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
backgroundColor: Colors.white,
body: Stack(
children: [
Positioned(
top: 0,
left: 0,
right: 0,
Positioned.fill(
child: Image.asset(
_AuthAssets.pageBg,
width: double.infinity,
fit: BoxFit.fitWidth,
fit: BoxFit.fill,
),
),
SafeArea(
@@ -74,11 +84,20 @@ class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
SizedBox(height: 190.h),
ClipRRect(
borderRadius: BorderRadius.circular(24.r),
child: Image.asset(
_AuthAssets.appIcon,
width: 82.w,
height: 82.w,
fit: BoxFit.cover,
child: GestureDetector(
onTap: () {
_clickCount++;
if (_clickCount >= 5) {
_showDeviceCodeDialog();
_clickCount = 0;
}
},
child: Image.asset(
_AuthAssets.appIcon,
width: 82.w,
height: 82.w,
fit: BoxFit.cover,
),
),
),
SizedBox(height: 18.h),
@@ -95,15 +114,15 @@ class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
isLoading: authState.isLoading,
onPressed: _handleSubmit,
),
SizedBox(height: 20.h),
AppButton(
onPressed: () async {
final deviceCode = await DeviceUtils.deviceCode();
if (!mounted) return;
AppDialog.confirm(context, title: '设备码:$deviceCode');
},
label: '获取设备码',
),
// SizedBox(height: 20.h),
// AppButton(
// onPressed: () async {
// final deviceCode = await DeviceUtils.deviceCode();
// if (!mounted) return;
// AppDialog.confirm(context, title: '设备码:$deviceCode');
// },
// label: '获取设备码',
// ),
],
),
),
@@ -121,6 +140,7 @@ class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
.auth(_controller.text);
if (!mounted) return;
if (success) {
_controller.clear();
AppNavigator.push(const ScanQrCodePage());
return;
}
+30 -4
View File
@@ -4,6 +4,7 @@ import 'package:recording_tool/app/config/api_common.dart';
import 'package:recording_tool/core/network/providers/dio_providers.dart';
import 'package:recording_tool/core/utils/device_utils.dart';
import 'package:recording_tool/features/auth/model/model_auth.dart';
import 'package:recording_tool/features/auth/model/model_jwt.dart';
class AuthServer {
/// [passCode] 口令码
@@ -29,15 +30,20 @@ class AuthServer {
/// 获取赛事列表
/// [path] 赛事目录
/// [nasIpAddr] NAS IP,由调用方传入,避免在 authProvider 内部再 read(authProvider)。
static Future<GetRecordListResModel?> getRecordList(
Ref ref,
String path,
) async {
Ref ref, {
required String path,
required String nasIpAddr,
}) async {
try {
if (nasIpAddr.isEmpty) {
throw const FormatException('无法获取NAS IP地址');
}
final apiClient = ref.read(apiClientProvider);
// NAS /api/files 直接返回 {path, items},不是业务网关的 {code,message,data} 包装。
final data = await apiClient.get<GetRecordListResModel>(
'http://sheling.local:9001/${AuthApi.getRecordList.path}',
'http://$nasIpAddr:9001/${AuthApi.getRecordList.path}',
queryParameters: {'path': path},
wrapResponse: false,
parser: (json) {
@@ -55,4 +61,24 @@ class AuthServer {
return null;
}
}
/// 获取登录设备信息
static Future<JwtDecodedData?> getLoginDeviceInfo(Ref ref) async {
try {
final apiClient = ref.read(apiClientProvider);
final data = await apiClient.get<JwtDecodedData>(
AuthApi.getLoginDeviceInfo.path,
parser: (json) {
if (json is! Map) {
throw const FormatException('登录设备信息响应格式错误');
}
return JwtDecodedData.fromJson(Map<String, dynamic>.from(json));
},
);
return data;
} catch (error) {
debugPrint('getLoginDeviceInfo failed: $error');
return null;
}
}
}
@@ -1,12 +1,9 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/legacy.dart';
import 'package:jwt_decoder/jwt_decoder.dart';
import 'package:recording_tool/core/cache/app_storage.dart';
import 'package:recording_tool/core/cache/storage_keys.dart';
import 'package:recording_tool/core/network/api_exception.dart';
import 'package:recording_tool/features/auth/model/model_auth.dart';
import 'package:recording_tool/features/auth/model/model_jwt.dart';
import 'package:recording_tool/features/auth/server/server_auth.dart';
import 'package:recording_tool/features/auth/state/state_auth.dart';
@@ -33,7 +30,11 @@ class AuthViewModel extends StateNotifier<AuthState> {
await AppStorage.setString(StorageKeys.authToken, data.deviceAccessToken);
state = const AuthState();
if (data.deviceAccessToken.isNotEmpty) {
parseTokenSetState(data.deviceAccessToken);
final ok = await parseTokenSetState();
if (!ok) {
state = const AuthState(errorMessage: '获取赛事信息失败,请重试');
return false;
}
}
return true;
} on FormatException catch (error) {
@@ -49,24 +50,34 @@ class AuthViewModel extends StateNotifier<AuthState> {
}
/// 解析 TOKEN,并更新状态
Future<bool> parseTokenSetState(String token) async {
final decoded = JwtDecoder.decode(token);
final rawData = decoded['data'];
if (rawData is! Map) return false;
try {
final data = JwtDecodedData.fromJson(Map<String, dynamic>.from(rawData));
state = state.copyWith(jwtDecodedData: data);
return true;
} catch (error) {
debugPrint('认证失败,请重试: $error');
return false;
}
Future<bool> parseTokenSetState() async {
final data = await AuthServer.getLoginDeviceInfo(_ref);
if (data == null) return false;
state = state.copyWith(jwtDecodedData: data);
return true;
// final decoded = JwtDecoder.decode(token);
// final rawData = decoded['data'];
// if (rawData is! Map) return false;
// try {
// final data = JwtDecodedData.fromJson(Map<String, dynamic>.from(rawData));
// state = state.copyWith(jwtDecodedData: data);
// return true;
// } catch (error) {
// debugPrint('认证失败,请重试: $error');
// return false;
// }
}
/// 获取赛事列表
Future<bool> getRecordList(String eventName) async {
if (eventName.isEmpty) return false;
final data = await AuthServer.getRecordList(_ref, eventName);
final nasIpAddr = state.jwtDecodedData?.nasIpAddr?.trim() ?? '';
if (nasIpAddr.isEmpty) return false;
final data = await AuthServer.getRecordList(
_ref,
path: eventName,
nasIpAddr: nasIpAddr,
);
if (data == null) return false;
if (data.items == null) return false;
state = state.copyWith(recordList: data.items);
@@ -76,7 +87,13 @@ class AuthViewModel extends StateNotifier<AuthState> {
/// 获取指定目录的录像列表(不更新 state,用于目录下钻)
Future<List<RecordListItem>?> fetchRecordList(String path) async {
if (path.isEmpty) return null;
final data = await AuthServer.getRecordList(_ref, path);
final nasIpAddr = state.jwtDecodedData?.nasIpAddr?.trim() ?? '';
if (nasIpAddr.isEmpty) return null;
final data = await AuthServer.getRecordList(
_ref,
path: path,
nasIpAddr: nasIpAddr,
);
return data?.items;
}
@@ -127,6 +127,7 @@ class EventRegistrationItem {
required this.itemName,
required this.groupName,
required this.matchPlace,
this.matchStartTime = '',
this.matchEndTime = '',
this.completed = false,
@@ -136,6 +137,7 @@ class EventRegistrationItem {
this.userId = '',
this.playerName = '',
this.playerPhone = '',
this.playerNo = '',
});
final String eventId;
@@ -151,6 +153,7 @@ class EventRegistrationItem {
final String opponentId;
final String opponentName;
final List<EventTeamMember> teamMembers;
final String playerNo;
/// 来自报名列表父级,不在 item JSON 内
final String userId;
@@ -198,6 +201,7 @@ class EventRegistrationItem {
: const [],
userId: userId,
playerName: playerName,
playerNo: _readString(map, const ['playerNo']),
);
}
@@ -521,9 +521,10 @@ String _formatOpponentLine(EventRegistrationItem item) {
}
String _formatNumberBadge(EventRegistrationItem item) {
final place = item.matchPlace.trim();
if (place.isEmpty) return '';
final match = RegExp(r'\d+').firstMatch(place);
if (match != null) return '${match.group(0)}';
return place;
return item.playerNo.trim().isEmpty ? '' : '${item.playerNo.trim()}';
// final place = item.matchPlace.trim();
// if (place.isEmpty) return '';
// final match = RegExp(r'\d+').firstMatch(place);
// if (match != null) return '${match.group(0)}号';
// return place;
}
@@ -10,6 +10,7 @@ import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/features/auth/model/model_auth.dart';
import 'package:recording_tool/features/auth/view_model_auth/view_model_auth.dart';
import 'package:recording_tool/features/scan_qrcode/pages/page_record_video_player.dart';
import 'package:recording_tool/gen/assets.gen.dart';
import 'package:recording_tool/shared/widgets/app_bar.dart';
import 'package:recording_tool/shared/widgets/app_empty_view.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart';
@@ -50,26 +51,26 @@ class RecordListPage extends ConsumerWidget {
return Scaffold(
backgroundColor: const Color(0xFFF5F6F8),
appBar: AppPageBar(title: '查看录像'),
appBar: AppPageBar(
centerTitle: false,
titleSpacing: 0,
titleWidget: Text(
breadcrumbs.join(' > '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 15.sp,
fontWeight: FontWeight.w500,
),
),
),
body: SafeArea(
top: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.fromLTRB(12.w, 12.h, 12.w, 4.h),
child: Text(
breadcrumbs.join(' > '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.sp,
height: 1.2,
color: const Color(0xFF30343A),
fontWeight: FontWeight.w700,
),
),
),
SizedBox(height: 4.h),
Expanded(
child: visibleItems.isEmpty
? const AppEmptyView(message: '暂无录像内容')
@@ -196,7 +197,7 @@ class _DirectoryCard extends StatelessWidget {
borderRadius: BorderRadius.circular(6.r),
child: Container(
constraints: BoxConstraints(minHeight: 72.h),
padding: EdgeInsets.fromLTRB(12.w, 12.h, 14.w, 12.h),
padding: EdgeInsets.fromLTRB(20.w, 12.h, 14.w, 12.h),
child: Row(
children: [
Expanded(
@@ -230,10 +231,22 @@ class _DirectoryCard extends StatelessWidget {
),
),
SizedBox(width: 12.w),
Icon(
Icons.chevron_right,
size: 20.r,
color: const Color(0xFF9AA3AF),
Row(
children: [
// Text(
// '视频',
// style: TextStyle(
// fontSize: 14.sp,
// color: const Color(0xFF358BFE),
// fontWeight: FontWeight.w600,
// ),
// ),
Icon(
Icons.chevron_right,
size: 20.r,
color: const Color(0xFF9AA3AF),
),
],
),
],
),
@@ -368,10 +381,9 @@ class _VideoThumbnailState extends State<_VideoThumbnail> {
color: Colors.black.withValues(alpha: 0.35),
shape: BoxShape.circle,
),
child: Icon(
Icons.play_arrow_rounded,
size: 22.r,
color: Colors.white,
child: Image(
image: AssetImage(Assets.images.imageVideoIcon.path),
fit: BoxFit.cover,
),
),
),
@@ -218,18 +218,50 @@ class _BrandHeader extends StatelessWidget {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
_ScanAssets.appIcon,
width: 34.w,
height: 34.w,
fit: BoxFit.cover,
Expanded(
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(17.r),
child: Image.asset(
_ScanAssets.appIcon,
width: 34.w,
height: 34.w,
fit: BoxFit.cover,
),
),
// SizedBox(width: .w),
Image.asset(
_ScanAssets.appNameText,
width: 117.w,
height: 29.w,
fit: BoxFit.contain,
),
],
),
),
SizedBox(width: 8.w),
Image.asset(
_ScanAssets.appNameText,
width: 117.w,
height: 29.w,
fit: BoxFit.contain,
GestureDetector(
onTap: () {
AppNavigator.pop();
},
child: Row(
children: [
Icon(
Icons.chevron_left_rounded,
color: Colors.black,
size: 28.sp,
),
Text(
'返回',
style: TextStyle(
// color: const Color(0xFF53A7F3),
color: Colors.black,
fontSize: 14.sp,
fontWeight: FontWeight.w400,
),
),
],
),
),
],
);
+5
View File
@@ -80,6 +80,10 @@ class $AssetsImagesGen {
AssetGenImage get imageTeamVsBg =>
const AssetGenImage('assets/images/image_team_vs_bg.png');
/// File path: assets/images/image_video_icon.png
AssetGenImage get imageVideoIcon =>
const AssetGenImage('assets/images/image_video_icon.png');
/// File path: assets/images/image_vs.png
AssetGenImage get imageVs =>
const AssetGenImage('assets/images/image_vs.png');
@@ -100,6 +104,7 @@ class $AssetsImagesGen {
imageScanQrcode,
imageStart,
imageTeamVsBg,
imageVideoIcon,
imageVs,
];
}
+8 -1
View File
@@ -14,6 +14,8 @@ class AppPageBar extends StatelessWidget implements PreferredSizeWidget {
this.backgroundImage,
this.onBack,
this.actions,
this.centerTitle = true,
this.titleSpacing,
});
final String title;
@@ -22,6 +24,10 @@ class AppPageBar extends StatelessWidget implements PreferredSizeWidget {
final VoidCallback? onBack;
final List<Widget>? actions;
/// 标题是否居中;false 时左对齐,紧随返回按钮。
final bool centerTitle;
final double? titleSpacing;
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@@ -33,7 +39,8 @@ class AppPageBar extends StatelessWidget implements PreferredSizeWidget {
backgroundColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: true,
centerTitle: centerTitle,
titleSpacing: titleSpacing,
systemOverlayStyle: SystemUiOverlayStyle.light,
flexibleSpace: SizedBox.expand(
child: Image.asset(