it0/it0_app/lib/features/approvals/presentation/pages/approvals_page.dart

557 lines
18 KiB
Dart

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/widgets/empty_state.dart';
// ---------------------------------------------------------------------------
// Approvals provider
// ---------------------------------------------------------------------------
/// Fetches the full list of ops approvals from the backend.
final approvalsProvider =
FutureProvider<List<Map<String, dynamic>>>((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<Map<String, dynamic>>();
}
if (data is Map && data.containsKey('items')) {
return (data['items'] as List).cast<Map<String, dynamic>>();
}
return [];
});
// ---------------------------------------------------------------------------
// Filter enum
// ---------------------------------------------------------------------------
enum _ApprovalFilter { all, pending, expired }
// ---------------------------------------------------------------------------
// Approvals page -- ConsumerStatefulWidget (for filter state)
// ---------------------------------------------------------------------------
class ApprovalsPage extends ConsumerStatefulWidget {
const ApprovalsPage({super.key});
@override
ConsumerState<ApprovalsPage> createState() => _ApprovalsPageState();
}
class _ApprovalsPageState extends ConsumerState<ApprovalsPage> {
_ApprovalFilter _filter = _ApprovalFilter.all;
List<Map<String, dynamic>> _applyFilter(List<Map<String, dynamic>> 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, _) => Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline,
color: AppColors.error, size: 48),
const SizedBox(height: 12),
const Text(
'加载审批失败',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w600),
),
const SizedBox(height: 6),
Text(
e.toString(),
style: const TextStyle(
color: AppColors.textSecondary, fontSize: 13),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: () => ref.invalidate(approvalsProvider),
icon: const Icon(Icons.refresh),
label: const Text('重试'),
),
],
),
),
),
),
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Approval card widget
// ---------------------------------------------------------------------------
class _ApprovalCard extends StatefulWidget {
final Map<String, dynamic> 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<String, dynamic> 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<void> _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('操作失败: $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? ??
'No command specified';
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? ??
'Unknown';
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,
),
),
],
);
}
}