From e5b6246458010ed0bc64cd93070ecaaaad359205 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E9=94=8B?= <2535831261@qq.com> Date: Mon, 20 Jul 2026 10:10:51 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=BA=94=E7=94=A8=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E4=BB=A5=E6=B7=BB=E5=8A=A0=E4=B8=BB=E8=A3=81=E5=88=A4?= =?UTF-8?q?=E8=AE=A1=E5=88=86=20H5=20=E9=93=BE=E6=8E=A5=EF=BC=9B=20?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E4=BA=8B=E4=BB=B6=E6=B3=A8=E5=86=8C=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E4=BB=A5=E6=94=AF=E6=8C=81=E5=9B=A2=E9=98=9F=E6=88=90?= =?UTF-8?q?=E5=91=98=E4=BF=A1=E6=81=AF=EF=BC=8C=E5=B9=B6=E7=AE=80=E5=8C=96?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E4=BF=A1=E6=81=AF=E9=A1=B5=E9=9D=A2=E7=9A=84?= =?UTF-8?q?=E5=AF=BC=E8=88=AA=E9=80=BB=E8=BE=91=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/app/config/app_config.dart | 10 + .../pages/page_event_team_match.dart | 443 ++++++++++++++++++ .../events/model/model_event_info.dart | 42 +- .../events/pages/page_event_info.dart | 24 +- 4 files changed, 507 insertions(+), 12 deletions(-) create mode 100644 lib/features/competition_teams/pages/page_event_team_match.dart diff --git a/lib/app/config/app_config.dart b/lib/app/config/app_config.dart index b528b5c..8775015 100644 --- a/lib/app/config/app_config.dart +++ b/lib/app/config/app_config.dart @@ -8,11 +8,13 @@ class EnvironmentValues { required this.environment, required this.baseUrl, required this.enableNetworkLog, + this.mainRefereeScoreH5Url = '', }); final AppEnvironment environment; final String baseUrl; final bool enableNetworkLog; + final String mainRefereeScoreH5Url; } class AppConfig { @@ -24,6 +26,8 @@ class AppConfig { static const appName = '飞行极控录像工作台'; static const designSize = Size(375, 812); + /// 主裁判计分 H5 链接 + static void configure({ required AppEnvironment environment, AppPackageInfo? packageInfo, @@ -34,16 +38,22 @@ class AppConfig { environment: AppEnvironment.dev, // baseUrl: 'http://192.168.1.104:8000', baseUrl: 'https://apitest.dronex.cc', + mainRefereeScoreH5Url: + 'https://drone.apptest.sportsx.cc/#/pages/h5/score-entry', enableNetworkLog: true, ), AppEnvironment.staging => const EnvironmentValues( environment: AppEnvironment.staging, baseUrl: 'https://apitest.dronex.cc', + mainRefereeScoreH5Url: + 'https://drone.apptest.sportsx.cc/#/pages/h5/score-entry', enableNetworkLog: true, ), AppEnvironment.prod => const EnvironmentValues( environment: AppEnvironment.prod, baseUrl: 'https://api.dronex.cc', + mainRefereeScoreH5Url: + 'https://drone.sportsx.cc/#/pages/h5/score-entry', enableNetworkLog: false, ), }; diff --git a/lib/features/competition_teams/pages/page_event_team_match.dart b/lib/features/competition_teams/pages/page_event_team_match.dart new file mode 100644 index 0000000..007f785 --- /dev/null +++ b/lib/features/competition_teams/pages/page_event_team_match.dart @@ -0,0 +1,443 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:recording_tool/app/config/app_config.dart'; +import 'package:recording_tool/app/router/app_navigator.dart'; +import 'package:recording_tool/features/competition_teams/model/model_competition_team.dart'; +import 'package:recording_tool/features/competition_teams/widgets/widget_manual_winner_dialog.dart'; +import 'package:recording_tool/features/events/model/model_event_info.dart'; +import 'package:recording_tool/shared/widgets/app_button.dart'; +import 'package:recording_tool/shared/widgets/app_qr_scanner_dialog.dart'; +import 'package:recording_tool/shared/widgets/app_toast.dart'; +import 'package:recording_tool/shared/widgets/app_webview.dart'; + +typedef TeamQrScanner = Future Function(BuildContext context); + +class EventTeamMatchPage extends StatefulWidget { + const EventTeamMatchPage({ + super.key, + required this.item, + required this.playerId, + this.qrScanner, + }); + + final EventRegistrationItem item; + final String playerId; + final TeamQrScanner? qrScanner; + + @override + State createState() => _EventTeamMatchPageState(); +} + +class _EventTeamMatchPageState extends State { + final Set _verifiedUserIds = {}; + String? _winnerTeamId; + + CompetitionTeam get _homeTeam { + final leader = widget.item.teamLeader; + final teamId = leader?.userId.isNotEmpty == true + ? leader!.userId + : widget.item.userId.isNotEmpty + ? widget.item.userId + : 'home-team'; + final teamName = leader?.name.isNotEmpty == true + ? leader!.name + : widget.item.playerName.isNotEmpty + ? widget.item.playerName + : '本方队伍'; + return CompetitionTeam( + id: teamId, + name: teamName, + players: widget.item.teamMembers + .map( + (member) => CompetitionPlayer(id: member.userId, name: member.name), + ) + .toList(growable: false), + ); + } + + CompetitionTeam get _opponentTeam { + final opponentId = widget.item.opponentId.isNotEmpty + ? widget.item.opponentId + : 'opponent-team'; + final opponentName = widget.item.opponentName.isNotEmpty + ? widget.item.opponentName + : '对方队伍'; + return CompetitionTeam( + id: opponentId, + name: opponentName, + players: + widget.item.opponentId.isEmpty && widget.item.opponentName.isEmpty + ? const [] + : [CompetitionPlayer(id: opponentId, name: opponentName)], + ); + } + + CompetitionMatchup get _matchup => CompetitionMatchup( + id: widget.item.scheduleId.isNotEmpty + ? widget.item.scheduleId + : '${widget.item.itemId}-team-match', + teamA: _homeTeam, + teamB: _opponentTeam, + winnerTeamId: _winnerTeamId, + ); + + @override + void initState() { + super.initState(); + final initialPlayerId = widget.playerId.trim(); + if (_isKnownMember(initialPlayerId)) { + _verifiedUserIds.add(initialPlayerId); + } + } + + bool _isKnownMember(String userId) { + if (userId.isEmpty) return false; + if (userId == widget.item.opponentId) return true; + return widget.item.teamMembers.any((member) => member.userId == userId); + } + + String _memberNameOf(String userId) { + if (userId == widget.item.opponentId) { + return widget.item.opponentName.isEmpty + ? '对方队长' + : widget.item.opponentName; + } + for (final member in widget.item.teamMembers) { + if (member.userId == userId) return member.name; + } + return ''; + } + + Future _continueScan() async { + final scan = widget.qrScanner ?? AppQrScannerDialog.show; + final result = (await scan(context))?.trim(); + if (!mounted || result == null || result.isEmpty) return; + if (!_isKnownMember(result)) { + AppToast.show('未找到对应参赛成员'); + return; + } + if (_verifiedUserIds.contains(result)) { + AppToast.show('${_memberNameOf(result)}已完成核验'); + return; + } + setState(() => _verifiedUserIds.add(result)); + AppToast.show('${_memberNameOf(result)}核验成功'); + } + + Future _manualProcess() async { + final winnerTeamId = await ManualWinnerDialog.show( + context, + matchup: _matchup, + ); + if (!mounted || winnerTeamId == null) return; + setState(() => _winnerTeamId = winnerTeamId); + final winnerName = winnerTeamId == _homeTeam.id + ? _homeTeam.name + : _opponentTeam.name; + AppToast.show('已设置$winnerName直接获胜'); + } + + void _startDirectly() { + AppNavigator.push( + buildTeamScorePage(item: widget.item, playerId: widget.playerId), + context: context, + ); + } + + @override + Widget build(BuildContext context) { + final homeTeam = _homeTeam; + final opponentTeam = _opponentTeam; + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar(), + body: SafeArea( + top: false, + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 20.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _MatchMetadata(item: widget.item), + SizedBox(height: 16.h), + Align( + alignment: Alignment.centerRight, + child: TextButton( + key: const ValueKey('event-team-manual-process'), + onPressed: _manualProcess, + child: Text( + '人工处理', + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w700, + color: const Color(0xFF078AF2), + ), + ), + ), + ), + _TeamCard( + team: homeTeam, + members: widget.item.teamMembers, + backgroundColor: const Color(0xFFFFEEF0), + accentColor: const Color(0xFFE84B5B), + verifiedUserIds: _verifiedUserIds, + winner: _winnerTeamId == homeTeam.id, + ), + SizedBox(height: 16.h), + _TeamCard( + team: opponentTeam, + members: [ + EventTeamMember( + userId: widget.item.opponentId, + name: widget.item.opponentName, + isLeader: true, + ), + ], + backgroundColor: const Color(0xFFEDF5FF), + accentColor: const Color(0xFF287FDD), + verifiedUserIds: _verifiedUserIds, + winner: _winnerTeamId == opponentTeam.id, + emptyMessage: '暂无对方成员数据', + ), + ], + ), + ), + ), + Padding( + padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 20.h), + child: Column( + children: [ + SizedBox( + width: double.infinity, + child: AppButton( + label: '继续扫码', + variant: AppButtonVariant.outline, + onPressed: _continueScan, + ), + ), + SizedBox(height: 12.h), + SizedBox( + width: double.infinity, + child: AppButton(label: '直接开赛', onPressed: _startDirectly), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +WebviewPage buildTeamScorePage({ + required EventRegistrationItem item, + required String playerId, +}) { + return WebviewPage( + url: AppConfig.current.mainRefereeScoreH5Url, + eventRegistrationItem: item, + playerId: playerId, + ); +} + +class _MatchMetadata extends StatelessWidget { + const _MatchMetadata({required this.item}); + + final EventRegistrationItem item; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: EdgeInsets.all(18.r), + decoration: BoxDecoration( + color: const Color(0xFFF7F9FC), + borderRadius: BorderRadius.circular(14.r), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: _MetadataText(label: '比赛项目', value: item.itemName), + ), + SizedBox(width: 16.w), + _MetadataText(label: '场地', value: item.matchPlace), + ], + ), + SizedBox(height: 14.h), + _MetadataText(label: '组别', value: item.groupName), + ], + ), + ); + } +} + +class _MetadataText extends StatelessWidget { + const _MetadataText({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Text( + '$label:${value.isEmpty ? '暂无' : value}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 17.sp, + height: 1.35, + color: const Color(0xFF303640), + fontWeight: FontWeight.w600, + ), + ); + } +} + +class _TeamCard extends StatelessWidget { + const _TeamCard({ + required this.team, + required this.members, + required this.backgroundColor, + required this.accentColor, + required this.verifiedUserIds, + required this.winner, + this.emptyMessage = '暂无成员数据', + }); + + final CompetitionTeam team; + final List members; + final Color backgroundColor; + final Color accentColor; + final Set verifiedUserIds; + final bool winner; + final String emptyMessage; + + @override + Widget build(BuildContext context) { + final visibleMembers = members + .where((member) => member.userId.isNotEmpty || member.name.isNotEmpty) + .toList(growable: false); + return AnimatedContainer( + duration: const Duration(milliseconds: 180), + width: double.infinity, + padding: EdgeInsets.all(18.r), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(14.r), + border: Border.all( + color: winner ? accentColor : accentColor.withValues(alpha: 0.24), + width: winner ? 2 : 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + '队长:${team.name}', + style: TextStyle( + fontSize: 19.sp, + fontWeight: FontWeight.w700, + color: const Color(0xFF252B34), + ), + ), + ), + if (winner) + Container( + key: ValueKey('event-team-winner-${team.id}'), + padding: EdgeInsets.symmetric( + horizontal: 10.w, + vertical: 4.h, + ), + decoration: BoxDecoration( + color: accentColor, + borderRadius: BorderRadius.circular(12.r), + ), + child: Text( + '胜方', + style: TextStyle( + color: Colors.white, + fontSize: 12.sp, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + SizedBox(height: 14.h), + if (visibleMembers.isEmpty) + Text( + emptyMessage, + style: TextStyle(fontSize: 15.sp, color: const Color(0xFF747D89)), + ) + else + ...visibleMembers.map( + (member) => _MemberRow( + member: member, + verified: verifiedUserIds.contains(member.userId), + accentColor: accentColor, + ), + ), + ], + ), + ); + } +} + +class _MemberRow extends StatelessWidget { + const _MemberRow({ + required this.member, + required this.verified, + required this.accentColor, + }); + + final EventTeamMember member; + final bool verified; + final Color accentColor; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 5.h), + child: Row( + children: [ + Container( + width: 6.r, + height: 6.r, + decoration: BoxDecoration( + color: accentColor, + shape: BoxShape.circle, + ), + ), + SizedBox(width: 10.w), + Expanded( + child: Text( + '${member.isLeader ? '队长' : '选手'}:${member.name.isEmpty ? '暂无' : member.name}', + style: TextStyle(fontSize: 16.sp, color: const Color(0xFF303640)), + ), + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + child: verified + ? Icon( + Icons.check_circle, + key: ValueKey('verified-member-${member.userId}'), + color: const Color(0xFF18A957), + size: 24.r, + ) + : SizedBox(width: 24.r, height: 24.r), + ), + ], + ), + ); + } +} diff --git a/lib/features/events/model/model_event_info.dart b/lib/features/events/model/model_event_info.dart index f5f59ce..c8a53cd 100644 --- a/lib/features/events/model/model_event_info.dart +++ b/lib/features/events/model/model_event_info.dart @@ -43,6 +43,27 @@ class EventProfile { final String avatarLabel; } +class EventTeamMember { + const EventTeamMember({ + required this.userId, + required this.name, + required this.isLeader, + }); + + final String userId; + final String name; + final bool isLeader; + + factory EventTeamMember.fromJson(Map json) { + final map = Map.from(json); + return EventTeamMember( + userId: _readString(map, const ['userId']), + name: _readString(map, const ['name']), + isLeader: _readBool(map, const ['isLeader']), + ); + } +} + class EventRegistrationList { const EventRegistrationList({ required this.userId, @@ -111,7 +132,7 @@ class EventRegistrationItem { this.completed = false, this.opponentId = '', this.opponentName = '', - this.teamMembers, + this.teamMembers = const [], this.userId = '', this.playerName = '', this.playerPhone = '', @@ -129,18 +150,24 @@ class EventRegistrationItem { final bool completed; final String opponentId; final String opponentName; - final List? teamMembers; + final List teamMembers; /// 来自报名列表父级,不在 item JSON 内 final String userId; final String playerName; final String playerPhone; - String get scheduleTime => - _formatScheduleTime(matchStartTime, matchEndTime); + String get scheduleTime => _formatScheduleTime(matchStartTime, matchEndTime); String? get statusLabel => completed ? '已完成' : null; + EventTeamMember? get teamLeader { + for (final member in teamMembers) { + if (member.isLeader) return member; + } + return teamMembers.isEmpty ? null : teamMembers.first; + } + factory EventRegistrationItem.fromJson( Map json, { String userId = '', @@ -164,8 +191,11 @@ class EventRegistrationItem { opponentId: _readString(map, const ['opponentId']), opponentName: _readString(map, const ['opponentName']), teamMembers: teamMembersRaw is List - ? List.from(teamMembersRaw) - : null, + ? teamMembersRaw + .whereType() + .map(EventTeamMember.fromJson) + .toList(growable: false) + : const [], userId: userId, playerName: playerName, ); diff --git a/lib/features/events/pages/page_event_info.dart b/lib/features/events/pages/page_event_info.dart index 620b093..99f5b17 100644 --- a/lib/features/events/pages/page_event_info.dart +++ b/lib/features/events/pages/page_event_info.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:recording_tool/app/config/app_config.dart'; import 'package:recording_tool/app/router/app_navigator.dart'; +import 'package:recording_tool/features/competition_teams/pages/page_event_team_match.dart'; import 'package:recording_tool/features/events/model/model_event_info.dart'; import 'package:recording_tool/features/events/state/state_event_info.dart'; import 'package:recording_tool/features/events/view_model/view_model_event_info.dart'; @@ -28,13 +30,9 @@ class _EventInfoPageState extends ConsumerState { if (!mounted) return; // final profile = ref.read(eventInfoProvider).profile; - /// 个人赛 AppNavigator.push( - WebviewPage( - url: 'https://drone.apptest.sportsx.cc/#/pages/h5/score-entry', - eventRegistrationItem: item, - playerId: widget.playerId, - ), + buildEventRegistrationDestination(item: item, playerId: widget.playerId), + context: context, ); } @@ -88,6 +86,20 @@ class _EventInfoPageState extends ConsumerState { } } +Widget buildEventRegistrationDestination({ + required EventRegistrationItem item, + required String playerId, +}) { + if (item.opponentId.isNotEmpty) { + return EventTeamMatchPage(item: item, playerId: playerId); + } + return WebviewPage( + url: AppConfig.current.mainRefereeScoreH5Url, + eventRegistrationItem: item, + playerId: playerId, + ); +} + class _Header extends StatelessWidget { const _Header({required this.onBack});