更新应用配置以添加主裁判计分 H5 链接;

重构事件注册模型以支持团队成员信息,并简化事件信息页面的导航逻辑。
This commit is contained in:
2026-07-20 10:10:51 +08:00
parent 4e0a675198
commit e5b6246458
4 changed files with 507 additions and 12 deletions
+10
View File
@@ -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,
),
};
@@ -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<String?> 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<EventTeamMatchPage> createState() => _EventTeamMatchPageState();
}
class _EventTeamMatchPageState extends State<EventTeamMatchPage> {
final Set<String> _verifiedUserIds = <String>{};
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<void> _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<void> _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<EventTeamMember> members;
final Color backgroundColor;
final Color accentColor;
final Set<String> 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),
),
],
),
);
}
}
@@ -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<dynamic, dynamic> json) {
final map = Map<String, dynamic>.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<dynamic>? teamMembers;
final List<EventTeamMember> 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<dynamic, dynamic> json, {
String userId = '',
@@ -164,8 +191,11 @@ class EventRegistrationItem {
opponentId: _readString(map, const ['opponentId']),
opponentName: _readString(map, const ['opponentName']),
teamMembers: teamMembersRaw is List
? List<dynamic>.from(teamMembersRaw)
: null,
? teamMembersRaw
.whereType<Map>()
.map(EventTeamMember.fromJson)
.toList(growable: false)
: const [],
userId: userId,
playerName: playerName,
);
+18 -6
View File
@@ -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<EventInfoPage> {
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<EventInfoPage> {
}
}
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});