feat: complete MVP administration tools

This commit is contained in:
selfrelease
2026-07-18 19:47:20 +08:00
parent d0a2f4f923
commit b311be6aa6
14 changed files with 615 additions and 8 deletions
+2
View File
@@ -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'),
);
});
}