feat: complete MVP administration tools
This commit is contained in:
@@ -6,6 +6,7 @@ import 'package:aioa_mobile/features/requests/presentation/leave_request_detail_
|
||||
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:aioa_mobile/features/admin/presentation/admin_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -30,6 +31,7 @@ final appRouter = GoRouter(
|
||||
),
|
||||
),
|
||||
GoRoute(path: '/leave', builder: (_, _) => const LeaveRequestListPage()),
|
||||
GoRoute(path: '/admin', builder: (_, _) => const AdminPage()),
|
||||
GoRoute(
|
||||
path: '/leave/:id',
|
||||
builder: (_, state) =>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
|
||||
import 'package:aioa_mobile/core/config/runtime_config.dart';
|
||||
import 'package:aioa_mobile/features/admin/data/admin_repository.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
final adminRepositoryProvider = Provider(
|
||||
(ref) => AdminRepository(
|
||||
client: ref.watch(authenticatedHttpClientProvider),
|
||||
baseUrl: RuntimeConfig.apiBaseUrl,
|
||||
),
|
||||
);
|
||||
final currentPermissionsProvider = FutureProvider(
|
||||
(ref) => ref.watch(adminRepositoryProvider).permissions(),
|
||||
);
|
||||
final adminDashboardProvider =
|
||||
AsyncNotifierProvider<AdminController, Map<String, Object?>>(
|
||||
AdminController.new,
|
||||
);
|
||||
|
||||
class AdminController extends AsyncNotifier<Map<String, Object?>> {
|
||||
@override
|
||||
Future<Map<String, Object?>> build() =>
|
||||
ref.read(adminRepositoryProvider).dashboard();
|
||||
Future<String?> createDepartment(String code, String name) async {
|
||||
try {
|
||||
await ref.read(adminRepositoryProvider).createDepartment(code, name);
|
||||
ref.invalidateSelf();
|
||||
return null;
|
||||
} catch (e) {
|
||||
return '$e';
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> replaceRoles(String id, Set<String> roles) async {
|
||||
try {
|
||||
await ref.read(adminRepositoryProvider).replaceRoles(id, roles);
|
||||
ref.invalidateSelf();
|
||||
return null;
|
||||
} catch (e) {
|
||||
return '$e';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class AdminRepository {
|
||||
AdminRepository({required this.client, required this.baseUrl});
|
||||
final http.Client client;
|
||||
final String baseUrl;
|
||||
Future<Set<String>> permissions() async =>
|
||||
((await _get('/me'))['permissions'] as List).cast<String>().toSet();
|
||||
Future<Map<String, Object?>> dashboard() async {
|
||||
final values = await Future.wait([
|
||||
_get('/admin/metrics'),
|
||||
_list('/admin/organization/departments'),
|
||||
_list('/admin/organization/roles'),
|
||||
_list('/admin/organization/users'),
|
||||
_list('/admin/workflows/definitions'),
|
||||
_list('/admin/workflows/instances'),
|
||||
_list('/admin/audit-events?limit=100'),
|
||||
]);
|
||||
return {
|
||||
'metrics': values[0],
|
||||
'departments': values[1],
|
||||
'roles': values[2],
|
||||
'users': values[3],
|
||||
'definitions': values[4],
|
||||
'instances': values[5],
|
||||
'audits': values[6],
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> createDepartment(String code, String name) => _write(
|
||||
'POST',
|
||||
'/admin/organization/departments',
|
||||
{'code': code, 'name': name},
|
||||
);
|
||||
Future<void> replaceRoles(String userId, Set<String> roles) => _write(
|
||||
'PUT',
|
||||
'/admin/organization/users/$userId/roles',
|
||||
{'roles': roles.toList()},
|
||||
);
|
||||
Future<Map<String, Object?>> _get(String path) async {
|
||||
final r = await client.get(Uri.parse('$baseUrl$path'));
|
||||
_ok(r);
|
||||
return Map<String, Object?>.from(jsonDecode(r.body) as Map);
|
||||
}
|
||||
|
||||
Future<List<Map<String, Object?>>> _list(String path) async {
|
||||
final r = await client.get(Uri.parse('$baseUrl$path'));
|
||||
_ok(r);
|
||||
return (jsonDecode(r.body) as List)
|
||||
.map((e) => Map<String, Object?>.from(e as Map))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> _write(
|
||||
String method,
|
||||
String path,
|
||||
Map<String, Object?> body,
|
||||
) async {
|
||||
final request = http.Request(method, Uri.parse('$baseUrl$path'))
|
||||
..headers['Content-Type'] = 'application/json'
|
||||
..body = jsonEncode(body);
|
||||
final r = await http.Response.fromStream(await client.send(request));
|
||||
_ok(r);
|
||||
}
|
||||
|
||||
void _ok(http.Response r) {
|
||||
if (r.statusCode < 200 || r.statusCode >= 300) {
|
||||
throw Exception('管理请求失败(${r.statusCode})');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import 'package:aioa_mobile/features/admin/application/admin_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class AdminPage extends ConsumerWidget {
|
||||
const AdminPage({super.key});
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final data = ref.watch(adminDashboardProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('OA 管理中心')),
|
||||
body: data.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(
|
||||
child: FilledButton(
|
||||
onPressed: () => ref.invalidate(adminDashboardProvider),
|
||||
child: Text('加载失败,点击重试\n$error'),
|
||||
),
|
||||
),
|
||||
data: (value) => DefaultTabController(
|
||||
length: 4,
|
||||
child: Column(
|
||||
children: [
|
||||
const TabBar(
|
||||
isScrollable: true,
|
||||
tabs: [
|
||||
Tab(text: '指标'),
|
||||
Tab(text: '组织'),
|
||||
Tab(text: '流程'),
|
||||
Tab(text: '审计'),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
_Metrics(value['metrics']! as Map<String, Object?>),
|
||||
_Organization(value),
|
||||
_Workflow(value),
|
||||
_Audit(value['audits']! as List<Map<String, Object?>>),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Metrics extends StatelessWidget {
|
||||
const _Metrics(this.data);
|
||||
final Map<String, Object?> data;
|
||||
@override
|
||||
Widget build(BuildContext context) => GridView.count(
|
||||
padding: const EdgeInsets.all(12),
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 1.5,
|
||||
children: data.entries
|
||||
.where((entry) => entry.key != 'leaveByStatus')
|
||||
.map(
|
||||
(entry) => Card(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${entry.value}',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
Text(entry.key, textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
class _Organization extends ConsumerWidget {
|
||||
const _Organization(this.data);
|
||||
final Map<String, Object?> data;
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final users = data['users']! as List<Map<String, Object?>>;
|
||||
final roles = data['roles']! as List<Map<String, Object?>>;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: () => _add(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('新增部门'),
|
||||
),
|
||||
...users.map(
|
||||
(user) => Card(
|
||||
child: ListTile(
|
||||
title: Text('${user['displayName']}'),
|
||||
subtitle: Text(
|
||||
'${user['username']} · ${(user['roles'] as List).join('、')}',
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.manage_accounts),
|
||||
onPressed: () => _roles(context, ref, user, roles),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _add(BuildContext context, WidgetRef ref) async {
|
||||
final code = TextEditingController();
|
||||
final name = TextEditingController();
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('新增部门'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: code,
|
||||
decoration: const InputDecoration(labelText: '编码'),
|
||||
),
|
||||
TextField(
|
||||
controller: name,
|
||||
decoration: const InputDecoration(labelText: '名称'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('创建'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await ref
|
||||
.read(adminDashboardProvider.notifier)
|
||||
.createDepartment(code.text, name.text);
|
||||
}
|
||||
code.dispose();
|
||||
name.dispose();
|
||||
}
|
||||
|
||||
Future<void> _roles(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Map<String, Object?> user,
|
||||
List<Map<String, Object?>> available,
|
||||
) async {
|
||||
final selected = (user['roles'] as List).cast<String>().toSet();
|
||||
final result = await showDialog<Set<String>>(
|
||||
context: context,
|
||||
builder: (dialogContext) => StatefulBuilder(
|
||||
builder: (context, setState) => AlertDialog(
|
||||
title: Text('${user['displayName']} 的角色'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: available.map((role) {
|
||||
final code = '${role['code']}';
|
||||
return CheckboxListTile(
|
||||
value: selected.contains(code),
|
||||
title: Text('${role['name']}'),
|
||||
onChanged: (checked) => setState(
|
||||
() => checked == true
|
||||
? selected.add(code)
|
||||
: selected.remove(code),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, selected),
|
||||
child: const Text('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (result != null) {
|
||||
await ref
|
||||
.read(adminDashboardProvider.notifier)
|
||||
.replaceRoles('${user['id']}', result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Workflow extends StatelessWidget {
|
||||
const _Workflow(this.data);
|
||||
final Map<String, Object?> data;
|
||||
@override
|
||||
Widget build(BuildContext context) => ListView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
children: [
|
||||
const Text('流程定义'),
|
||||
...(data['definitions']! as List<Map<String, Object?>>).map(
|
||||
(item) => ListTile(
|
||||
title: Text('${item['name'] ?? item['key']} v${item['version']}'),
|
||||
subtitle: Text('${item['id']}'),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
const Text('最近实例'),
|
||||
...(data['instances']! as List<Map<String, Object?>>).map(
|
||||
(item) => ListTile(
|
||||
title: Text('${item['businessKey'] ?? item['id']}'),
|
||||
subtitle: Text(item['active'] == true ? '运行中' : '已结束'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _Audit extends StatelessWidget {
|
||||
const _Audit(this.items);
|
||||
final List<Map<String, Object?>> items;
|
||||
@override
|
||||
Widget build(BuildContext context) => ListView.builder(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return Card(
|
||||
child: ListTile(
|
||||
title: Text('${item['action']}'),
|
||||
subtitle: Text('${item['resourceType']} · ${item['traceId']}'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ 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';
|
||||
import 'package:aioa_mobile/features/admin/application/admin_controller.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class ProfilePage extends ConsumerWidget {
|
||||
const ProfilePage({super.key});
|
||||
@@ -10,6 +12,8 @@ class ProfilePage extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final devices = ref.watch(deviceListProvider);
|
||||
final permissions =
|
||||
ref.watch(currentPermissionsProvider).value ?? const <String>{};
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(18),
|
||||
children: [
|
||||
@@ -21,6 +25,14 @@ class ProfilePage extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (permissions.contains('ORGANIZATION_MANAGE_TENANT')) ...[
|
||||
FilledButton.icon(
|
||||
onPressed: () => context.push('/admin'),
|
||||
icon: const Icon(Icons.admin_panel_settings),
|
||||
label: const Text('OA 管理中心'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
Text('登录设备', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
...devices.when(
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'dart:convert';
|
||||
import 'package:aioa_mobile/features/admin/data/admin_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
void main() {
|
||||
test('loads server computed permissions', () async {
|
||||
final repository = AdminRepository(
|
||||
baseUrl: 'https://api.test/api/v1',
|
||||
client: MockClient((request) async {
|
||||
expect(request.url.path, '/api/v1/me');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'permissions': ['ORGANIZATION_MANAGE_TENANT'],
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
await repository.permissions(),
|
||||
contains('ORGANIZATION_MANAGE_TENANT'),
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user