feat: complete leave approval MVP
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
|
||||
import 'package:aioa_mobile/core/config/runtime_config.dart';
|
||||
import 'package:aioa_mobile/features/requests/data/leave_request_repository.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
final leaveRequestRepositoryProvider = Provider<LeaveRequestRepository>(
|
||||
(ref) => LeaveRequestRepository(
|
||||
client: ref.watch(authenticatedHttpClientProvider),
|
||||
baseUrl: RuntimeConfig.apiBaseUrl,
|
||||
),
|
||||
);
|
||||
|
||||
final leaveRequestListProvider =
|
||||
AsyncNotifierProvider<LeaveRequestListController, List<LeaveRequestItem>>(
|
||||
LeaveRequestListController.new,
|
||||
);
|
||||
|
||||
class LeaveRequestListController extends AsyncNotifier<List<LeaveRequestItem>> {
|
||||
@override
|
||||
Future<List<LeaveRequestItem>> build() =>
|
||||
ref.read(leaveRequestRepositoryProvider).list();
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(
|
||||
() => ref.read(leaveRequestRepositoryProvider).list(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LeaveRequestDetail {
|
||||
const LeaveRequestDetail({required this.request, required this.timeline});
|
||||
final LeaveRequestItem request;
|
||||
final List<LeaveTimelineEvent> timeline;
|
||||
}
|
||||
|
||||
final leaveRequestDetailProvider =
|
||||
AsyncNotifierProvider.family<
|
||||
LeaveRequestDetailController,
|
||||
LeaveRequestDetail,
|
||||
String
|
||||
>((id) => LeaveRequestDetailController(id));
|
||||
|
||||
class LeaveRequestDetailController extends AsyncNotifier<LeaveRequestDetail> {
|
||||
LeaveRequestDetailController(this.id);
|
||||
final String id;
|
||||
|
||||
@override
|
||||
Future<LeaveRequestDetail> build() async {
|
||||
final repository = ref.read(leaveRequestRepositoryProvider);
|
||||
final results = await Future.wait([
|
||||
repository.get(id),
|
||||
repository.timeline(id),
|
||||
]);
|
||||
return LeaveRequestDetail(
|
||||
request: results[0] as LeaveRequestItem,
|
||||
timeline: results[1] as List<LeaveTimelineEvent>,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> transition(String action) async {
|
||||
final current = state.value;
|
||||
if (current == null) return '申请尚未加载完成';
|
||||
try {
|
||||
final updated = await ref
|
||||
.read(leaveRequestRepositoryProvider)
|
||||
.transition(current.request, action);
|
||||
final timeline = await ref
|
||||
.read(leaveRequestRepositoryProvider)
|
||||
.timeline(id);
|
||||
state = AsyncData(
|
||||
LeaveRequestDetail(request: updated, timeline: timeline),
|
||||
);
|
||||
ref.invalidate(leaveRequestListProvider);
|
||||
return null;
|
||||
} on LeaveRequestException catch (error) {
|
||||
return error.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class LeaveRequestItem {
|
||||
const LeaveRequestItem({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.startsAt,
|
||||
required this.endsAt,
|
||||
required this.reason,
|
||||
required this.status,
|
||||
required this.version,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String type;
|
||||
final DateTime startsAt;
|
||||
final DateTime endsAt;
|
||||
final String reason;
|
||||
final String status;
|
||||
final int version;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
factory LeaveRequestItem.fromJson(Map<String, Object?> json) =>
|
||||
LeaveRequestItem(
|
||||
id: json['id']! as String,
|
||||
type: json['type']! as String,
|
||||
startsAt: DateTime.parse(json['startsAt']! as String),
|
||||
endsAt: DateTime.parse(json['endsAt']! as String),
|
||||
reason: json['reason']! as String,
|
||||
status: json['status']! as String,
|
||||
version: json['version']! as int,
|
||||
createdAt: DateTime.parse(json['createdAt']! as String),
|
||||
updatedAt: DateTime.parse(json['updatedAt']! as String),
|
||||
);
|
||||
}
|
||||
|
||||
class LeaveTimelineEvent {
|
||||
const LeaveTimelineEvent({
|
||||
required this.id,
|
||||
required this.eventType,
|
||||
required this.fromStatus,
|
||||
required this.toStatus,
|
||||
required this.occurredAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String eventType;
|
||||
final String fromStatus;
|
||||
final String toStatus;
|
||||
final DateTime occurredAt;
|
||||
|
||||
factory LeaveTimelineEvent.fromJson(Map<String, Object?> json) =>
|
||||
LeaveTimelineEvent(
|
||||
id: json['id']! as String,
|
||||
eventType: json['eventType']! as String,
|
||||
fromStatus: json['fromStatus']! as String,
|
||||
toStatus: json['toStatus']! as String,
|
||||
occurredAt: DateTime.parse(json['occurredAt']! as String),
|
||||
);
|
||||
}
|
||||
|
||||
class LeaveRequestException implements Exception {
|
||||
const LeaveRequestException(this.message);
|
||||
final String message;
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class LeaveRequestRepository {
|
||||
LeaveRequestRepository({
|
||||
http.Client? client,
|
||||
this.baseUrl = const String.fromEnvironment(
|
||||
'AIOA_API_BASE_URL',
|
||||
defaultValue: 'http://127.0.0.1:8080/api/v1',
|
||||
),
|
||||
this.accessToken = const String.fromEnvironment('AIOA_ACCESS_TOKEN'),
|
||||
}) : _client = client ?? http.Client();
|
||||
|
||||
final http.Client _client;
|
||||
final String baseUrl;
|
||||
final String accessToken;
|
||||
|
||||
Future<List<LeaveRequestItem>> list() async {
|
||||
final response = await _client.get(
|
||||
Uri.parse('$baseUrl/leave-requests'),
|
||||
headers: _headers,
|
||||
);
|
||||
_requireSuccess(response);
|
||||
return (jsonDecode(response.body) as List)
|
||||
.map(
|
||||
(item) =>
|
||||
LeaveRequestItem.fromJson(Map<String, Object?>.from(item as Map)),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<LeaveRequestItem> get(String id) async {
|
||||
final response = await _client.get(
|
||||
Uri.parse('$baseUrl/leave-requests/$id'),
|
||||
headers: _headers,
|
||||
);
|
||||
_requireSuccess(response);
|
||||
return LeaveRequestItem.fromJson(
|
||||
jsonDecode(response.body) as Map<String, Object?>,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<LeaveTimelineEvent>> timeline(String id) async {
|
||||
final response = await _client.get(
|
||||
Uri.parse('$baseUrl/leave-requests/$id/timeline'),
|
||||
headers: _headers,
|
||||
);
|
||||
_requireSuccess(response);
|
||||
return (jsonDecode(response.body) as List)
|
||||
.map(
|
||||
(item) => LeaveTimelineEvent.fromJson(
|
||||
Map<String, Object?>.from(item as Map),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<LeaveRequestItem> transition(
|
||||
LeaveRequestItem request,
|
||||
String action,
|
||||
) async {
|
||||
final payload = jsonEncode({'version': request.version});
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final storageKey = 'leave-transition.${request.id}.$action';
|
||||
final existing = preferences.getString(storageKey);
|
||||
final pending = existing == null ? null : _decodePending(existing);
|
||||
final key = pending?.payload == payload ? pending!.key : _newKey(action);
|
||||
await preferences.setString(
|
||||
storageKey,
|
||||
jsonEncode({'key': key, 'payload': payload}),
|
||||
);
|
||||
|
||||
late http.Response response;
|
||||
try {
|
||||
response = await _client.post(
|
||||
Uri.parse('$baseUrl/leave-requests/${request.id}/$action'),
|
||||
headers: {
|
||||
..._headers,
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': key,
|
||||
},
|
||||
body: payload,
|
||||
);
|
||||
} catch (_) {
|
||||
throw const LeaveRequestException('网络不可用,操作已保存,可安全重试');
|
||||
}
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
if (response.statusCode < 500 && response.statusCode != 401) {
|
||||
await preferences.remove(storageKey);
|
||||
}
|
||||
_requireSuccess(response);
|
||||
}
|
||||
await preferences.remove(storageKey);
|
||||
return LeaveRequestItem.fromJson(
|
||||
jsonDecode(response.body) as Map<String, Object?>,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, String> get _headers => {
|
||||
'Accept': 'application/json',
|
||||
if (accessToken.isNotEmpty) 'Authorization': 'Bearer $accessToken',
|
||||
};
|
||||
|
||||
void _requireSuccess(http.Response response) {
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) return;
|
||||
String? message;
|
||||
try {
|
||||
message =
|
||||
(jsonDecode(response.body) as Map<String, Object?>)['detail']
|
||||
as String?;
|
||||
} catch (_) {}
|
||||
throw LeaveRequestException(message ?? '申请请求失败(${response.statusCode})');
|
||||
}
|
||||
|
||||
_PendingTransition? _decodePending(String value) {
|
||||
try {
|
||||
final json = jsonDecode(value) as Map<String, Object?>;
|
||||
return _PendingTransition(
|
||||
json['key']! as String,
|
||||
json['payload']! as String,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String _newKey(String action) {
|
||||
final random = Random.secure();
|
||||
final entropy = List.generate(
|
||||
12,
|
||||
(_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
|
||||
).join();
|
||||
return 'leave-$action-${DateTime.now().microsecondsSinceEpoch}-$entropy';
|
||||
}
|
||||
}
|
||||
|
||||
class _PendingTransition {
|
||||
const _PendingTransition(this.key, this.payload);
|
||||
final String key;
|
||||
final String payload;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import 'package:aioa_mobile/features/requests/application/leave_request_controller.dart';
|
||||
import 'package:aioa_mobile/features/requests/data/leave_request_repository.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class LeaveRequestDetailPage extends ConsumerWidget {
|
||||
const LeaveRequestDetailPage({required this.id, super.key});
|
||||
final String id;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final detail = ref.watch(leaveRequestDetailProvider(id));
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('申请详情')),
|
||||
body: detail.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(child: Text('详情加载失败:$error')),
|
||||
data: (value) => _DetailBody(id: id, detail: value),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailBody extends ConsumerWidget {
|
||||
const _DetailBody({required this.id, required this.detail});
|
||||
final String id;
|
||||
final LeaveRequestDetail detail;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final request = detail.request;
|
||||
final formatter = DateFormat('yyyy-MM-dd HH:mm');
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_statusLabel(request.status),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_row('请假类型', _typeLabel(request.type)),
|
||||
_row('开始时间', formatter.format(request.startsAt.toLocal())),
|
||||
_row('结束时间', formatter.format(request.endsAt.toLocal())),
|
||||
_row('请假原因', request.reason),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'流程时间线',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (detail.timeline.isEmpty)
|
||||
const Card(
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.edit_note),
|
||||
title: Text('草稿已创建'),
|
||||
),
|
||||
)
|
||||
else
|
||||
for (final event in detail.timeline) _TimelineTile(event: event),
|
||||
const SizedBox(height: 16),
|
||||
if (request.status == 'DRAFT')
|
||||
FilledButton.icon(
|
||||
onPressed: () => _transition(context, ref, 'submit'),
|
||||
icon: const Icon(Icons.send_outlined),
|
||||
label: const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text('提交审批'),
|
||||
),
|
||||
),
|
||||
if (request.status == 'PENDING')
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _transition(context, ref, 'withdraw'),
|
||||
icon: const Icon(Icons.undo),
|
||||
label: const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text('撤回申请'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _transition(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String action,
|
||||
) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(action == 'submit' ? '提交审批' : '撤回申请'),
|
||||
content: Text(action == 'submit' ? '提交后将进入审批流程,确认继续?' : '确认撤回当前申请?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('确认'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
final error = await ref
|
||||
.read(leaveRequestDetailProvider(id).notifier)
|
||||
.transition(action);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(error ?? (action == 'submit' ? '已提交审批' : '已撤回'))),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(String label, String value) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: 78, child: Text(label)),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
String _typeLabel(String value) => switch (value) {
|
||||
'PERSONAL' => '事假',
|
||||
'SICK' => '病假',
|
||||
'ANNUAL' => '年假',
|
||||
_ => value,
|
||||
};
|
||||
String _statusLabel(String value) => switch (value) {
|
||||
'DRAFT' => '草稿',
|
||||
'PENDING' => '审批中',
|
||||
'APPROVED' => '已通过',
|
||||
'REJECTED' => '已驳回',
|
||||
'WITHDRAWN' => '已撤回',
|
||||
_ => value,
|
||||
};
|
||||
}
|
||||
|
||||
class _TimelineTile extends StatelessWidget {
|
||||
const _TimelineTile({required this.event});
|
||||
final LeaveTimelineEvent event;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.radio_button_checked),
|
||||
title: Text(_eventLabel(event.eventType)),
|
||||
subtitle: Text(
|
||||
'${event.fromStatus} → ${event.toStatus}\n${DateFormat('MM-dd HH:mm').format(event.occurredAt.toLocal())}',
|
||||
),
|
||||
isThreeLine: true,
|
||||
),
|
||||
);
|
||||
|
||||
String _eventLabel(String value) => switch (value) {
|
||||
'LEAVE_REQUEST_SUBMITTED' => '申请已提交',
|
||||
'LEAVE_REQUEST_WITHDRAWN' => '申请已撤回',
|
||||
'LEAVE_REQUEST_APPROVED' => '申请已通过',
|
||||
'LEAVE_REQUEST_REJECTED' => '申请已驳回',
|
||||
'LEAVE_APPROVAL_TASK_APPROVED' => '审批节点已通过',
|
||||
'LEAVE_APPROVAL_TASK_REJECTED' => '审批节点已驳回',
|
||||
_ => value,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:aioa_mobile/features/requests/application/leave_request_controller.dart';
|
||||
import 'package:aioa_mobile/features/requests/data/leave_request_repository.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class LeaveRequestListPage extends ConsumerWidget {
|
||||
const LeaveRequestListPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final requests = ref.watch(leaveRequestListProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('我的请假申请')),
|
||||
body: requests.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(
|
||||
child: FilledButton(
|
||||
onPressed: ref.read(leaveRequestListProvider.notifier).refresh,
|
||||
child: Text('加载失败,点击重试\n$error'),
|
||||
),
|
||||
),
|
||||
data: (items) {
|
||||
if (items.isEmpty) return const Center(child: Text('暂无请假申请'));
|
||||
return RefreshIndicator(
|
||||
onRefresh: ref.read(leaveRequestListProvider.notifier).refresh,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) =>
|
||||
_RequestCard(request: items[index]),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => context.push('/leave/new'),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('发起请假'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RequestCard extends StatelessWidget {
|
||||
const _RequestCard({required this.request});
|
||||
final LeaveRequestItem request;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Card(
|
||||
child: ListTile(
|
||||
onTap: () => context.push('/leave/${request.id}'),
|
||||
leading: CircleAvatar(child: Icon(_statusIcon(request.status))),
|
||||
title: Text(
|
||||
'${_typeLabel(request.type)} · ${_statusLabel(request.status)}',
|
||||
),
|
||||
subtitle: Text(
|
||||
'${DateFormat('MM-dd HH:mm').format(request.startsAt.toLocal())} — ${DateFormat('MM-dd HH:mm').format(request.endsAt.toLocal())}\n${request.reason}',
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
isThreeLine: true,
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
),
|
||||
);
|
||||
|
||||
String _typeLabel(String value) => switch (value) {
|
||||
'PERSONAL' => '事假',
|
||||
'SICK' => '病假',
|
||||
'ANNUAL' => '年假',
|
||||
_ => value,
|
||||
};
|
||||
|
||||
String _statusLabel(String value) => switch (value) {
|
||||
'DRAFT' => '草稿',
|
||||
'PENDING' => '审批中',
|
||||
'APPROVED' => '已通过',
|
||||
'REJECTED' => '已驳回',
|
||||
'WITHDRAWN' => '已撤回',
|
||||
_ => value,
|
||||
};
|
||||
|
||||
IconData _statusIcon(String value) => switch (value) {
|
||||
'DRAFT' => Icons.edit_note,
|
||||
'PENDING' => Icons.hourglass_top,
|
||||
'APPROVED' => Icons.check_circle_outline,
|
||||
'REJECTED' => Icons.cancel_outlined,
|
||||
'WITHDRAWN' => Icons.undo,
|
||||
_ => Icons.description_outlined,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user