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,79 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class AiLeaveSuggestion {
const AiLeaveSuggestion({
required this.values,
required this.assumptions,
required this.needsClarification,
required this.model,
});
final Map<String, Object?> values;
final List<String> assumptions;
final List<String> needsClarification;
final String model;
}
class AiLeaveSuggestionException implements Exception {
const AiLeaveSuggestionException(this.message);
final String message;
@override
String toString() => message;
}
class AiLeaveSuggestionRepository {
AiLeaveSuggestionRepository({
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<AiLeaveSuggestion> suggest(String text) async {
final response = await _client.post(
Uri.parse('$baseUrl/ai/leave-draft-suggestions'),
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer $accessToken',
},
body: jsonEncode({'text': text.trim(), 'timezone': 'Asia/Shanghai'}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
String? message;
try {
message =
(jsonDecode(response.body) as Map<String, Object?>)['detail']
as String?;
} catch (_) {}
throw AiLeaveSuggestionException(
message ?? 'AI 建议生成失败(${response.statusCode}',
);
}
final json = jsonDecode(response.body) as Map<String, Object?>;
if (json['requiresUserConfirmation'] != true) {
throw const AiLeaveSuggestionException('AI 响应缺少用户确认保护');
}
final suggestion = Map<String, Object?>.from(json['suggestion']! as Map);
return AiLeaveSuggestion(
values: {
for (final key in const ['type', 'startsAt', 'endsAt', 'reason'])
if (suggestion[key] != null) key: suggestion[key],
},
assumptions: ((suggestion['assumptions'] as List?) ?? const [])
.cast<String>(),
needsClarification:
((suggestion['needsClarification'] as List?) ?? const [])
.cast<String>(),
model: json['model']! as String,
);
}
}
@@ -0,0 +1,83 @@
import 'dart:convert';
import 'package:aioa_mobile/core/forms/schema/form_schema.dart';
import 'package:aioa_mobile/features/form/domain/leave_form_definition.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
enum FormDefinitionSource { remote, cache, bundled }
class LoadedFormDefinition {
const LoadedFormDefinition({required this.definition, required this.source});
final DynamicFormDefinition definition;
final FormDefinitionSource source;
}
class FormDefinitionRepository {
FormDefinitionRepository({
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();
static const _cacheKey = 'form-definition.leave-request.v1';
final http.Client _client;
final String baseUrl;
final String accessToken;
Future<LoadedFormDefinition> loadLeaveRequest() async {
final preferences = await SharedPreferences.getInstance();
try {
final response = await _client
.get(
Uri.parse('$baseUrl/form-definitions/leave-request'),
headers: {
'Accept': 'application/json',
if (accessToken.isNotEmpty)
'Authorization': 'Bearer $accessToken',
},
)
.timeout(const Duration(seconds: 5));
if (response.statusCode != 200) {
throw http.ClientException(
'Form definition request failed: ${response.statusCode}',
);
}
final json = jsonDecode(response.body) as Map<String, Object?>;
final definition = DynamicFormDefinition.fromJson(json);
try {
await preferences.setString(_cacheKey, response.body);
} catch (_) {
// A valid remote definition remains usable even if local persistence
// is temporarily unavailable.
}
return LoadedFormDefinition(
definition: definition,
source: FormDefinitionSource.remote,
);
} catch (_) {
final cached = preferences.getString(_cacheKey);
if (cached != null) {
try {
return LoadedFormDefinition(
definition: DynamicFormDefinition.fromJson(
jsonDecode(cached) as Map<String, Object?>,
),
source: FormDefinitionSource.cache,
);
} catch (_) {
await preferences.remove(_cacheKey);
}
}
return const LoadedFormDefinition(
definition: leaveFormDefinition,
source: FormDefinitionSource.bundled,
);
}
}
}
@@ -0,0 +1,147 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
class LeaveAttachmentItem {
const LeaveAttachmentItem({
required this.id,
required this.fileName,
required this.contentType,
required this.sizeBytes,
required this.status,
});
final String id;
final String fileName;
final String contentType;
final int sizeBytes;
final String status;
factory LeaveAttachmentItem.fromJson(Map<String, Object?> json) {
return LeaveAttachmentItem(
id: json['id']! as String,
fileName: json['fileName']! as String,
contentType: json['contentType']! as String,
sizeBytes: json['sizeBytes']! as int,
status: json['status']! as String,
);
}
}
class LeaveAttachmentException implements Exception {
const LeaveAttachmentException(this.message);
final String message;
@override
String toString() => message;
}
class LeaveAttachmentRepository {
LeaveAttachmentRepository({
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<LeaveAttachmentItem> upload({
required String leaveRequestId,
required String fileName,
required String contentType,
required Uint8List bytes,
required void Function(double progress) onProgress,
}) async {
if (bytes.isEmpty || bytes.length > 10 * 1024 * 1024) {
throw const LeaveAttachmentException('附件大小必须在 1 字节到 10 MB 之间');
}
final taskResponse = await _client.post(
Uri.parse(
'$baseUrl/leave-requests/$leaveRequestId/attachments/upload-tasks',
),
headers: _jsonHeaders,
body: jsonEncode({
'fileName': fileName,
'contentType': contentType,
'sizeBytes': bytes.length,
}),
);
_requireSuccess(taskResponse, {201});
final task = jsonDecode(taskResponse.body) as Map<String, Object?>;
final attachment = Map<String, Object?>.from(task['attachment']! as Map);
final attachmentId = attachment['id']! as String;
final uploadRequest = http.StreamedRequest(
'PUT',
Uri.parse(task['uploadUrl']! as String),
);
uploadRequest.headers['Content-Type'] = contentType;
uploadRequest.contentLength = bytes.length;
final uploadResponseFuture = _client.send(uploadRequest);
const chunkSize = 64 * 1024;
var sent = 0;
for (var offset = 0; offset < bytes.length; offset += chunkSize) {
final end = (offset + chunkSize).clamp(0, bytes.length);
uploadRequest.sink.add(bytes.sublist(offset, end));
sent = end;
onProgress(sent / bytes.length);
}
await uploadRequest.sink.close();
final uploadResponse = await uploadResponseFuture;
if (uploadResponse.statusCode < 200 || uploadResponse.statusCode >= 300) {
throw LeaveAttachmentException('对象存储上传失败(${uploadResponse.statusCode}');
}
final completeResponse = await _client.post(
Uri.parse(
'$baseUrl/leave-requests/$leaveRequestId/attachments/$attachmentId/complete',
),
headers: _authHeaders,
);
_requireSuccess(completeResponse, {200});
onProgress(1);
return LeaveAttachmentItem.fromJson(
jsonDecode(completeResponse.body) as Map<String, Object?>,
);
}
Future<List<LeaveAttachmentItem>> list(String leaveRequestId) async {
final response = await _client.get(
Uri.parse('$baseUrl/leave-requests/$leaveRequestId/attachments'),
headers: _authHeaders,
);
_requireSuccess(response, {200});
return (jsonDecode(response.body) as List)
.map(
(item) => LeaveAttachmentItem.fromJson(
Map<String, Object?>.from(item as Map),
),
)
.toList();
}
Map<String, String> get _authHeaders => {
'Accept': 'application/json',
if (accessToken.isNotEmpty) 'Authorization': 'Bearer $accessToken',
};
Map<String, String> get _jsonHeaders => {
..._authHeaders,
'Content-Type': 'application/json',
};
void _requireSuccess(http.Response response, Set<int> expected) {
if (expected.contains(response.statusCode)) return;
String? message;
try {
final problem = jsonDecode(response.body) as Map<String, Object?>;
message = problem['detail'] as String?;
} catch (_) {}
throw LeaveAttachmentException(message ?? '附件请求失败(${response.statusCode}');
}
}
@@ -0,0 +1,144 @@
import 'dart:convert';
import 'dart:math';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
class CreatedLeaveDraft {
const CreatedLeaveDraft({
required this.id,
required this.status,
required this.version,
});
final String id;
final String status;
final int version;
}
class LeaveDraftSubmissionException implements Exception {
const LeaveDraftSubmissionException(this.message, {this.retryable = true});
final String message;
final bool retryable;
@override
String toString() => message;
}
class LeaveDraftSubmissionRepository {
LeaveDraftSubmissionRepository({
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();
static const pendingStorageKey = 'leave-request.pending-create.v1';
final http.Client _client;
final String baseUrl;
final String accessToken;
Future<CreatedLeaveDraft> create(Map<String, Object?> values) async {
final requestBody = _requestBody(values);
final payload = jsonEncode(requestBody);
final preferences = await SharedPreferences.getInstance();
final pending = _readPending(preferences);
final idempotencyKey = pending?.payload == payload
? pending!.idempotencyKey
: _newIdempotencyKey();
await preferences.setString(
pendingStorageKey,
jsonEncode({'idempotencyKey': idempotencyKey, 'payload': payload}),
);
late http.Response response;
try {
response = await _client
.post(
Uri.parse('$baseUrl/leave-requests/drafts'),
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer $accessToken',
'Idempotency-Key': idempotencyKey,
},
body: payload,
)
.timeout(const Duration(seconds: 10));
} catch (_) {
throw const LeaveDraftSubmissionException('网络不可用,已保存请求,可安全重试');
}
if (response.statusCode != 200 && response.statusCode != 201) {
final message =
_problemMessage(response.body) ?? '创建草稿失败(${response.statusCode}';
final retryable =
response.statusCode >= 500 || response.statusCode == 401;
if (!retryable) await preferences.remove(pendingStorageKey);
throw LeaveDraftSubmissionException(message, retryable: retryable);
}
final json = jsonDecode(response.body) as Map<String, Object?>;
await preferences.remove(pendingStorageKey);
return CreatedLeaveDraft(
id: json['id']! as String,
status: json['status']! as String,
version: json['version']! as int,
);
}
Map<String, Object?> _requestBody(Map<String, Object?> values) => {
'type': values['type'],
'startsAt': values['startsAt'],
'endsAt': values['endsAt'],
'reason': values['reason'],
'version': 0,
};
_PendingSubmission? _readPending(SharedPreferences preferences) {
final encoded = preferences.getString(pendingStorageKey);
if (encoded == null) return null;
try {
final json = jsonDecode(encoded) as Map<String, Object?>;
return _PendingSubmission(
idempotencyKey: json['idempotencyKey']! as String,
payload: json['payload']! as String,
);
} catch (_) {
preferences.remove(pendingStorageKey);
return null;
}
}
String _newIdempotencyKey() {
final random = Random.secure();
final entropy = List.generate(
16,
(_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
).join();
return 'leave-${DateTime.now().microsecondsSinceEpoch}-$entropy';
}
String? _problemMessage(String body) {
try {
final problem = jsonDecode(body) as Map<String, Object?>;
return problem['detail'] as String? ?? problem['title'] as String?;
} catch (_) {
return null;
}
}
}
class _PendingSubmission {
const _PendingSubmission({
required this.idempotencyKey,
required this.payload,
});
final String idempotencyKey;
final String payload;
}
@@ -0,0 +1,47 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
class RestoredLeaveDraft {
const RestoredLeaveDraft({required this.values, required this.savedAt});
final Map<String, Object?> values;
final DateTime savedAt;
}
class LeaveLocalDraftRepository {
static const storageKey = 'leave-request.local-draft.v1';
Future<void> save(Map<String, Object?> values) async {
final preferences = await SharedPreferences.getInstance();
await preferences.setString(
storageKey,
jsonEncode({
'version': 1,
'savedAt': DateTime.now().toUtc().toIso8601String(),
'values': values,
}),
);
}
Future<RestoredLeaveDraft?> load() async {
final preferences = await SharedPreferences.getInstance();
final encoded = preferences.getString(storageKey);
if (encoded == null) return null;
try {
final envelope = jsonDecode(encoded) as Map<String, Object?>;
if (envelope['version'] != 1) throw const FormatException();
final savedAt = DateTime.parse(envelope['savedAt']! as String);
final values = Map<String, Object?>.from(envelope['values']! as Map);
return RestoredLeaveDraft(values: values, savedAt: savedAt);
} catch (_) {
await preferences.remove(storageKey);
return null;
}
}
Future<void> clear() async {
final preferences = await SharedPreferences.getInstance();
await preferences.remove(storageKey);
}
}