433 lines
14 KiB
Dart
433 lines
14 KiB
Dart
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();
|
||
}
|
||
}
|