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,30 @@
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:aioa_mobile/features/assistant/data/leave_progress_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final leaveProgressRepositoryProvider = Provider(
(ref) => LeaveProgressRepository(
client: ref.watch(authenticatedHttpClientProvider),
baseUrl: RuntimeConfig.apiBaseUrl,
),
);
final leaveProgressProvider =
AsyncNotifierProvider<LeaveProgressController, ProgressAnswer?>(
LeaveProgressController.new,
);
class LeaveProgressController extends AsyncNotifier<ProgressAnswer?> {
String _question = '';
@override
Future<ProgressAnswer?> build() async => null;
Future<void> ask(String question, {String? selectedRequestId}) async {
if (selectedRequestId == null) _question = question.trim();
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref
.read(leaveProgressRepositoryProvider)
.ask(_question, selectedRequestId: selectedRequestId),
);
}
}
@@ -0,0 +1,88 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class ProgressCandidate {
const ProgressCandidate({
required this.id,
required this.type,
required this.status,
required this.startsAt,
required this.endsAt,
});
final String id, type, status;
final DateTime startsAt, endsAt;
factory ProgressCandidate.fromJson(Map<String, Object?> j) =>
ProgressCandidate(
id: j['id']! as String,
type: j['type']! as String,
status: j['status']! as String,
startsAt: DateTime.parse(j['startsAt']! as String),
endsAt: DateTime.parse(j['endsAt']! as String),
);
}
class ProgressAnswer {
const ProgressAnswer({
required this.requiresSelection,
required this.candidates,
this.answer,
this.request,
this.activeTasks = const [],
this.completedTasks = const [],
this.processEnded = false,
});
final bool requiresSelection, processEnded;
final List<ProgressCandidate> candidates;
final String? answer;
final ProgressCandidate? request;
final List<String> activeTasks, completedTasks;
factory ProgressAnswer.fromJson(Map<String, Object?> j) {
final p = j['progress'] as Map<String, Object?>?;
return ProgressAnswer(
requiresSelection: j['requiresSelection']! as bool,
candidates: ((j['candidates'] as List?) ?? const [])
.map(
(e) =>
ProgressCandidate.fromJson(Map<String, Object?>.from(e as Map)),
)
.toList(),
answer: j['answer'] as String?,
request: j['request'] == null
? null
: ProgressCandidate.fromJson(
Map<String, Object?>.from(j['request']! as Map),
),
activeTasks: ((p?['activeTaskNames'] as List?) ?? const [])
.cast<String>(),
completedTasks: ((p?['completedTaskNames'] as List?) ?? const [])
.cast<String>(),
processEnded: p?['processEnded'] as bool? ?? false,
);
}
}
class LeaveProgressRepository {
LeaveProgressRepository({required this.client, required this.baseUrl});
final http.Client client;
final String baseUrl;
Future<ProgressAnswer> ask(String text, {String? selectedRequestId}) async {
final response = await client.post(
Uri.parse('$baseUrl/ai/leave-progress-answers'),
headers: const {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: jsonEncode({
'text': text,
'timezone': 'Asia/Shanghai',
'selectedRequestId': ?selectedRequestId,
}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('查询失败(${response.statusCode}');
}
return ProgressAnswer.fromJson(
jsonDecode(response.body) as Map<String, Object?>,
);
}
}
@@ -1,9 +1,147 @@
import 'package:aioa_mobile/features/assistant/application/leave_progress_controller.dart';
import 'package:aioa_mobile/features/assistant/data/leave_progress_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 AssistantPage extends StatelessWidget {
class AssistantPage extends ConsumerStatefulWidget {
const AssistantPage({super.key});
@override
ConsumerState<AssistantPage> createState() => _AssistantPageState();
}
class _AssistantPageState extends ConsumerState<AssistantPage> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) =>
const Center(child: Text('AI 助手将在下一阶段接入'));
Widget build(BuildContext context) {
final result = ref.watch(leaveProgressProvider);
return Scaffold(
appBar: AppBar(title: const Text('AI 流程助手')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
const Text('询问本人请假流程进度,AI 只读查询,不会执行审批或修改申请。'),
const SizedBox(height: 12),
TextField(
controller: _controller,
minLines: 2,
maxLines: 4,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: '例如:我最近提交的年假审批到哪一步了?',
),
),
const SizedBox(height: 10),
FilledButton.icon(
onPressed: result.isLoading
? null
: () {
if (_controller.text.trim().isNotEmpty) {
ref
.read(leaveProgressProvider.notifier)
.ask(_controller.text);
}
},
icon: const Icon(Icons.auto_awesome),
label: const Text('查询进度'),
),
const SizedBox(height: 16),
result.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text('查询失败:$e'),
),
),
data: (data) => data == null
? const SizedBox.shrink()
: _Result(
data: data,
onSelect: (id) => ref
.read(leaveProgressProvider.notifier)
.ask('', selectedRequestId: id),
),
),
],
),
);
}
}
class _Result extends StatelessWidget {
const _Result({required this.data, required this.onSelect});
final ProgressAnswer data;
final ValueChanged<String> onSelect;
@override
Widget build(BuildContext context) {
if (data.requiresSelection) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('找到多条可能的申请,请选择:'),
...data.candidates.map(
(c) => Card(
child: ListTile(
onTap: () => onSelect(c.id),
title: Text('${_type(c.type)} · ${_status(c.status)}'),
subtitle: Text(
'${DateFormat('MM-dd HH:mm').format(c.startsAt.toLocal())}${DateFormat('MM-dd HH:mm').format(c.endsAt.toLocal())}',
),
trailing: const Icon(Icons.chevron_right),
),
),
),
],
);
}
final request = data.request!;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${_type(request.type)} · ${_status(request.status)}',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 10),
Text(data.answer ?? ''),
if (data.activeTasks.isNotEmpty) ...[
const SizedBox(height: 12),
Text('当前节点:${data.activeTasks.join('')}'),
],
if (data.completedTasks.isNotEmpty)
Text('已完成:${data.completedTasks.join('')}'),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => context.push('/leave/${request.id}'),
icon: const Icon(Icons.open_in_new),
label: const Text('查看申请详情'),
),
],
),
),
);
}
static String _type(String v) =>
{'PERSONAL': '事假', 'SICK': '病假', 'ANNUAL': '年假'}[v] ?? v;
static String _status(String v) =>
{
'DRAFT': '草稿',
'PENDING': '审批中',
'APPROVED': '已通过',
'REJECTED': '已驳回',
'WITHDRAWN': '已撤回',
}[v] ??
v;
}
@@ -0,0 +1,12 @@
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:aioa_mobile/features/form/data/ai_leave_suggestion_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final aiLeaveSuggestionRepositoryProvider =
Provider<AiLeaveSuggestionRepository>(
(ref) => AiLeaveSuggestionRepository(
client: ref.watch(authenticatedHttpClientProvider),
baseUrl: RuntimeConfig.apiBaseUrl,
),
);
@@ -0,0 +1,15 @@
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:aioa_mobile/features/form/data/form_definition_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final formDefinitionRepositoryProvider = Provider<FormDefinitionRepository>(
(ref) => FormDefinitionRepository(
client: ref.watch(authenticatedHttpClientProvider),
baseUrl: RuntimeConfig.apiBaseUrl,
),
);
final leaveFormDefinitionProvider = FutureProvider<LoadedFormDefinition>((ref) {
return ref.watch(formDefinitionRepositoryProvider).loadLeaveRequest();
});
@@ -0,0 +1,97 @@
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'dart:typed_data';
import 'package:aioa_mobile/features/form/data/leave_attachment_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final leaveAttachmentRepositoryProvider = Provider<LeaveAttachmentRepository>(
(ref) => LeaveAttachmentRepository(
client: ref.watch(authenticatedHttpClientProvider),
baseUrl: RuntimeConfig.apiBaseUrl,
),
);
class LeaveAttachmentState {
const LeaveAttachmentState({
this.items = const [],
this.uploading = false,
this.progress = 0,
this.error,
});
final List<LeaveAttachmentItem> items;
final bool uploading;
final double progress;
final String? error;
LeaveAttachmentState copyWith({
List<LeaveAttachmentItem>? items,
bool? uploading,
double? progress,
String? error,
bool clearError = false,
}) => LeaveAttachmentState(
items: items ?? this.items,
uploading: uploading ?? this.uploading,
progress: progress ?? this.progress,
error: clearError ? null : error ?? this.error,
);
}
final leaveAttachmentProvider =
NotifierProvider.family<
LeaveAttachmentController,
LeaveAttachmentState,
String
>((leaveRequestId) => LeaveAttachmentController(leaveRequestId));
class LeaveAttachmentController extends Notifier<LeaveAttachmentState> {
LeaveAttachmentController(this._leaveRequestId);
final String _leaveRequestId;
@override
LeaveAttachmentState build() => const LeaveAttachmentState();
Future<void> load() async {
try {
final items = await ref
.read(leaveAttachmentRepositoryProvider)
.list(_leaveRequestId);
state = state.copyWith(items: items, clearError: true);
} on LeaveAttachmentException catch (error) {
state = state.copyWith(error: error.message);
}
}
Future<void> upload({
required String fileName,
required String contentType,
required Uint8List bytes,
}) async {
if (state.uploading) return;
state = state.copyWith(uploading: true, progress: 0, clearError: true);
try {
final item = await ref
.read(leaveAttachmentRepositoryProvider)
.upload(
leaveRequestId: _leaveRequestId,
fileName: fileName,
contentType: contentType,
bytes: bytes,
onProgress: (progress) {
state = state.copyWith(progress: progress);
},
);
state = state.copyWith(
items: [...state.items, item],
uploading: false,
progress: 1,
clearError: true,
);
} on LeaveAttachmentException catch (error) {
state = state.copyWith(uploading: false, error: error.message);
}
}
}
@@ -1,21 +1,47 @@
import 'dart:async';
import 'package:aioa_mobile/core/forms/schema/form_schema.dart';
import 'package:aioa_mobile/core/forms/schema/form_validator.dart';
import 'package:aioa_mobile/features/form/domain/leave_form_definition.dart';
import 'package:aioa_mobile/features/form/data/leave_local_draft_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final leaveLocalDraftRepositoryProvider = Provider<LeaveLocalDraftRepository>(
(ref) => LeaveLocalDraftRepository(),
);
final leaveDraftProvider =
NotifierProvider<LeaveDraftController, DynamicFormState>(
LeaveDraftController.new,
);
class LeaveDraftController extends Notifier<DynamicFormState> {
late final LeaveLocalDraftRepository _repository;
@override
DynamicFormState build() => const DynamicFormState();
DynamicFormState build() {
_repository = ref.watch(leaveLocalDraftRepositoryProvider);
unawaited(_restore());
return const DynamicFormState();
}
Future<void> _restore() async {
final restored = await _repository.load();
if (restored == null || state.values.isNotEmpty) return;
state = DynamicFormState(
values: restored.values,
restoredAt: restored.savedAt,
);
}
void setValue(String field, Object? value) {
final values = {...state.values, field: value};
final errors = {...state.errors}..remove(field);
state = state.copyWith(values: values, errors: errors);
state = state.copyWith(
values: values,
errors: errors,
clearRestoredAt: true,
);
unawaited(_repository.save(values));
}
void applyAiSuggestion() {
@@ -36,13 +62,22 @@ class LeaveDraftController extends Notifier<DynamicFormState> {
'reason': '办理个人事务,已提前完成工作交接。',
},
);
unawaited(_repository.save(state.values));
}
bool validate() {
final errors = validateDynamicForm(
leaveFormDefinition.dataSchema,
state.values,
);
void applySuggestion(Map<String, Object?> suggestion) {
final values = {...state.values, ...suggestion};
state = DynamicFormState(values: values);
unawaited(_repository.save(values));
}
Future<void> clear() async {
await _repository.clear();
state = const DynamicFormState();
}
bool validate(JsonFormSchema schema) {
final errors = validateDynamicForm(schema, state.values);
final startsAt = DateTime.tryParse(
state.values['startsAt'] as String? ?? '',
);
@@ -0,0 +1,44 @@
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:aioa_mobile/features/form/data/leave_draft_submission_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final leaveDraftSubmissionRepositoryProvider =
Provider<LeaveDraftSubmissionRepository>(
(ref) => LeaveDraftSubmissionRepository(
client: ref.watch(authenticatedHttpClientProvider),
baseUrl: RuntimeConfig.apiBaseUrl,
),
);
final leaveSubmissionProvider =
NotifierProvider<LeaveSubmissionController, LeaveSubmissionState>(
LeaveSubmissionController.new,
);
class LeaveSubmissionState {
const LeaveSubmissionState({this.submitting = false, this.error});
final bool submitting;
final String? error;
}
class LeaveSubmissionController extends Notifier<LeaveSubmissionState> {
@override
LeaveSubmissionState build() => const LeaveSubmissionState();
Future<CreatedLeaveDraft?> submit(Map<String, Object?> values) async {
if (state.submitting) return null;
state = const LeaveSubmissionState(submitting: true);
try {
final created = await ref
.read(leaveDraftSubmissionRepositoryProvider)
.create(values);
state = const LeaveSubmissionState();
return created;
} on LeaveDraftSubmissionException catch (error) {
state = LeaveSubmissionState(error: error.message);
return null;
}
}
}
@@ -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);
}
}
@@ -0,0 +1,138 @@
import 'package:aioa_mobile/features/form/application/leave_attachment_controller.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class LeaveAttachmentSheet extends ConsumerStatefulWidget {
const LeaveAttachmentSheet({required this.leaveRequestId, super.key});
final String leaveRequestId;
@override
ConsumerState<LeaveAttachmentSheet> createState() =>
_LeaveAttachmentSheetState();
}
class _LeaveAttachmentSheetState extends ConsumerState<LeaveAttachmentSheet> {
@override
void initState() {
super.initState();
Future.microtask(
() => ref
.read(leaveAttachmentProvider(widget.leaveRequestId).notifier)
.load(),
);
}
@override
Widget build(BuildContext context) {
final state = ref.watch(leaveAttachmentProvider(widget.leaveRequestId));
return SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(
20,
0,
20,
20 + MediaQuery.viewInsetsOf(context).bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'添加附件',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 6),
const Text('支持 JPEG、PNG、PDF,单个文件不超过 10 MB。文件将直接上传至对象存储。'),
if (state.error != null) ...[
const SizedBox(height: 10),
Text(
state.error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
if (state.uploading) ...[
const SizedBox(height: 14),
LinearProgressIndicator(value: state.progress),
const SizedBox(height: 6),
Text('正在上传 ${(state.progress * 100).round()}%'),
],
if (state.items.isNotEmpty) ...[
const SizedBox(height: 14),
for (final item in state.items)
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
item.contentType == 'application/pdf'
? Icons.picture_as_pdf_outlined
: Icons.image_outlined,
),
title: Text(
item.fileName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${_formatBytes(item.sizeBytes)} · ${item.status == 'READY' ? '已上传' : '处理中'}',
),
trailing: item.status == 'READY'
? const Icon(Icons.check_circle, color: Colors.green)
: null,
),
],
const SizedBox(height: 14),
OutlinedButton.icon(
onPressed: state.uploading ? null : _pickAndUpload,
icon: const Icon(Icons.attach_file),
label: const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text('选择附件'),
),
),
const SizedBox(height: 8),
FilledButton(
onPressed: state.uploading ? null : () => Navigator.pop(context),
child: const Text('完成'),
),
],
),
),
);
}
Future<void> _pickAndUpload() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: const ['jpg', 'jpeg', 'png', 'pdf'],
withData: true,
);
if (result == null || !mounted) return;
final file = result.files.single;
final bytes = file.bytes;
if (bytes == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('无法读取所选文件')));
return;
}
final contentType = switch (file.extension?.toLowerCase()) {
'jpg' || 'jpeg' => 'image/jpeg',
'png' => 'image/png',
'pdf' => 'application/pdf',
_ => null,
};
if (contentType == null) return;
await ref
.read(leaveAttachmentProvider(widget.leaveRequestId).notifier)
.upload(fileName: file.name, contentType: contentType, bytes: bytes);
}
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
}
@@ -1,8 +1,13 @@
import 'dart:convert';
import 'package:aioa_mobile/core/forms/presentation/dynamic_form_card.dart';
import 'package:aioa_mobile/features/form/application/form_definition_controller.dart';
import 'package:aioa_mobile/features/form/application/ai_leave_suggestion_provider.dart';
import 'package:aioa_mobile/features/form/application/leave_draft_controller.dart';
import 'package:aioa_mobile/features/form/domain/leave_form_definition.dart';
import 'package:aioa_mobile/features/form/application/leave_submission_controller.dart';
import 'package:aioa_mobile/features/form/data/form_definition_repository.dart';
import 'package:aioa_mobile/features/form/data/ai_leave_suggestion_repository.dart';
import 'package:aioa_mobile/features/form/presentation/leave_attachment_sheet.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -13,51 +18,154 @@ class LeaveFormPage extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final formState = ref.watch(leaveDraftProvider);
final controller = ref.read(leaveDraftProvider.notifier);
final loadedDefinition = ref.watch(leaveFormDefinitionProvider);
final submission = ref.watch(leaveSubmissionProvider);
return Scaffold(
appBar: AppBar(title: Text(leaveFormDefinition.dataSchema.title)),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: [
_AiAssistCard(onApply: controller.applyAiSuggestion),
const SizedBox(height: 14),
DynamicFormCard(
definition: leaveFormDefinition,
state: formState,
onChanged: controller.setValue,
),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: () {
if (!controller.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请检查表单中的必填项和时间范围')),
);
return;
}
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) => _ConfirmationSheet(
values: ref.read(leaveDraftProvider).values,
),
);
},
icon: const Icon(Icons.check_circle_outline),
label: const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Text('检查并确认'),
appBar: AppBar(title: const Text('请假申请')),
body: loadedDefinition.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text('表单定义加载失败:$error')),
data: (loaded) => ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: [
_DefinitionSourceBanner(source: loaded.source),
if (formState.restoredAt != null) ...[
const SizedBox(height: 8),
_RestoredDraftBanner(onClear: controller.clear),
],
const SizedBox(height: 8),
_AiAssistCard(onApply: controller.applySuggestion),
const SizedBox(height: 14),
DynamicFormCard(
definition: loaded.definition,
state: formState,
onChanged: controller.setValue,
),
),
],
const SizedBox(height: 8),
FilledButton.icon(
onPressed: submission.submitting
? null
: () async {
if (!controller.validate(loaded.definition.dataSchema)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请检查表单中的必填项和时间范围')),
);
return;
}
final confirmed = await showModalBottomSheet<bool>(
context: context,
showDragHandle: true,
builder: (context) => _ConfirmationSheet(
values: ref.read(leaveDraftProvider).values,
),
);
if (confirmed != true || !context.mounted) return;
final created = await ref
.read(leaveSubmissionProvider.notifier)
.submit(ref.read(leaveDraftProvider).values);
if (!context.mounted) return;
if (created == null) {
final message = ref.read(leaveSubmissionProvider).error;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message ?? '创建草稿失败')),
);
return;
}
await controller.clear();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('后端草稿已创建:${created.id}')),
);
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) =>
LeaveAttachmentSheet(leaveRequestId: created.id),
);
},
icon: submission.submitting
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check_circle_outline),
label: Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Text(submission.submitting ? '正在创建草稿…' : '检查并确认'),
),
),
],
),
),
);
}
}
class _AiAssistCard extends StatelessWidget {
class _RestoredDraftBanner extends StatelessWidget {
const _RestoredDraftBanner({required this.onClear});
final Future<void> Function() onClear;
@override
Widget build(BuildContext context) {
return MaterialBanner(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
leading: const Icon(Icons.restore),
content: const Text('已恢复上次未完成的本地草稿'),
actions: [TextButton(onPressed: onClear, child: const Text('清除'))],
);
}
}
class _DefinitionSourceBanner extends StatelessWidget {
const _DefinitionSourceBanner({required this.source});
final FormDefinitionSource source;
@override
Widget build(BuildContext context) {
final (icon, text) = switch (source) {
FormDefinitionSource.remote => (Icons.cloud_done_outlined, '已加载最新表单定义'),
FormDefinitionSource.cache => (
Icons.offline_bolt_outlined,
'当前离线,使用已缓存表单定义',
),
FormDefinitionSource.bundled => (
Icons.inventory_2_outlined,
'当前离线,使用内置安全表单定义',
),
};
return Row(
children: [
Icon(icon, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(text, style: Theme.of(context).textTheme.bodySmall),
),
],
);
}
}
class _AiAssistCard extends ConsumerStatefulWidget {
const _AiAssistCard({required this.onApply});
final VoidCallback onApply;
final void Function(Map<String, Object?> values) onApply;
@override
ConsumerState<_AiAssistCard> createState() => _AiAssistCardState();
}
class _AiAssistCardState extends ConsumerState<_AiAssistCard> {
final textController = TextEditingController(text: '明天下午请事假四小时,办理个人事务');
bool loading = false;
@override
void dispose() {
textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
@@ -67,29 +175,75 @@ class _AiAssistCard extends StatelessWidget {
).colorScheme.primaryContainer.withValues(alpha: 0.45),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const CircleAvatar(child: Icon(Icons.auto_awesome)),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'AI 表单助手',
const Row(
children: [
CircleAvatar(child: Icon(Icons.auto_awesome)),
SizedBox(width: 12),
Expanded(
child: Text(
'千问表单助手',
style: TextStyle(fontWeight: FontWeight.w700),
),
SizedBox(height: 4),
Text('示例:帮我填写明天下午的事假申请'),
],
),
],
),
const SizedBox(height: 12),
TextField(
controller: textController,
minLines: 2,
maxLines: 4,
maxLength: 2000,
decoration: const InputDecoration(
hintText: '例如:明天下午请事假四小时,办理个人事务',
border: OutlineInputBorder(),
),
),
TextButton(onPressed: onApply, child: const Text('自动填写')),
FilledButton.icon(
onPressed: loading ? null : _suggest,
icon: loading
? const SizedBox.square(
dimension: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.auto_awesome),
label: Text(loading ? '正在生成建议…' : '生成草稿建议'),
),
],
),
),
);
}
Future<void> _suggest() async {
if (textController.text.trim().isEmpty) return;
setState(() => loading = true);
try {
final suggestion = await ref
.read(aiLeaveSuggestionRepositoryProvider)
.suggest(textController.text);
if (!mounted) return;
widget.onApply(suggestion.values);
final notes = [
...suggestion.assumptions,
...suggestion.needsClarification,
];
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(notes.isEmpty ? 'AI 建议已填入,请检查并确认' : notes.join('')),
),
);
} on AiLeaveSuggestionException catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.message)));
} finally {
if (mounted) setState(() => loading = false);
}
}
}
class _ConfirmationSheet extends StatelessWidget {
@@ -128,10 +282,7 @@ class _ConfirmationSheet extends StatelessWidget {
const SizedBox(height: 16),
FilledButton(
onPressed: () {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('演示模式:草稿已通过本地校验,尚未调用后端')),
);
Navigator.pop(context, true);
},
child: const Text('确认创建草稿'),
),
@@ -0,0 +1,40 @@
import 'package:aioa_mobile/core/auth/auth_session_controller.dart';
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:aioa_mobile/features/profile/data/device_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final deviceRepositoryProvider = Provider(
(ref) => DeviceRepository(
client: ref.watch(authenticatedHttpClientProvider),
baseUrl: RuntimeConfig.apiBaseUrl,
),
);
final deviceListProvider =
AsyncNotifierProvider<DeviceController, List<UserDeviceItem>>(
DeviceController.new,
);
class DeviceController extends AsyncNotifier<List<UserDeviceItem>> {
@override
Future<List<UserDeviceItem>> build() =>
ref.read(deviceRepositoryProvider).list();
Future<String?> revoke(String id) async {
try {
final repository = ref.read(deviceRepositoryProvider);
final current = await repository.isCurrent(id);
await repository.revoke(id);
if (current) {
await ref.read(authSessionProvider.notifier).invalidateDeviceSession();
} else {
state = AsyncData([
for (final item in state.value ?? const <UserDeviceItem>[])
if (item.id != id) item,
]);
}
return null;
} catch (error) {
return error.toString();
}
}
}
@@ -0,0 +1,52 @@
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
class UserDeviceItem {
const UserDeviceItem({
required this.id,
required this.name,
required this.platform,
required this.status,
required this.lastSeenAt,
});
final String id, name, platform, status;
final DateTime lastSeenAt;
factory UserDeviceItem.fromJson(Map<String, Object?> json) => UserDeviceItem(
id: json['id']! as String,
name: json['name']! as String,
platform: json['platform']! as String,
status: json['status']! as String,
lastSeenAt: DateTime.parse(json['lastSeenAt']! as String),
);
}
class DeviceRepository {
DeviceRepository({required this.client, required this.baseUrl});
static const _storage = FlutterSecureStorage();
final http.Client client;
final String baseUrl;
Future<List<UserDeviceItem>> list() async {
final response = await client.get(Uri.parse('$baseUrl/devices'));
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('设备列表加载失败');
}
return (jsonDecode(response.body) as List)
.map(
(item) =>
UserDeviceItem.fromJson(Map<String, Object?>.from(item as Map)),
)
.toList();
}
Future<void> revoke(String id) async {
final response = await client.delete(Uri.parse('$baseUrl/devices/$id'));
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('撤销设备失败');
}
}
Future<bool> isCurrent(String id) async =>
await _storage.read(key: 'device_id') == id;
}
@@ -1,9 +1,75 @@
import 'package:aioa_mobile/core/auth/auth_session_controller.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:aioa_mobile/features/profile/application/device_controller.dart';
import 'package:intl/intl.dart';
class ProfilePage extends StatelessWidget {
class ProfilePage extends ConsumerWidget {
const ProfilePage({super.key});
@override
Widget build(BuildContext context) =>
const Center(child: Text('员工小明 · 产品研发部'));
Widget build(BuildContext context, WidgetRef ref) {
final devices = ref.watch(deviceListProvider);
return ListView(
padding: const EdgeInsets.all(18),
children: [
const Card(
child: ListTile(
leading: CircleAvatar(child: Icon(Icons.person_outline)),
title: Text('企业账号'),
subtitle: Text('身份由 Keycloak OIDC 管理'),
),
),
const SizedBox(height: 12),
Text('登录设备', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
...devices.when(
loading: () => const [Center(child: CircularProgressIndicator())],
error: (error, _) => [
Card(
child: ListTile(
title: const Text('设备列表加载失败'),
subtitle: Text('$error'),
),
),
],
data: (items) => items
.map(
(device) => Card(
child: ListTile(
leading: Icon(
device.platform == 'IOS'
? Icons.phone_iphone
: Icons.phone_android,
),
title: Text(device.name),
subtitle: Text(
'${device.status == 'ACTIVE' ? '已登录' : '已撤销'} · ${DateFormat('MM-dd HH:mm').format(device.lastSeenAt.toLocal())}',
),
trailing: device.status != 'ACTIVE'
? null
: IconButton(
tooltip: '撤销并退出',
icon: const Icon(Icons.logout),
onPressed: () => ref
.read(deviceListProvider.notifier)
.revoke(device.id),
),
),
),
)
.toList(),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: ref.read(authSessionProvider.notifier).logout,
icon: const Icon(Icons.logout),
label: const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text('安全退出'),
),
),
],
);
}
}
@@ -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,
};
}
@@ -0,0 +1,48 @@
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:aioa_mobile/features/tasks/data/approval_task_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final approvalTaskRepositoryProvider = Provider<ApprovalTaskRepository>(
(ref) => ApprovalTaskRepository(
client: ref.watch(authenticatedHttpClientProvider),
baseUrl: RuntimeConfig.apiBaseUrl,
),
);
final approvalTaskProvider =
AsyncNotifierProvider<ApprovalTaskController, List<ApprovalTaskItem>>(
ApprovalTaskController.new,
);
class ApprovalTaskController extends AsyncNotifier<List<ApprovalTaskItem>> {
@override
Future<List<ApprovalTaskItem>> build() =>
ref.read(approvalTaskRepositoryProvider).list();
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref.read(approvalTaskRepositoryProvider).list(),
);
}
Future<String?> decide({
required ApprovalTaskItem task,
required bool approved,
String? comment,
}) async {
try {
await ref
.read(approvalTaskRepositoryProvider)
.decide(task: task, approved: approved, comment: comment);
state = AsyncData([
for (final item in state.value ?? const <ApprovalTaskItem>[])
if (item.id != task.id) item,
]);
return null;
} on ApprovalTaskException catch (error) {
return error.message;
}
}
}
@@ -0,0 +1,39 @@
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:aioa_mobile/features/tasks/data/notification_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final notificationRepositoryProvider = Provider<NotificationRepository>(
(ref) => NotificationRepository(
client: ref.watch(authenticatedHttpClientProvider),
baseUrl: RuntimeConfig.apiBaseUrl,
),
);
final notificationProvider =
AsyncNotifierProvider<NotificationController, List<AppNotification>>(
NotificationController.new,
);
class NotificationController extends AsyncNotifier<List<AppNotification>> {
@override
Future<List<AppNotification>> build() =>
ref.read(notificationRepositoryProvider).list();
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref.read(notificationRepositoryProvider).list(),
);
}
Future<void> markRead(String id) async {
final current = state.value;
if (current == null) return;
final updated = await ref.read(notificationRepositoryProvider).markRead(id);
state = AsyncData([
for (final item in current)
if (item.id == id) updated else item,
]);
}
}
@@ -0,0 +1,185 @@
import 'dart:convert';
import 'dart:math';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
class ApprovalTaskItem {
const ApprovalTaskItem({
required this.id,
required this.name,
required this.createdAt,
required this.leaveRequest,
});
final String id;
final String name;
final DateTime createdAt;
final ApprovalLeaveRequest leaveRequest;
factory ApprovalTaskItem.fromJson(Map<String, Object?> json) =>
ApprovalTaskItem(
id: json['id']! as String,
name: json['name']! as String,
createdAt: DateTime.parse(json['createdAt']! as String),
leaveRequest: ApprovalLeaveRequest.fromJson(
Map<String, Object?>.from(json['leaveRequest']! as Map),
),
);
}
class ApprovalLeaveRequest {
const ApprovalLeaveRequest({
required this.id,
required this.type,
required this.startsAt,
required this.endsAt,
required this.reason,
required this.status,
required this.version,
});
final String id;
final String type;
final DateTime startsAt;
final DateTime endsAt;
final String reason;
final String status;
final int version;
factory ApprovalLeaveRequest.fromJson(Map<String, Object?> json) =>
ApprovalLeaveRequest(
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,
);
}
class ApprovalTaskException implements Exception {
const ApprovalTaskException(this.message);
final String message;
@override
String toString() => message;
}
class ApprovalTaskRepository {
ApprovalTaskRepository({
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<ApprovalTaskItem>> list() async {
final response = await _client.get(
Uri.parse('$baseUrl/approval-tasks'),
headers: _headers,
);
_requireSuccess(response);
return (jsonDecode(response.body) as List)
.map(
(item) =>
ApprovalTaskItem.fromJson(Map<String, Object?>.from(item as Map)),
)
.toList();
}
Future<void> decide({
required ApprovalTaskItem task,
required bool approved,
String? comment,
}) async {
final action = approved ? 'approve' : 'reject';
final payload = jsonEncode({
'version': task.leaveRequest.version,
if (comment != null && comment.trim().isNotEmpty)
'comment': comment.trim(),
});
final preferences = await SharedPreferences.getInstance();
final storageKey = 'approval.pending.${task.id}.$action';
final existing = preferences.getString(storageKey);
final pending = existing == null ? null : _decodePending(existing);
final idempotencyKey = pending?.payload == payload
? pending!.key
: _newKey(action);
await preferences.setString(
storageKey,
jsonEncode({'key': idempotencyKey, 'payload': payload}),
);
late http.Response response;
try {
response = await _client.post(
Uri.parse('$baseUrl/approval-tasks/${task.id}/$action'),
headers: {
..._headers,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: payload,
);
} catch (_) {
throw const ApprovalTaskException('网络不可用,审批请求已保存,可安全重试');
}
if (response.statusCode < 200 || response.statusCode >= 300) {
if (response.statusCode < 500 && response.statusCode != 401) {
await preferences.remove(storageKey);
}
_requireSuccess(response);
}
await preferences.remove(storageKey);
}
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 ApprovalTaskException(message ?? '待办请求失败(${response.statusCode}');
}
_PendingApproval? _decodePending(String encoded) {
try {
final json = jsonDecode(encoded) as Map<String, Object?>;
return _PendingApproval(
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 'approval-$action-${DateTime.now().microsecondsSinceEpoch}-$entropy';
}
}
class _PendingApproval {
const _PendingApproval(this.key, this.payload);
final String key;
final String payload;
}
@@ -0,0 +1,91 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class AppNotification {
const AppNotification({
required this.id,
required this.type,
required this.title,
required this.body,
required this.createdAt,
this.resourceType,
this.resourceId,
this.readAt,
});
final String id;
final String type;
final String title;
final String body;
final String? resourceType;
final String? resourceId;
final DateTime createdAt;
final DateTime? readAt;
bool get isRead => readAt != null;
factory AppNotification.fromJson(Map<String, Object?> json) =>
AppNotification(
id: json['id']! as String,
type: json['type']! as String,
title: json['title']! as String,
body: json['body']! as String,
resourceType: json['resourceType'] as String?,
resourceId: json['resourceId'] as String?,
createdAt: DateTime.parse(json['createdAt']! as String),
readAt: json['readAt'] == null
? null
: DateTime.parse(json['readAt']! as String),
);
}
class NotificationRepository {
NotificationRepository({
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<AppNotification>> list() async {
final response = await _client.get(
Uri.parse('$baseUrl/notifications'),
headers: _headers,
);
_requireSuccess(response);
return (jsonDecode(response.body) as List)
.map(
(item) =>
AppNotification.fromJson(Map<String, Object?>.from(item as Map)),
)
.toList();
}
Future<AppNotification> markRead(String id) async {
final response = await _client.post(
Uri.parse('$baseUrl/notifications/$id/read'),
headers: _headers,
);
_requireSuccess(response);
return AppNotification.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;
throw Exception('通知请求失败(${response.statusCode}');
}
}
@@ -1,8 +1,280 @@
import 'package:aioa_mobile/features/tasks/application/approval_task_controller.dart';
import 'package:aioa_mobile/features/tasks/application/notification_controller.dart';
import 'package:aioa_mobile/features/tasks/data/approval_task_repository.dart';
import 'package:aioa_mobile/features/tasks/data/notification_repository.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
class TasksPage extends StatelessWidget {
class TasksPage extends ConsumerWidget {
const TasksPage({super.key});
@override
Widget build(BuildContext context) => const Center(child: Text('暂无待办'));
Widget build(BuildContext context, WidgetRef ref) {
final notifications = ref.watch(notificationProvider);
final tasks = ref.watch(approvalTaskProvider);
final unread =
notifications.value?.where((item) => !item.isRead).length ?? 0;
final taskCount = tasks.value?.length ?? 0;
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: const Text('待办与通知'),
bottom: TabBar(
tabs: [
Tab(text: taskCount == 0 ? '待办' : '待办 ($taskCount)'),
Tab(text: unread == 0 ? '通知' : '通知 ($unread)'),
],
),
),
body: TabBarView(
children: [
_ApprovalTaskList(tasks: tasks),
_NotificationList(notifications: notifications),
],
),
),
);
}
}
class _ApprovalTaskList extends ConsumerWidget {
const _ApprovalTaskList({required this.tasks});
final AsyncValue<List<ApprovalTaskItem>> tasks;
@override
Widget build(BuildContext context, WidgetRef ref) => tasks.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('待办加载失败:$error'),
const SizedBox(height: 8),
FilledButton(
onPressed: ref.read(approvalTaskProvider.notifier).refresh,
child: const Text('重试'),
),
],
),
),
data: (items) {
if (items.isEmpty) return const Center(child: Text('暂无待办'));
return RefreshIndicator(
onRefresh: ref.read(approvalTaskProvider.notifier).refresh,
child: ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: items.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) =>
_ApprovalTaskCard(task: items[index]),
),
);
},
);
}
class _ApprovalTaskCard extends ConsumerWidget {
const _ApprovalTaskCard({required this.task});
final ApprovalTaskItem task;
@override
Widget build(BuildContext context, WidgetRef ref) {
final request = task.leaveRequest;
final formatter = DateFormat('MM-dd HH:mm');
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const CircleAvatar(child: Icon(Icons.assignment_ind_outlined)),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
task.name,
style: const TextStyle(fontWeight: FontWeight.w700),
),
Text(_typeLabel(request.type)),
],
),
),
],
),
const SizedBox(height: 12),
Text(
'${formatter.format(request.startsAt.toLocal())}${formatter.format(request.endsAt.toLocal())}',
),
const SizedBox(height: 6),
Text(request.reason),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => _decide(context, ref, approved: false),
child: const Text('驳回'),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton(
onPressed: () => _decide(context, ref, approved: true),
child: const Text('批准'),
),
),
],
),
],
),
),
);
}
Future<void> _decide(
BuildContext context,
WidgetRef ref, {
required bool approved,
}) async {
final comment = await showDialog<String>(
context: context,
builder: (context) => _DecisionDialog(approved: approved),
);
if (comment == null || !context.mounted) return;
final error = await ref
.read(approvalTaskProvider.notifier)
.decide(task: task, approved: approved, comment: comment);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error ?? (approved ? '已批准' : '已驳回'))),
);
}
String _typeLabel(String type) => switch (type) {
'PERSONAL' => '事假',
'SICK' => '病假',
'ANNUAL' => '年假',
_ => type,
};
}
class _DecisionDialog extends StatefulWidget {
const _DecisionDialog({required this.approved});
final bool approved;
@override
State<_DecisionDialog> createState() => _DecisionDialogState();
}
class _DecisionDialogState extends State<_DecisionDialog> {
final controller = TextEditingController();
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => AlertDialog(
title: Text(widget.approved ? '批准申请' : '驳回申请'),
content: TextField(
controller: controller,
maxLength: 1000,
maxLines: 3,
decoration: InputDecoration(
labelText: widget.approved ? '审批意见(选填)' : '驳回原因',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, controller.text),
child: Text(widget.approved ? '确认批准' : '确认驳回'),
),
],
);
}
class _NotificationList extends ConsumerWidget {
const _NotificationList({required this.notifications});
final AsyncValue<List<AppNotification>> notifications;
@override
Widget build(BuildContext context, WidgetRef ref) => notifications.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('通知加载失败:$error'),
const SizedBox(height: 8),
FilledButton(
onPressed: ref.read(notificationProvider.notifier).refresh,
child: const Text('重试'),
),
],
),
),
data: (items) {
if (items.isEmpty) return const Center(child: Text('暂无通知'));
return RefreshIndicator(
onRefresh: ref.read(notificationProvider.notifier).refresh,
child: ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: items.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final item = items[index];
return Card(
color: item.isRead
? null
: Theme.of(
context,
).colorScheme.primaryContainer.withValues(alpha: 0.35),
child: ListTile(
leading: Icon(_icon(item.type)),
title: Text(
item.title,
style: TextStyle(
fontWeight: item.isRead ? FontWeight.w500 : FontWeight.w700,
),
),
subtitle: Text(
'${item.body}\n${DateFormat('MM-dd HH:mm').format(item.createdAt.toLocal())}',
),
isThreeLine: true,
trailing: item.isRead ? null : const Badge(),
onTap: item.isRead
? null
: () => ref
.read(notificationProvider.notifier)
.markRead(item.id),
),
);
},
),
);
},
);
IconData _icon(String type) => switch (type) {
'APPROVAL_TASK_ASSIGNED' => Icons.assignment_outlined,
'LEAVE_APPROVED' => Icons.check_circle_outline,
'LEAVE_REJECTED' => Icons.cancel_outlined,
_ => Icons.notifications_none,
};
}
@@ -53,6 +53,21 @@ class WorkspacePage extends StatelessWidget {
),
),
),
const SizedBox(height: 10),
Card(
child: ListTile(
onTap: () => context.push('/leave'),
leading: const CircleAvatar(
child: Icon(Icons.description_outlined),
),
title: const Text(
'我的请假申请',
style: TextStyle(fontWeight: FontWeight.w700),
),
subtitle: const Text('查看草稿、审批状态、时间线和撤回申请'),
trailing: const Icon(Icons.chevron_right),
),
),
const SizedBox(height: 16),
const _StatusCard(),
],