feat: complete leave approval MVP
This commit is contained in:
+16
-2
@@ -1,12 +1,26 @@
|
||||
import 'package:aioa_mobile/app/router.dart';
|
||||
import 'package:aioa_mobile/app/theme.dart';
|
||||
import 'package:aioa_mobile/core/auth/auth_session_controller.dart';
|
||||
import 'package:aioa_mobile/core/auth/login_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:aioa_mobile/core/notifications/push_registration_controller.dart';
|
||||
|
||||
class AioaApp extends StatelessWidget {
|
||||
class AioaApp extends ConsumerWidget {
|
||||
const AioaApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final auth = ref.watch(authSessionProvider);
|
||||
if (auth.isLoading) {
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildAioaTheme(),
|
||||
home: const Scaffold(body: Center(child: CircularProgressIndicator())),
|
||||
);
|
||||
}
|
||||
if (auth.value == null) return const LoginPage();
|
||||
ref.watch(pushRegistrationProvider);
|
||||
return MaterialApp.router(
|
||||
title: 'AIOA',
|
||||
debugShowCheckedModeBanner: false,
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'package:aioa_mobile/app/shell.dart';
|
||||
import 'package:aioa_mobile/features/assistant/presentation/assistant_page.dart';
|
||||
import 'package:aioa_mobile/features/form/presentation/leave_form_page.dart';
|
||||
import 'package:aioa_mobile/features/profile/presentation/profile_page.dart';
|
||||
import 'package:aioa_mobile/features/requests/presentation/leave_request_detail_page.dart';
|
||||
import 'package:aioa_mobile/features/requests/presentation/leave_request_list_page.dart';
|
||||
import 'package:aioa_mobile/features/tasks/presentation/tasks_page.dart';
|
||||
import 'package:aioa_mobile/features/workspace/presentation/workspace_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -27,5 +29,11 @@ final appRouter = GoRouter(
|
||||
child: const LeaveFormPage(),
|
||||
),
|
||||
),
|
||||
GoRoute(path: '/leave', builder: (_, _) => const LeaveRequestListPage()),
|
||||
GoRoute(
|
||||
path: '/leave/:id',
|
||||
builder: (_, state) =>
|
||||
LeaveRequestDetailPage(id: state.pathParameters['id']!),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
class AuthSession {
|
||||
const AuthSession({
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.idToken,
|
||||
required this.expiresAt,
|
||||
});
|
||||
|
||||
final String accessToken;
|
||||
final String? refreshToken;
|
||||
final String? idToken;
|
||||
final DateTime expiresAt;
|
||||
|
||||
bool get needsRefresh => expiresAt.isBefore(
|
||||
DateTime.now().toUtc().add(const Duration(minutes: 1)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import 'package:aioa_mobile/core/auth/auth_session.dart';
|
||||
import 'package:aioa_mobile/core/config/runtime_config.dart';
|
||||
import 'package:flutter_appauth/flutter_appauth.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
String get oidcIssuer => RuntimeConfig.oidcIssuer;
|
||||
const oidcClientId = 'aioa-mobile';
|
||||
const oidcRedirectUrl = 'aioa://oauth/callback';
|
||||
const oidcLogoutRedirectUrl = 'aioa://oauth/logout';
|
||||
|
||||
final authSessionProvider =
|
||||
AsyncNotifierProvider<AuthSessionController, AuthSession?>(
|
||||
AuthSessionController.new,
|
||||
);
|
||||
|
||||
class AuthSessionController extends AsyncNotifier<AuthSession?> {
|
||||
static const _storage = FlutterSecureStorage();
|
||||
static const _appAuth = FlutterAppAuth();
|
||||
|
||||
@override
|
||||
Future<AuthSession?> build() => _restore();
|
||||
|
||||
Future<void> login() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final result = await _appAuth.authorizeAndExchangeCode(
|
||||
AuthorizationTokenRequest(
|
||||
oidcClientId,
|
||||
oidcRedirectUrl,
|
||||
issuer: oidcIssuer,
|
||||
scopes: const ['openid', 'profile', 'email', 'offline_access'],
|
||||
promptValues: const ['login'],
|
||||
allowInsecureConnections: oidcIssuer.startsWith('http://'),
|
||||
),
|
||||
);
|
||||
return _storeResponse(result);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
final current = state.value;
|
||||
try {
|
||||
if (current?.idToken != null) {
|
||||
await _appAuth.endSession(
|
||||
EndSessionRequest(
|
||||
idTokenHint: current!.idToken,
|
||||
postLogoutRedirectUrl: oidcLogoutRedirectUrl,
|
||||
issuer: oidcIssuer,
|
||||
allowInsecureConnections: oidcIssuer.startsWith('http://'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await _clear();
|
||||
state = const AsyncData(null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> invalidateDeviceSession() async {
|
||||
await _clear();
|
||||
state = const AsyncData(null);
|
||||
}
|
||||
|
||||
Future<String?> validAccessToken() async {
|
||||
final current = state.value;
|
||||
if (current == null) return null;
|
||||
if (!current.needsRefresh) return current.accessToken;
|
||||
final refreshToken = current.refreshToken;
|
||||
if (refreshToken == null) {
|
||||
await _clear();
|
||||
state = const AsyncData(null);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final refreshed = await _refresh(current);
|
||||
state = AsyncData(refreshed);
|
||||
return refreshed.accessToken;
|
||||
} catch (_) {
|
||||
await _clear();
|
||||
state = const AsyncData(null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<AuthSession?> _restore() async {
|
||||
final values = await Future.wait([
|
||||
_storage.read(key: 'access_token'),
|
||||
_storage.read(key: 'refresh_token'),
|
||||
_storage.read(key: 'id_token'),
|
||||
_storage.read(key: 'expires_at'),
|
||||
]);
|
||||
final accessToken = values[0];
|
||||
final expiresAt = DateTime.tryParse(values[3] ?? '');
|
||||
if (accessToken == null || expiresAt == null) return null;
|
||||
final session = AuthSession(
|
||||
accessToken: accessToken,
|
||||
refreshToken: values[1],
|
||||
idToken: values[2],
|
||||
expiresAt: expiresAt,
|
||||
);
|
||||
if (session.needsRefresh) {
|
||||
if (session.refreshToken == null) {
|
||||
await _clear();
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await _refresh(session);
|
||||
} catch (_) {
|
||||
await _clear();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
Future<AuthSession> _refresh(AuthSession current) async {
|
||||
final response = await _appAuth.token(
|
||||
TokenRequest(
|
||||
oidcClientId,
|
||||
oidcRedirectUrl,
|
||||
issuer: oidcIssuer,
|
||||
refreshToken: current.refreshToken,
|
||||
scopes: const ['openid', 'profile', 'email', 'offline_access'],
|
||||
allowInsecureConnections: oidcIssuer.startsWith('http://'),
|
||||
),
|
||||
);
|
||||
return _storeResponse(response, fallback: current);
|
||||
}
|
||||
|
||||
Future<AuthSession> _storeResponse(
|
||||
TokenResponse response, {
|
||||
AuthSession? fallback,
|
||||
}) async {
|
||||
final accessToken = response.accessToken ?? fallback?.accessToken;
|
||||
final expiresAt =
|
||||
response.accessTokenExpirationDateTime ?? fallback?.expiresAt;
|
||||
if (accessToken == null || expiresAt == null) {
|
||||
throw StateError('OIDC token response is incomplete');
|
||||
}
|
||||
final session = AuthSession(
|
||||
accessToken: accessToken,
|
||||
refreshToken: response.refreshToken ?? fallback?.refreshToken,
|
||||
idToken: response.idToken ?? fallback?.idToken,
|
||||
expiresAt: expiresAt.toUtc(),
|
||||
);
|
||||
await Future.wait([
|
||||
_storage.write(key: 'access_token', value: session.accessToken),
|
||||
_storage.write(key: 'refresh_token', value: session.refreshToken),
|
||||
_storage.write(key: 'id_token', value: session.idToken),
|
||||
_storage.write(
|
||||
key: 'expires_at',
|
||||
value: session.expiresAt.toIso8601String(),
|
||||
),
|
||||
]);
|
||||
return session;
|
||||
}
|
||||
|
||||
Future<void> _clear() async {
|
||||
await Future.wait([
|
||||
_storage.delete(key: 'access_token'),
|
||||
_storage.delete(key: 'refresh_token'),
|
||||
_storage.delete(key: 'id_token'),
|
||||
_storage.delete(key: 'expires_at'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'package:aioa_mobile/core/auth/auth_session_controller.dart';
|
||||
import 'package:aioa_mobile/core/config/runtime_config.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
String get apiBaseUrl => RuntimeConfig.apiBaseUrl;
|
||||
|
||||
final authenticatedHttpClientProvider = Provider<http.Client>((ref) {
|
||||
final inner = http.Client();
|
||||
return AuthenticatedHttpClient(
|
||||
inner: inner,
|
||||
apiOrigin: Uri.parse(apiBaseUrl).origin,
|
||||
tokenProvider: ref.read(authSessionProvider.notifier).validAccessToken,
|
||||
deviceRegistration: ref.watch(deviceRegistrationProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final deviceRegistrationProvider = Provider<DeviceRegistration>((ref) {
|
||||
return DeviceRegistration(
|
||||
inner: http.Client(),
|
||||
baseUrl: apiBaseUrl,
|
||||
onRevoked: ref.read(authSessionProvider.notifier).invalidateDeviceSession,
|
||||
);
|
||||
});
|
||||
|
||||
class AuthenticatedHttpClient extends http.BaseClient {
|
||||
AuthenticatedHttpClient({
|
||||
required this.inner,
|
||||
required this.apiOrigin,
|
||||
required this.tokenProvider,
|
||||
this.deviceRegistration,
|
||||
});
|
||||
|
||||
final http.Client inner;
|
||||
final String apiOrigin;
|
||||
final Future<String?> Function() tokenProvider;
|
||||
final DeviceRegistration? deviceRegistration;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
if (request.url.origin == apiOrigin &&
|
||||
!request.headers.containsKey('Authorization')) {
|
||||
final token = await tokenProvider();
|
||||
if (token != null) {
|
||||
request.headers['Authorization'] = 'Bearer $token';
|
||||
final registration = deviceRegistration;
|
||||
if (registration != null) {
|
||||
request.headers['X-AIOA-Device-Id'] = await registration.ensure(
|
||||
token,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
final response = await inner.send(request);
|
||||
if (response.headers['x-aioa-auth-error'] == 'DEVICE_REVOKED') {
|
||||
await deviceRegistration?.revoked();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@override
|
||||
void close() => inner.close();
|
||||
}
|
||||
|
||||
class DeviceRegistration {
|
||||
DeviceRegistration({
|
||||
required this.inner,
|
||||
required this.baseUrl,
|
||||
required this.onRevoked,
|
||||
});
|
||||
|
||||
static const _storage = FlutterSecureStorage();
|
||||
final http.Client inner;
|
||||
final String baseUrl;
|
||||
final Future<void> Function() onRevoked;
|
||||
String? _registeredToken;
|
||||
String? _deviceId;
|
||||
|
||||
Future<String> ensure(String token) async {
|
||||
final id = _deviceId ??= await _loadOrCreateId();
|
||||
if (_registeredToken == token) return id;
|
||||
final platform = Platform.isIOS
|
||||
? 'IOS'
|
||||
: Platform.isAndroid
|
||||
? 'ANDROID'
|
||||
: 'OTHER';
|
||||
final response = await inner.post(
|
||||
Uri.parse('$baseUrl/devices/register'),
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'id': id,
|
||||
'name': Platform.localHostname.isEmpty
|
||||
? '$platform 设备'
|
||||
: Platform.localHostname,
|
||||
'platform': platform,
|
||||
}),
|
||||
);
|
||||
if (response.statusCode == 401 || response.statusCode == 403) {
|
||||
await revoked();
|
||||
throw const DeviceRevokedException();
|
||||
}
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw DeviceRegistrationException(response.statusCode);
|
||||
}
|
||||
_registeredToken = token;
|
||||
return id;
|
||||
}
|
||||
|
||||
Future<void> revoked() async {
|
||||
_registeredToken = null;
|
||||
await onRevoked();
|
||||
}
|
||||
|
||||
Future<String> currentId() async => _deviceId ??= await _loadOrCreateId();
|
||||
|
||||
Future<String> _loadOrCreateId() async {
|
||||
final existing = await _storage.read(key: 'device_id');
|
||||
if (existing != null) return existing;
|
||||
final random = Random.secure();
|
||||
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
String hex(int start, int end) => bytes
|
||||
.sublist(start, end)
|
||||
.map((value) => value.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
final id =
|
||||
'${hex(0, 4)}-${hex(4, 6)}-${hex(6, 8)}-${hex(8, 10)}-${hex(10, 16)}';
|
||||
await _storage.write(key: 'device_id', value: id);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
class DeviceRevokedException implements Exception {
|
||||
const DeviceRevokedException();
|
||||
}
|
||||
|
||||
class DeviceRegistrationException implements Exception {
|
||||
const DeviceRegistrationException(this.statusCode);
|
||||
final int statusCode;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:aioa_mobile/app/theme.dart';
|
||||
import 'package:aioa_mobile/core/auth/auth_session_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class LoginPage extends ConsumerWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final auth = ref.watch(authSessionProvider);
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildAioaTheme(),
|
||||
home: Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(28),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
radius: 36,
|
||||
child: Icon(Icons.apartment_rounded, size: 38),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'AIOA',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineMedium
|
||||
?.copyWith(fontWeight: FontWeight.w900),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text('使用企业账号安全登录', textAlign: TextAlign.center),
|
||||
if (auth.hasError) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'登录失败:${auth.error}',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 28),
|
||||
FilledButton.icon(
|
||||
onPressed: auth.isLoading
|
||||
? null
|
||||
: ref.read(authSessionProvider.notifier).login,
|
||||
icon: auth.isLoading
|
||||
? const SizedBox.square(
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.login),
|
||||
label: const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 14),
|
||||
child: Text('Keycloak 登录'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class RuntimeConfig {
|
||||
static const _configuredApiBaseUrl = String.fromEnvironment(
|
||||
'AIOA_API_BASE_URL',
|
||||
);
|
||||
static const _configuredOidcIssuer = String.fromEnvironment(
|
||||
'AIOA_OIDC_ISSUER',
|
||||
);
|
||||
|
||||
static String get apiBaseUrl => _configuredApiBaseUrl.isNotEmpty
|
||||
? _configuredApiBaseUrl
|
||||
: '${_localOrigin(8080)}/api/v1';
|
||||
|
||||
static String get oidcIssuer => _configuredOidcIssuer.isNotEmpty
|
||||
? _configuredOidcIssuer
|
||||
: 'http://localhost:8081/realms/aioa';
|
||||
|
||||
static String _localOrigin(int port) {
|
||||
final host = defaultTargetPlatform == TargetPlatform.android
|
||||
? '10.0.2.2'
|
||||
: '127.0.0.1';
|
||||
return 'http://$host:$port';
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,17 @@ class DynamicFormDefinition {
|
||||
|
||||
final JsonFormSchema dataSchema;
|
||||
final FormUiSchema uiSchema;
|
||||
|
||||
factory DynamicFormDefinition.fromJson(Map<String, Object?> json) {
|
||||
return DynamicFormDefinition(
|
||||
dataSchema: JsonFormSchema.fromJson(
|
||||
Map<String, Object?>.from(json['dataSchema']! as Map),
|
||||
),
|
||||
uiSchema: FormUiSchema.fromJson(
|
||||
Map<String, Object?>.from(json['uiSchema']! as Map),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class JsonFormSchema {
|
||||
@@ -24,6 +35,23 @@ class JsonFormSchema {
|
||||
final String title;
|
||||
final Map<String, JsonFieldSchema> properties;
|
||||
final Set<String> required;
|
||||
|
||||
factory JsonFormSchema.fromJson(Map<String, Object?> json) {
|
||||
final properties = Map<String, Object?>.from(json['properties']! as Map);
|
||||
return JsonFormSchema(
|
||||
id: json[r'$id']! as String,
|
||||
title: json['title']! as String,
|
||||
properties: properties.map(
|
||||
(key, value) => MapEntry(
|
||||
key,
|
||||
JsonFieldSchema.fromJson(Map<String, Object?>.from(value as Map)),
|
||||
),
|
||||
),
|
||||
required: ((json['required'] as List?) ?? const [])
|
||||
.cast<String>()
|
||||
.toSet(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class JsonFieldSchema {
|
||||
@@ -40,6 +68,16 @@ class JsonFieldSchema {
|
||||
final List<String> enumValues;
|
||||
final int? minLength;
|
||||
final int? maxLength;
|
||||
|
||||
factory JsonFieldSchema.fromJson(Map<String, Object?> json) {
|
||||
return JsonFieldSchema(
|
||||
type: JsonValueType.values.byName(json['type']! as String),
|
||||
format: json['format'] as String?,
|
||||
enumValues: ((json['enum'] as List?) ?? const []).cast<String>(),
|
||||
minLength: json['minLength'] as int?,
|
||||
maxLength: json['maxLength'] as int?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FormUiSchema {
|
||||
@@ -47,6 +85,19 @@ class FormUiSchema {
|
||||
|
||||
final String description;
|
||||
final List<FormSectionSchema> sections;
|
||||
|
||||
factory FormUiSchema.fromJson(Map<String, Object?> json) {
|
||||
return FormUiSchema(
|
||||
description: json['description']! as String,
|
||||
sections: (json['sections']! as List)
|
||||
.map(
|
||||
(value) => FormSectionSchema.fromJson(
|
||||
Map<String, Object?>.from(value as Map),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FormSectionSchema {
|
||||
@@ -54,6 +105,19 @@ class FormSectionSchema {
|
||||
|
||||
final String title;
|
||||
final List<FormControlSchema> controls;
|
||||
|
||||
factory FormSectionSchema.fromJson(Map<String, Object?> json) {
|
||||
return FormSectionSchema(
|
||||
title: json['title']! as String,
|
||||
controls: (json['controls']! as List)
|
||||
.map(
|
||||
(value) => FormControlSchema.fromJson(
|
||||
Map<String, Object?>.from(value as Map),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FormControlSchema {
|
||||
@@ -72,21 +136,41 @@ class FormControlSchema {
|
||||
final String? placeholder;
|
||||
final String? helperText;
|
||||
final Map<String, String> optionLabels;
|
||||
|
||||
factory FormControlSchema.fromJson(Map<String, Object?> json) {
|
||||
return FormControlSchema(
|
||||
field: json['field']! as String,
|
||||
label: json['label']! as String,
|
||||
control: FormControlType.values.byName(json['control']! as String),
|
||||
placeholder: json['placeholder'] as String?,
|
||||
helperText: json['helperText'] as String?,
|
||||
optionLabels: ((json['optionLabels'] as Map?) ?? const {})
|
||||
.cast<String, String>(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DynamicFormState {
|
||||
const DynamicFormState({this.values = const {}, this.errors = const {}});
|
||||
const DynamicFormState({
|
||||
this.values = const {},
|
||||
this.errors = const {},
|
||||
this.restoredAt,
|
||||
});
|
||||
|
||||
final Map<String, Object?> values;
|
||||
final Map<String, String> errors;
|
||||
final DateTime? restoredAt;
|
||||
|
||||
DynamicFormState copyWith({
|
||||
Map<String, Object?>? values,
|
||||
Map<String, String>? errors,
|
||||
DateTime? restoredAt,
|
||||
bool clearRestoredAt = false,
|
||||
}) {
|
||||
return DynamicFormState(
|
||||
values: values ?? this.values,
|
||||
errors: errors ?? this.errors,
|
||||
restoredAt: clearRestoredAt ? null : restoredAt ?? this.restoredAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
|
||||
import 'package:aioa_mobile/core/config/runtime_config.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
final pushRegistrationProvider =
|
||||
AsyncNotifierProvider<PushRegistrationController, bool>(
|
||||
PushRegistrationController.new,
|
||||
);
|
||||
|
||||
class PushRegistrationController extends AsyncNotifier<bool> {
|
||||
@override
|
||||
Future<bool> build() async {
|
||||
try {
|
||||
if (Firebase.apps.isEmpty) await Firebase.initializeApp();
|
||||
final messaging = FirebaseMessaging.instance;
|
||||
final settings = await messaging.requestPermission(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
if (settings.authorizationStatus == AuthorizationStatus.denied) {
|
||||
return false;
|
||||
}
|
||||
final token = await messaging.getToken();
|
||||
if (token == null) return false;
|
||||
await _upload(token);
|
||||
messaging.onTokenRefresh.listen(_upload);
|
||||
return true;
|
||||
} catch (_) {
|
||||
// 未提供 Firebase 平台配置时保留站内通知,应用仍可正常启动。
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _upload(String token) async {
|
||||
final id = await ref.read(deviceRegistrationProvider).currentId();
|
||||
final response = await ref
|
||||
.read(authenticatedHttpClientProvider)
|
||||
.put(
|
||||
Uri.parse('${RuntimeConfig.apiBaseUrl}/devices/$id/push-token'),
|
||||
headers: const {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'token': token}),
|
||||
);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw http.ClientException('Push token registration failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user