import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/config/api_endpoints.dart'; import '../../../../core/network/dio_client.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/errors/error_handler.dart'; import '../../../../core/widgets/empty_state.dart'; import '../../../../core/widgets/error_view.dart'; // --------------------------------------------------------------------------- // Approvals provider // --------------------------------------------------------------------------- /// Fetches the full list of ops approvals from the backend. final approvalsProvider = FutureProvider>>((ref) async { final dio = ref.watch(dioClientProvider); final response = await dio.get(ApiEndpoints.approvals); final data = response.data; if (data is List) { return data.cast>(); } if (data is Map && data.containsKey('items')) { return (data['items'] as List).cast>(); } return []; }); // --------------------------------------------------------------------------- // Filter enum // --------------------------------------------------------------------------- enum _ApprovalFilter { all, pending, expired } // --------------------------------------------------------------------------- // Approvals page -- ConsumerStatefulWidget (for filter state) // --------------------------------------------------------------------------- class ApprovalsPage extends ConsumerStatefulWidget { const ApprovalsPage({super.key}); @override ConsumerState createState() => _ApprovalsPageState(); } class _ApprovalsPageState extends ConsumerState { _ApprovalFilter _filter = _ApprovalFilter.all; List> _applyFilter(List> items) { switch (_filter) { case _ApprovalFilter.pending: return items .where((a) => (a['status'] as String? ?? '').toLowerCase() == 'pending') .toList(); case _ApprovalFilter.expired: return items.where((a) { final status = (a['status'] as String? ?? '').toLowerCase(); if (status == 'expired') return true; final expiresAt = a['expires_at'] as String? ?? a['expiresAt'] as String?; if (expiresAt != null) { try { return DateTime.parse(expiresAt).isBefore(DateTime.now()); } catch (_) {} } return false; }).toList(); case _ApprovalFilter.all: return items; } } @override Widget build(BuildContext context) { final approvalsAsync = ref.watch(approvalsProvider); return Scaffold( appBar: AppBar( title: const Text('审批中心'), actions: [ IconButton( icon: const Icon(Icons.refresh), onPressed: () => ref.invalidate(approvalsProvider), ), ], ), body: Column( children: [ // ------------- Filter chips row ----------------------------------- Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: SegmentedButton<_ApprovalFilter>( segments: const [ ButtonSegment( value: _ApprovalFilter.all, label: Text('全部'), icon: Icon(Icons.list, size: 18), ), ButtonSegment( value: _ApprovalFilter.pending, label: Text('待审批'), icon: Icon(Icons.hourglass_top, size: 18), ), ButtonSegment( value: _ApprovalFilter.expired, label: Text('已过期'), icon: Icon(Icons.timer_off, size: 18), ), ], selected: {_filter}, onSelectionChanged: (selection) { setState(() => _filter = selection.first); }, style: ButtonStyle( visualDensity: VisualDensity.compact, foregroundColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.selected)) { return AppColors.textPrimary; } return AppColors.textSecondary; }), ), ), ), // ------------- Main content --------------------------------------- Expanded( child: RefreshIndicator( onRefresh: () async => ref.invalidate(approvalsProvider), child: approvalsAsync.when( data: (approvals) { final filtered = _applyFilter(approvals); if (filtered.isEmpty) { return ListView( children: [ SizedBox( height: MediaQuery.of(context).size.height * 0.5, child: EmptyState( icon: Icons.approval_outlined, title: _filter == _ApprovalFilter.all ? '暂无审批' : '没有${_filter.name}审批记录', subtitle: _filter == _ApprovalFilter.all ? '审批请求将显示在此处' : '尝试切换筛选条件', ), ), ], ); } return ListView.builder( padding: const EdgeInsets.all(12), itemCount: filtered.length, itemBuilder: (context, index) { return _ApprovalCard( approval: filtered[index], onAction: () => ref.invalidate(approvalsProvider), ); }, ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => ErrorView( error: e, onRetry: () => ref.invalidate(approvalsProvider), ), ), ), ), ], ), ); } } // --------------------------------------------------------------------------- // Approval card widget // --------------------------------------------------------------------------- class _ApprovalCard extends StatefulWidget { final Map approval; final VoidCallback onAction; const _ApprovalCard({required this.approval, required this.onAction}); @override State<_ApprovalCard> createState() => _ApprovalCardState(); } class _ApprovalCardState extends State<_ApprovalCard> { bool _acting = false; Timer? _countdownTimer; Duration _remaining = Duration.zero; Map get approval => widget.approval; @override void initState() { super.initState(); _startCountdown(); } @override void didUpdateWidget(covariant _ApprovalCard oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.approval != widget.approval) { _countdownTimer?.cancel(); _startCountdown(); } } void _startCountdown() { final expiresAt = approval['expires_at'] as String? ?? approval['expiresAt'] as String?; if (expiresAt == null) return; try { final expiry = DateTime.parse(expiresAt); _remaining = expiry.difference(DateTime.now()); if (_remaining.isNegative) { _remaining = Duration.zero; return; } _countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) { final now = DateTime.now(); final diff = expiry.difference(now); if (diff.isNegative) { _countdownTimer?.cancel(); if (mounted) setState(() => _remaining = Duration.zero); } else { if (mounted) setState(() => _remaining = diff); } }); } catch (_) {} } @override void dispose() { _countdownTimer?.cancel(); super.dispose(); } // ---- Risk level helpers -------------------------------------------------- static Color _riskColor(String risk) { switch (risk.toUpperCase()) { case 'L0': return AppColors.riskL0; case 'L1': return AppColors.riskL1; case 'L2': return AppColors.riskL2; case 'L3': return AppColors.riskL3; default: return AppColors.textMuted; } } // ---- Countdown display --------------------------------------------------- String get _countdownLabel { if (_remaining == Duration.zero) return '已过期'; final hours = _remaining.inHours; final minutes = _remaining.inMinutes.remainder(60); final seconds = _remaining.inSeconds.remainder(60); if (hours > 0) return '剩余 ${hours}小时${minutes}分'; if (minutes > 0) return '剩余 ${minutes}分${seconds}秒'; return '剩余 ${seconds}秒'; } Color get _countdownColor { if (_remaining == Duration.zero) return AppColors.error; if (_remaining.inMinutes < 5) return AppColors.error; if (_remaining.inMinutes < 15) return AppColors.warning; return AppColors.textSecondary; } // ---- Actions ------------------------------------------------------------- Future _performAction(String action) async { if (_acting) return; setState(() => _acting = true); try { final id = approval['id']?.toString() ?? ''; final dio = ProviderScope.containerOf(context).read(dioClientProvider); await dio.post('${ApiEndpoints.approvals}/$id/$action'); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( action == 'approve' ? '审批已通过' : '审批已拒绝', ), backgroundColor: action == 'approve' ? AppColors.success : AppColors.error, ), ); widget.onAction(); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('操作失败: ${ErrorHandler.friendlyMessage(e)}'), backgroundColor: AppColors.error, ), ); } } finally { if (mounted) setState(() => _acting = false); } } @override Widget build(BuildContext context) { final command = approval['command'] as String? ?? approval['description'] as String? ?? '未指定命令'; final riskLevel = approval['risk_level'] as String? ?? approval['riskLevel'] as String? ?? ''; final requester = approval['requester'] as String? ?? approval['requested_by'] as String? ?? approval['requestedBy'] as String? ?? '未知'; final status = (approval['status'] as String? ?? '').toLowerCase(); final isPending = status == 'pending' || status.isEmpty; final isExpired = _remaining == Duration.zero && (approval['expires_at'] != null || approval['expiresAt'] != null); return Card( color: AppColors.surface, margin: const EdgeInsets.only(bottom: 10), child: Padding( padding: const EdgeInsets.all(14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ---- Header row: command + risk chip --------------------------- Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Text( command, style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, fontFamily: 'monospace', ), maxLines: 3, overflow: TextOverflow.ellipsis, ), ), if (riskLevel.isNotEmpty) ...[ const SizedBox(width: 8), _RiskChip(level: riskLevel), ], ], ), const SizedBox(height: 10), // ---- Requester + countdown row --------------------------------- Row( children: [ const Icon(Icons.person_outline, size: 14, color: AppColors.textMuted), const SizedBox(width: 4), Text( requester, style: const TextStyle( color: AppColors.textSecondary, fontSize: 13), ), const Spacer(), Icon( isExpired ? Icons.timer_off : Icons.timer_outlined, size: 14, color: _countdownColor, ), const SizedBox(width: 4), Text( _countdownLabel, style: TextStyle(color: _countdownColor, fontSize: 12), ), ], ), // ---- Status badge for non-pending items ------------------------ if (!isPending) ...[ const SizedBox(height: 10), _StatusChip(status: status), ], // ---- Action buttons (only for pending + not expired) ----------- if (isPending && !isExpired) ...[ const SizedBox(height: 12), Row( children: [ Expanded( child: OutlinedButton.icon( onPressed: _acting ? null : () => _performAction('reject'), icon: _acting ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: AppColors.error), ) : const Icon(Icons.close, size: 18), label: const Text('拒绝'), style: OutlinedButton.styleFrom( foregroundColor: AppColors.error, side: const BorderSide(color: AppColors.error), ), ), ), const SizedBox(width: 12), Expanded( child: FilledButton.icon( onPressed: _acting ? null : () => _performAction('approve'), icon: _acting ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white), ) : const Icon(Icons.check, size: 18), label: const Text('通过'), style: FilledButton.styleFrom( backgroundColor: AppColors.success, foregroundColor: Colors.white, ), ), ), ], ), ], ], ), ), ); } } // --------------------------------------------------------------------------- // Risk level chip // --------------------------------------------------------------------------- class _RiskChip extends StatelessWidget { final String level; const _RiskChip({required this.level}); @override Widget build(BuildContext context) { final color = _ApprovalCardState._riskColor(level); return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: color.withOpacity(0.15), borderRadius: BorderRadius.circular(12), border: Border.all(color: color.withOpacity(0.3)), ), child: Text( level.toUpperCase(), style: TextStyle( color: color, fontSize: 11, fontWeight: FontWeight.w600, ), ), ); } } // --------------------------------------------------------------------------- // Status chip (for approved / rejected / expired) // --------------------------------------------------------------------------- class _StatusChip extends StatelessWidget { final String status; const _StatusChip({required this.status}); Color get _color { switch (status.toLowerCase()) { case 'approved': return AppColors.success; case 'rejected': return AppColors.error; case 'expired': return AppColors.warning; default: return AppColors.info; } } IconData get _icon { switch (status.toLowerCase()) { case 'approved': return Icons.check_circle_outline; case 'rejected': return Icons.cancel_outlined; case 'expired': return Icons.timer_off_outlined; default: return Icons.info_outline; } } @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.min, children: [ Icon(_icon, size: 14, color: _color), const SizedBox(width: 4), Text( status[0].toUpperCase() + status.substring(1), style: TextStyle( color: _color, fontSize: 12, fontWeight: FontWeight.w500, ), ), ], ); } }