diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/metrics/OperationsMetricsController.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/metrics/OperationsMetricsController.kt new file mode 100644 index 0000000..7d60225 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/metrics/OperationsMetricsController.kt @@ -0,0 +1,35 @@ +package com.all8ai.aioa.admin.metrics + +import com.all8ai.aioa.identity.application.CurrentUserService +import com.all8ai.aioa.shared.security.ToolPermission +import com.all8ai.aioa.shared.security.requirePermission +import org.jooq.DSLContext +import org.flowable.engine.RuntimeService +import org.flowable.engine.TaskService +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +data class OperationsMetrics( + val leaveByStatus:Map,val activeWorkflowInstances:Long,val activeWorkflowTasks:Long, + val unreadNotifications:Int,val pendingPush:Int,val failedPushAttempts:Int,val activeDevices:Int, +) + +@RestController @RequestMapping("/api/v1/admin/metrics") +class OperationsMetricsController(private val users:CurrentUserService,private val dsl:DSLContext,private val runtime:RuntimeService,private val tasks:TaskService) { + @GetMapping fun metrics(@AuthenticationPrincipal jwt:Jwt):OperationsMetrics { + val actor=users.get(jwt.subject,jwt.getClaimAsString("tenant_id")); actor.requirePermission(ToolPermission.OPERATIONS_METRICS_READ_TENANT) + fun count(sql:String,vararg bindings:Any):Int=dsl.fetchOne(sql,*bindings)?.get("count",Int::class.java)?:0 + val statuses=dsl.fetch("SELECT status,COUNT(*)::int count FROM business.leave_request WHERE tenant_id=? GROUP BY status",actor.tenantId) + .associate { it.get("status",String::class.java)!! to it.get("count",Int::class.java)!! } + return OperationsMetrics(statuses, + runtime.createProcessInstanceQuery().variableValueEquals("tenantId",actor.tenantId.toString()).count(), + tasks.createTaskQuery().processVariableValueEquals("tenantId",actor.tenantId.toString()).active().count(), + count("SELECT COUNT(*)::int count FROM communication.notification WHERE tenant_id=? AND read_at IS NULL",actor.tenantId), + count("SELECT COUNT(*)::int count FROM communication.notification_push_outbox o JOIN communication.notification n ON n.id=o.notification_id WHERE n.tenant_id=? AND o.status='PENDING'",actor.tenantId), + count("SELECT COALESCE(SUM(o.attempts),0)::int count FROM communication.notification_push_outbox o JOIN communication.notification n ON n.id=o.notification_id WHERE n.tenant_id=?",actor.tenantId), + count("SELECT COUNT(*)::int count FROM identity.user_device WHERE tenant_id=? AND status='ACTIVE'",actor.tenantId)) + } +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/organization/AdminOrganization.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/organization/AdminOrganization.kt new file mode 100644 index 0000000..2dba03e --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/organization/AdminOrganization.kt @@ -0,0 +1,77 @@ +package com.all8ai.aioa.admin.organization + +import com.all8ai.aioa.audit.application.AuditService +import com.all8ai.aioa.identity.domain.CurrentUser +import com.all8ai.aioa.shared.id.UuidV7 +import com.all8ai.aioa.shared.security.ToolPermission +import com.all8ai.aioa.shared.security.requirePermission +import com.all8ai.aioa.shared.web.ApiException +import org.jooq.DSLContext +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +data class AdminDepartment(val id: UUID, val code: String, val name: String, val status: String) +data class AdminRole(val id: UUID, val code: String, val name: String, val status: String) +data class AdminUser(val id: UUID, val username: String, val displayName: String, val email: String?, val status: String, val departmentId: UUID?, val positionId: UUID?, val roles: Set) + +@Service +class AdminOrganizationService(private val dsl: DSLContext, private val audit: AuditService) { + fun departments(actor: CurrentUser): List { + require(actor) + return dsl.fetch("SELECT id, code, name, status FROM organization.department WHERE tenant_id = ? ORDER BY code", actor.tenantId) + .map { AdminDepartment(it.get("id", UUID::class.java)!!, it.get("code", String::class.java)!!, it.get("name", String::class.java)!!, it.get("status", String::class.java)!!) } + } + fun roles(actor: CurrentUser): List { + require(actor) + return dsl.fetch("SELECT id, code, name, status FROM authz.role WHERE tenant_id = ? ORDER BY code", actor.tenantId) + .map { AdminRole(it.get("id", UUID::class.java)!!, it.get("code", String::class.java)!!, it.get("name", String::class.java)!!, it.get("status", String::class.java)!!) } + } + fun users(actor: CurrentUser): List { + require(actor) + return dsl.fetch(""" + SELECT u.id, u.username, u.display_name, u.email, u.status, a.department_id, a.position_id, + COALESCE(array_agg(r.code ORDER BY r.code) FILTER (WHERE r.code IS NOT NULL), '{}') AS roles + FROM identity.user_account u + LEFT JOIN organization.user_assignment a ON a.tenant_id=u.tenant_id AND a.user_id=u.id AND a.is_primary=TRUE AND a.effective_until IS NULL + LEFT JOIN authz.user_role ur ON ur.tenant_id=u.tenant_id AND ur.user_id=u.id AND ur.effective_until IS NULL + LEFT JOIN authz.role r ON r.tenant_id=ur.tenant_id AND r.id=ur.role_id + WHERE u.tenant_id=? GROUP BY u.id,a.department_id,a.position_id ORDER BY u.username + """.trimIndent(), actor.tenantId).map { + AdminUser(it.get("id", UUID::class.java)!!, it.get("username", String::class.java)!!, it.get("display_name", String::class.java)!!, + it.get("email", String::class.java), it.get("status", String::class.java)!!, it.get("department_id", UUID::class.java), + it.get("position_id", UUID::class.java), (it.get("roles") as? Array<*>)?.filterIsInstance()?.toSet().orEmpty()) + } + } + @Transactional + fun createDepartment(actor: CurrentUser, code: String, name: String): AdminDepartment { + require(actor); val c = valid(code, 64); val n = valid(name, 200); val id = UuidV7.generate() + try { dsl.execute("INSERT INTO organization.department(id,tenant_id,code,name,status) VALUES(?,?,?,?,'ACTIVE')", id, actor.tenantId, c, n) } + catch (_: Exception) { throw ApiException(HttpStatus.CONFLICT, "DEPARTMENT_CODE_EXISTS", "部门编码已存在") } + audit.recordSuccess(actor, "DEPARTMENT_CREATED", "DEPARTMENT", id.toString(), null, mapOf("code" to c)) + return AdminDepartment(id, c, n, "ACTIVE") + } + @Transactional + fun replaceRoles(actor: CurrentUser, userId: UUID, roleCodes: Set) { + require(actor); if (roleCodes.isEmpty()) throw ApiException(HttpStatus.BAD_REQUEST, "ROLES_REQUIRED", "至少保留一个角色") + val bindings = mutableListOf(actor.tenantId).apply { addAll(roleCodes) }.toTypedArray() + val roleIds = dsl.fetch("SELECT id,code FROM authz.role WHERE tenant_id=? AND code IN (${roleCodes.joinToString { "?" }}) AND status='ACTIVE'", *bindings) + if (roleIds.size != roleCodes.size) throw ApiException(HttpStatus.BAD_REQUEST, "ROLE_INVALID", "包含不存在的角色") + if (dsl.fetchOne("SELECT EXISTS(SELECT 1 FROM identity.user_account WHERE tenant_id=? AND id=?) AS ok", actor.tenantId, userId)?.get("ok", Boolean::class.java) != true) throw ApiException(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "用户不存在") + dsl.execute("DELETE FROM authz.user_role WHERE tenant_id=? AND user_id=?", actor.tenantId, userId) + roleIds.forEach { dsl.execute("INSERT INTO authz.user_role(tenant_id,user_id,role_id) VALUES(?,?,?)", actor.tenantId, userId, it.get("id", UUID::class.java)) } + audit.recordSuccess(actor, "USER_ROLES_REPLACED", "USER", userId.toString(), null, mapOf("roles" to roleCodes.sorted())) + } + @Transactional + fun assign(actor: CurrentUser, userId: UUID, departmentId: UUID, positionId: UUID) { + require(actor) + val validRefs = dsl.fetchOne("SELECT EXISTS(SELECT 1 FROM organization.department WHERE tenant_id=? AND id=? AND status='ACTIVE') AND EXISTS(SELECT 1 FROM organization.position WHERE tenant_id=? AND id=? AND status='ACTIVE') AS ok", actor.tenantId, departmentId, actor.tenantId, positionId)?.get("ok", Boolean::class.java) == true + if (!validRefs) throw ApiException(HttpStatus.BAD_REQUEST, "ASSIGNMENT_REFERENCE_INVALID", "部门或岗位无效") + dsl.execute("UPDATE organization.user_assignment SET effective_until=CURRENT_TIMESTAMP,is_primary=FALSE WHERE tenant_id=? AND user_id=? AND is_primary=TRUE AND effective_until IS NULL", actor.tenantId, userId) + dsl.execute("INSERT INTO organization.user_assignment(id,tenant_id,user_id,department_id,position_id,is_primary) VALUES(?,?,?,?,?,TRUE)", UuidV7.generate(), actor.tenantId, userId, departmentId, positionId) + audit.recordSuccess(actor, "USER_ASSIGNMENT_REPLACED", "USER", userId.toString(), null, mapOf("departmentId" to departmentId, "positionId" to positionId)) + } + private fun require(actor: CurrentUser) = actor.requirePermission(ToolPermission.ORGANIZATION_MANAGE_TENANT) + private fun valid(value: String, max: Int) = value.trim().takeIf { it.isNotEmpty() && it.length <= max && it.matches(Regex("[A-Za-z0-9._-]+|[\\p{L}0-9 ._-]+")) } ?: throw ApiException(HttpStatus.BAD_REQUEST, "VALUE_INVALID", "输入值无效") +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/organization/AdminOrganizationController.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/organization/AdminOrganizationController.kt new file mode 100644 index 0000000..e448bfc --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/organization/AdminOrganizationController.kt @@ -0,0 +1,24 @@ +package com.all8ai.aioa.admin.organization + +import com.all8ai.aioa.identity.application.CurrentUserService +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.web.bind.annotation.* +import java.util.UUID + +@RestController @RequestMapping("/api/v1/admin/organization") +class AdminOrganizationController(private val users: CurrentUserService, private val service: AdminOrganizationService) { + private fun actor(jwt: Jwt) = users.get(jwt.subject, jwt.getClaimAsString("tenant_id")) + @GetMapping("/departments") fun departments(@AuthenticationPrincipal jwt: Jwt) = service.departments(actor(jwt)) + @PostMapping("/departments") fun createDepartment(@AuthenticationPrincipal jwt: Jwt, @Valid @RequestBody body: DepartmentCommand) = service.createDepartment(actor(jwt), body.code, body.name) + @GetMapping("/roles") fun roles(@AuthenticationPrincipal jwt: Jwt) = service.roles(actor(jwt)) + @GetMapping("/users") fun listUsers(@AuthenticationPrincipal jwt: Jwt) = service.users(actor(jwt)) + @PutMapping("/users/{id}/roles") fun roles(@AuthenticationPrincipal jwt: Jwt, @PathVariable id: UUID, @RequestBody body: RolesCommand) = service.replaceRoles(actor(jwt), id, body.roles) + @PutMapping("/users/{id}/assignment") fun assign(@AuthenticationPrincipal jwt: Jwt, @PathVariable id: UUID, @RequestBody body: AssignmentCommand) = service.assign(actor(jwt), id, body.departmentId, body.positionId) +} +data class DepartmentCommand(@field:NotBlank @field:Size(max=64) val code:String,@field:NotBlank @field:Size(max=200) val name:String) +data class RolesCommand(val roles:Set) +data class AssignmentCommand(val departmentId:UUID,val positionId:UUID) diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/workflow/AdminWorkflowController.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/workflow/AdminWorkflowController.kt new file mode 100644 index 0000000..425e5aa --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/workflow/AdminWorkflowController.kt @@ -0,0 +1,36 @@ +package com.all8ai.aioa.admin.workflow + +import com.all8ai.aioa.identity.application.CurrentUserService +import com.all8ai.aioa.identity.domain.CurrentUser +import com.all8ai.aioa.shared.security.ToolPermission +import com.all8ai.aioa.shared.security.requirePermission +import org.flowable.engine.HistoryService +import org.flowable.engine.RepositoryService +import org.flowable.engine.RuntimeService +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.web.bind.annotation.* +import java.time.Instant + +data class ProcessDefinitionView(val id:String,val key:String,val name:String?,val version:Int,val deploymentId:String,val suspended:Boolean) +data class ProcessInstanceView(val id:String,val definitionId:String,val businessKey:String?,val startedAt:Instant?,val endedAt:Instant?,val active:Boolean) + +@RestController @RequestMapping("/api/v1/admin/workflows") +class AdminWorkflowController( + private val currentUsers:CurrentUserService, private val repository:RepositoryService, + private val runtime:RuntimeService, private val history:HistoryService, +) { + private fun actor(jwt:Jwt):CurrentUser=currentUsers.get(jwt.subject,jwt.getClaimAsString("tenant_id")).also { it.requirePermission(ToolPermission.WORKFLOW_READ_TENANT) } + @GetMapping("/definitions") fun definitions(@AuthenticationPrincipal jwt:Jwt):List { + actor(jwt); return repository.createProcessDefinitionQuery().orderByProcessDefinitionKey().asc().orderByProcessDefinitionVersion().desc().list() + .map { ProcessDefinitionView(it.id,it.key,it.name,it.version,it.deploymentId,it.isSuspended) } + } + @GetMapping("/instances") fun instances(@AuthenticationPrincipal jwt:Jwt,@RequestParam(defaultValue="100") limit:Int):List { + val actor=actor(jwt); val active=runtime.createProcessInstanceQuery().variableValueEquals("tenantId",actor.tenantId.toString()).listPage(0,limit.coerceIn(1,200)) + .map { ProcessInstanceView(it.id,it.processDefinitionId,it.businessKey,null,null,true) } + if(active.size>=limit) return active + val ended=history.createHistoricProcessInstanceQuery().variableValueEquals("tenantId",actor.tenantId.toString()).finished().orderByProcessInstanceEndTime().desc().listPage(0,(limit-active.size).coerceIn(0,200)) + .map { ProcessInstanceView(it.id,it.processDefinitionId,it.businessKey,it.startTime?.toInstant(),it.endTime?.toInstant(),false) } + return active+ended + } +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/security/AuthorizationPolicy.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/security/AuthorizationPolicy.kt index 14de569..10451da 100644 --- a/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/security/AuthorizationPolicy.kt +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/security/AuthorizationPolicy.kt @@ -14,6 +14,9 @@ enum class ToolPermission { APPROVAL_TASK_READ_ASSIGNED, APPROVAL_TASK_DECIDE_ASSIGNED, AUDIT_READ_TENANT_REDACTED, + ORGANIZATION_MANAGE_TENANT, + WORKFLOW_READ_TENANT, + OPERATIONS_METRICS_READ_TENANT, } enum class DataScope { OWN, ASSIGNED, TENANT } @@ -42,14 +45,19 @@ object AuthorizationPolicy { val permissions = buildSet { if ("employee" in user.roles) addAll(employeePermissions) if (user.roles.any(approverRoles::contains)) addAll(approvalPermissions) - if ("oa_admin" in user.roles) add(ToolPermission.AUDIT_READ_TENANT_REDACTED) + if ("oa_admin" in user.roles) addAll(setOf( + ToolPermission.AUDIT_READ_TENANT_REDACTED, + ToolPermission.ORGANIZATION_MANAGE_TENANT, + ToolPermission.WORKFLOW_READ_TENANT, + ToolPermission.OPERATIONS_METRICS_READ_TENANT, + )) } return UserCapabilities( permissions, buildSet { if (permissions.any { it.name.endsWith("_OWN") }) add(DataScope.OWN) if (permissions.any { it.name.endsWith("_ASSIGNED") }) add(DataScope.ASSIGNED) - if (ToolPermission.AUDIT_READ_TENANT_REDACTED in permissions) add(DataScope.TENANT) + if (permissions.any { it.name.endsWith("_TENANT") }) add(DataScope.TENANT) }, ) } diff --git a/backend/boot/src/test/kotlin/com/all8ai/aioa/shared/security/AuthorizationPolicyTest.kt b/backend/boot/src/test/kotlin/com/all8ai/aioa/shared/security/AuthorizationPolicyTest.kt index 46018c9..28b6a11 100644 --- a/backend/boot/src/test/kotlin/com/all8ai/aioa/shared/security/AuthorizationPolicyTest.kt +++ b/backend/boot/src/test/kotlin/com/all8ai/aioa/shared/security/AuthorizationPolicyTest.kt @@ -33,6 +33,19 @@ class AuthorizationPolicyTest { .isInstanceOfSatisfying(ApiException::class.java) { assertThat(it.code).isEqualTo("PERMISSION_DENIED") } } + @Test + fun `only oa administrator receives tenant management tools`() { + val admin = AuthorizationPolicy.capabilities(user(setOf("employee", "oa_admin"))) + val manager = AuthorizationPolicy.capabilities(user(setOf("employee", "department_manager"))) + assertThat(admin.permissions).contains( + ToolPermission.ORGANIZATION_MANAGE_TENANT, + ToolPermission.WORKFLOW_READ_TENANT, + ToolPermission.OPERATIONS_METRICS_READ_TENANT, + ) + assertThat(admin.dataScopes).contains(DataScope.TENANT) + assertThat(manager.permissions).doesNotContain(ToolPermission.ORGANIZATION_MANAGE_TENANT) + } + private fun user(roles: Set) = CurrentUser( UUID.randomUUID(), UUID.randomUUID(), "user", "用户", null, null, null, roles, ) diff --git a/contracts/openapi/aioa-v1.yaml b/contracts/openapi/aioa-v1.yaml index 8ac2812..7508df6 100644 --- a/contracts/openapi/aioa-v1.yaml +++ b/contracts/openapi/aioa-v1.yaml @@ -1,12 +1,29 @@ openapi: 3.1.0 info: title: AIOA API - version: 0.13.0 + version: 0.14.0 servers: - url: /api/v1 security: - bearerAuth: [] paths: + /admin/organization/departments: + get: { operationId: listAdminDepartments, summary: OA 管理员查询租户部门, responses: { "200": { description: 部门列表 } } } + post: { operationId: createAdminDepartment, summary: OA 管理员创建部门, responses: { "200": { description: 已创建部门 } } } + /admin/organization/roles: + get: { operationId: listAdminRoles, summary: OA 管理员查询租户角色, responses: { "200": { description: 角色列表 } } } + /admin/organization/users: + get: { operationId: listAdminUsers, summary: OA 管理员查询租户用户与任职角色, responses: { "200": { description: 用户列表 } } } + /admin/organization/users/{id}/roles: + put: { operationId: replaceAdminUserRoles, summary: OA 管理员替换用户角色, parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }], responses: { "200": { description: 角色已更新 } } } + /admin/organization/users/{id}/assignment: + put: { operationId: replaceAdminUserAssignment, summary: OA 管理员替换用户主任职, parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }], responses: { "200": { description: 任职已更新 } } } + /admin/workflows/definitions: + get: { operationId: listWorkflowDefinitions, summary: 查询流程定义及版本, responses: { "200": { description: 流程定义列表 } } } + /admin/workflows/instances: + get: { operationId: listTenantWorkflowInstances, summary: 查询当前租户流程实例, responses: { "200": { description: 流程实例列表 } } } + /admin/metrics: + get: { operationId: getTenantOperationsMetrics, summary: 查询业务流程推送运行指标, responses: { "200": { description: 聚合运行指标 } } } /admin/audit-events: get: operationId: listRedactedTenantAuditEvents @@ -643,7 +660,7 @@ components: uniqueItems: true items: type: string - enum: [LEAVE_REQUEST_READ_OWN, LEAVE_REQUEST_WRITE_OWN, LEAVE_ATTACHMENT_MANAGE_OWN, NOTIFICATION_READ_OWN, AI_LEAVE_DRAFT_SUGGEST, AI_LEAVE_PROGRESS_READ_OWN, APPROVAL_TASK_READ_ASSIGNED, APPROVAL_TASK_DECIDE_ASSIGNED, AUDIT_READ_TENANT_REDACTED] + enum: [LEAVE_REQUEST_READ_OWN, LEAVE_REQUEST_WRITE_OWN, LEAVE_ATTACHMENT_MANAGE_OWN, NOTIFICATION_READ_OWN, AI_LEAVE_DRAFT_SUGGEST, AI_LEAVE_PROGRESS_READ_OWN, APPROVAL_TASK_READ_ASSIGNED, APPROVAL_TASK_DECIDE_ASSIGNED, AUDIT_READ_TENANT_REDACTED, ORGANIZATION_MANAGE_TENANT, WORKFLOW_READ_TENANT, OPERATIONS_METRICS_READ_TENANT] dataScopes: type: array uniqueItems: true diff --git a/docs/engineering/roadmap.md b/docs/engineering/roadmap.md index bdf20da..2cdcdfb 100644 --- a/docs/engineering/roadmap.md +++ b/docs/engineering/roadmap.md @@ -51,10 +51,10 @@ ## M4:管理与运营闭环 - [x] OA 管理员按租户脱敏查询审计记录 -- [ ] OA 管理员组织与角色维护 API -- [ ] 流程定义、版本和运行实例只读管理 -- [ ] Flutter 管理工具入口与权限驱动展示 -- [ ] 业务与推送运行指标仪表板 +- [x] OA 管理员组织与角色维护 API +- [x] 流程定义、版本和运行实例只读管理 +- [x] Flutter 管理工具入口与权限驱动展示 +- [x] 业务与推送运行指标仪表板 ## Definition of Done diff --git a/mobile/lib/app/router.dart b/mobile/lib/app/router.dart index 458c162..8ebde67 100644 --- a/mobile/lib/app/router.dart +++ b/mobile/lib/app/router.dart @@ -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) => diff --git a/mobile/lib/features/admin/application/admin_controller.dart b/mobile/lib/features/admin/application/admin_controller.dart new file mode 100644 index 0000000..78c2de0 --- /dev/null +++ b/mobile/lib/features/admin/application/admin_controller.dart @@ -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.new, + ); + +class AdminController extends AsyncNotifier> { + @override + Future> build() => + ref.read(adminRepositoryProvider).dashboard(); + Future createDepartment(String code, String name) async { + try { + await ref.read(adminRepositoryProvider).createDepartment(code, name); + ref.invalidateSelf(); + return null; + } catch (e) { + return '$e'; + } + } + + Future replaceRoles(String id, Set roles) async { + try { + await ref.read(adminRepositoryProvider).replaceRoles(id, roles); + ref.invalidateSelf(); + return null; + } catch (e) { + return '$e'; + } + } +} diff --git a/mobile/lib/features/admin/data/admin_repository.dart b/mobile/lib/features/admin/data/admin_repository.dart new file mode 100644 index 0000000..2f5a5d9 --- /dev/null +++ b/mobile/lib/features/admin/data/admin_repository.dart @@ -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> permissions() async => + ((await _get('/me'))['permissions'] as List).cast().toSet(); + Future> 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 createDepartment(String code, String name) => _write( + 'POST', + '/admin/organization/departments', + {'code': code, 'name': name}, + ); + Future replaceRoles(String userId, Set roles) => _write( + 'PUT', + '/admin/organization/users/$userId/roles', + {'roles': roles.toList()}, + ); + Future> _get(String path) async { + final r = await client.get(Uri.parse('$baseUrl$path')); + _ok(r); + return Map.from(jsonDecode(r.body) as Map); + } + + Future>> _list(String path) async { + final r = await client.get(Uri.parse('$baseUrl$path')); + _ok(r); + return (jsonDecode(r.body) as List) + .map((e) => Map.from(e as Map)) + .toList(); + } + + Future _write( + String method, + String path, + Map 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})'); + } + } +} diff --git a/mobile/lib/features/admin/presentation/admin_page.dart b/mobile/lib/features/admin/presentation/admin_page.dart new file mode 100644 index 0000000..6a29f09 --- /dev/null +++ b/mobile/lib/features/admin/presentation/admin_page.dart @@ -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), + _Organization(value), + _Workflow(value), + _Audit(value['audits']! as List>), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _Metrics extends StatelessWidget { + const _Metrics(this.data); + final Map 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 data; + @override + Widget build(BuildContext context, WidgetRef ref) { + final users = data['users']! as List>; + final roles = data['roles']! as List>; + 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 _add(BuildContext context, WidgetRef ref) async { + final code = TextEditingController(); + final name = TextEditingController(); + final confirmed = await showDialog( + 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 _roles( + BuildContext context, + WidgetRef ref, + Map user, + List> available, + ) async { + final selected = (user['roles'] as List).cast().toSet(); + final result = await showDialog>( + 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 data; + @override + Widget build(BuildContext context) => ListView( + padding: const EdgeInsets.all(12), + children: [ + const Text('流程定义'), + ...(data['definitions']! as List>).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( + (item) => ListTile( + title: Text('${item['businessKey'] ?? item['id']}'), + subtitle: Text(item['active'] == true ? '运行中' : '已结束'), + ), + ), + ], + ); +} + +class _Audit extends StatelessWidget { + const _Audit(this.items); + final List> 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']}'), + ), + ); + }, + ); +} diff --git a/mobile/lib/features/profile/presentation/profile_page.dart b/mobile/lib/features/profile/presentation/profile_page.dart index 950d102..a39673d 100644 --- a/mobile/lib/features/profile/presentation/profile_page.dart +++ b/mobile/lib/features/profile/presentation/profile_page.dart @@ -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 {}; 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( diff --git a/mobile/test/features/admin/admin_repository_test.dart b/mobile/test/features/admin/admin_repository_test.dart new file mode 100644 index 0000000..c3e6efe --- /dev/null +++ b/mobile/test/features/admin/admin_repository_test.dart @@ -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'), + ); + }); +}