feat: complete leave approval MVP

This commit is contained in:
selfrelease
2026-07-18 19:20:07 +08:00
parent 2105fe3bac
commit 090a7e33ce
133 changed files with 7845 additions and 100 deletions
@@ -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;
}