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