feat: complete leave approval MVP
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user