it0/it0_app/lib/features/tasks/presentation/pages/tasks_page.dart

433 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/utils/date_formatter.dart';
import '../../../../core/widgets/empty_state.dart';
import '../../../../core/widgets/status_badge.dart';
// ---------------------------------------------------------------------------
// TODO 41 Tasks page: list + create form
// ---------------------------------------------------------------------------
/// Fetches the full list of ops tasks from the backend.
final tasksProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
final dio = ref.watch(dioClientProvider);
final response = await dio.get(ApiEndpoints.opsTasks);
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 [];
});
/// Fetches a list of servers for the optional server selector in the create
/// task form.
final _serversForPickerProvider =
FutureProvider<List<Map<String, dynamic>>>((ref) async {
final dio = ref.watch(dioClientProvider);
final response = await dio.get(ApiEndpoints.servers);
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 [];
});
// ---------------------------------------------------------------------------
// Tasks page ConsumerWidget
// ---------------------------------------------------------------------------
class TasksPage extends ConsumerWidget {
const TasksPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tasksAsync = ref.watch(tasksProvider);
return Scaffold(
appBar: AppBar(
title: const Text('运维任务'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => ref.invalidate(tasksProvider),
),
],
),
body: RefreshIndicator(
onRefresh: () async => ref.invalidate(tasksProvider),
child: tasksAsync.when(
data: (tasks) {
if (tasks.isEmpty) {
return const EmptyState(
icon: Icons.assignment_outlined,
title: '暂无任务',
subtitle: '点击 + 创建新任务',
);
}
return ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: tasks.length,
itemBuilder: (context, index) {
final task = tasks[index];
return _TaskCard(task: task);
},
);
},
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),
Text(
'加载任务失败',
style: const 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(tasksProvider),
icon: const Icon(Icons.refresh),
label: const Text('重试'),
),
],
),
),
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showCreateTaskSheet(context, ref),
child: const Icon(Icons.add),
),
);
}
// ---- Create task bottom sheet --------------------------------------------
void _showCreateTaskSheet(BuildContext context, WidgetRef ref) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: AppColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => _CreateTaskSheet(ref: ref),
);
}
}
// ---------------------------------------------------------------------------
// Task card widget
// ---------------------------------------------------------------------------
class _TaskCard extends StatelessWidget {
final Map<String, dynamic> task;
const _TaskCard({required this.task});
@override
Widget build(BuildContext context) {
final title = task['title'] as String? ?? task['name'] as String? ?? '未命名';
final description = task['description'] as String? ?? '';
final status = task['status'] as String? ?? 'unknown';
final priority = task['priority'] as String? ?? '';
final createdAt = task['created_at'] as String? ?? task['createdAt'] as String?;
final timeLabel = createdAt != null
? DateFormatter.timeAgo(DateTime.parse(createdAt))
: '';
return Card(
color: AppColors.surface,
margin: const EdgeInsets.only(bottom: 10),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title row with status badge
Row(
children: [
Expanded(
child: Text(
title,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
StatusBadge(status: status),
],
),
if (description.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
description,
style: const TextStyle(color: AppColors.textSecondary, fontSize: 13),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 8),
// Priority + time row
Row(
children: [
if (priority.isNotEmpty) ...[
_PriorityIndicator(priority: priority),
const SizedBox(width: 12),
],
Icon(Icons.access_time, size: 14, color: AppColors.textMuted),
const SizedBox(width: 4),
Text(
timeLabel,
style: const TextStyle(color: AppColors.textMuted, fontSize: 12),
),
],
),
],
),
),
);
}
}
class _PriorityIndicator extends StatelessWidget {
final String priority;
const _PriorityIndicator({required this.priority});
Color get _color {
switch (priority.toLowerCase()) {
case 'critical':
case 'urgent':
return AppColors.error;
case 'high':
return AppColors.warning;
case 'medium':
return AppColors.info;
case 'low':
default:
return AppColors.textMuted;
}
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.flag, size: 14, color: _color),
const SizedBox(width: 3),
Text(
priority,
style: TextStyle(color: _color, fontSize: 12, fontWeight: FontWeight.w500),
),
],
);
}
}
// ---------------------------------------------------------------------------
// Create task bottom sheet
// ---------------------------------------------------------------------------
class _CreateTaskSheet extends StatefulWidget {
final WidgetRef ref;
const _CreateTaskSheet({required this.ref});
@override
State<_CreateTaskSheet> createState() => _CreateTaskSheetState();
}
class _CreateTaskSheetState extends State<_CreateTaskSheet> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
String _priority = 'medium';
String? _selectedServerId;
bool _submitting = false;
static const _priorities = ['low', 'medium', 'high', 'critical'];
@override
Widget build(BuildContext context) {
final serversAsync = widget.ref.watch(_serversForPickerProvider);
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
return Padding(
padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + bottomInset),
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Handle bar
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppColors.textMuted,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
const Text(
'新建任务',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 20),
// Title
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: '标题',
hintText: '例如: 重启 web-01 的 nginx',
border: OutlineInputBorder(),
),
validator: (v) => (v == null || v.trim().isEmpty) ? '请输入标题' : null,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 14),
// Description
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: '描述',
hintText: '可选详情...',
border: OutlineInputBorder(),
),
maxLines: 3,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 14),
// Priority dropdown
DropdownButtonFormField<String>(
value: _priority,
decoration: const InputDecoration(
labelText: '优先级',
border: OutlineInputBorder(),
),
items: _priorities
.map((p) => DropdownMenuItem(
value: p,
child: Text(p[0].toUpperCase() + p.substring(1)),
))
.toList(),
onChanged: (v) {
if (v != null) setState(() => _priority = v);
},
),
const SizedBox(height: 14),
// Optional server selection
serversAsync.when(
data: (servers) {
if (servers.isEmpty) return const SizedBox.shrink();
return DropdownButtonFormField<String?>(
value: _selectedServerId,
decoration: const InputDecoration(
labelText: '服务器(可选)',
border: OutlineInputBorder(),
),
items: [
const DropdownMenuItem(value: null, child: Text('不指定')),
...servers.map((s) {
final id = s['id']?.toString() ?? '';
final name = s['hostname'] as String? ??
s['name'] as String? ??
id;
return DropdownMenuItem(value: id, child: Text(name));
}),
],
onChanged: (v) => setState(() => _selectedServerId = v),
);
},
loading: () => const LinearProgressIndicator(),
error: (_, __) => const SizedBox.shrink(),
),
const SizedBox(height: 24),
// Submit button
FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Text('创建任务'),
),
const SizedBox(height: 8),
],
),
),
),
);
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _submitting = true);
try {
final dio = widget.ref.read(dioClientProvider);
final body = <String, dynamic>{
'title': _titleController.text.trim(),
'description': _descriptionController.text.trim(),
'priority': _priority,
};
if (_selectedServerId != null) {
body['serverId'] = _selectedServerId;
}
await dio.post(ApiEndpoints.opsTasks, data: body);
// Refresh the task list
widget.ref.invalidate(tasksProvider);
if (mounted) Navigator.of(context).pop();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('创建任务失败: $e'),
backgroundColor: AppColors.error,
),
);
}
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
super.dispose();
}
}