feat: complete leave approval MVP
This commit is contained in:
@@ -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('安全退出'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user