From f2fa10c17883fec2437f33e4d959294768658834 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Fri, 10 Jul 2026 22:12:59 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E6=9D=83=E9=99=90=E7=AE=A1?= =?UTF-8?q?=E7=90=86)=EF=BC=9A=E7=BB=9F=E4=B8=80=20PM=20=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=AF=BC=E8=88=AA=E4=B8=8E=E6=9D=83=E9=99=90=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/permission_monitoring.py | 407 +++++- backend/app/api/v1/users.py | 11 +- backend/app/crud/user.py | 36 +- backend/tests/test_api_permissions.py | 10 + .../tests/test_permission_monitoring_api.py | 92 +- backend/tests/test_user_login_sessions.py | 42 + frontend/src/api/projectPermissions.ts | 1 + .../components/ApiEndpointPermissions.test.ts | 10 +- .../src/components/ApiEndpointPermissions.vue | 142 +- frontend/src/components/DesktopLayout.vue | 12 +- .../src/components/Layout.desktop.test.ts | 1 + .../components/PermissionAccessLogs.test.ts | 17 +- .../src/components/PermissionAccessLogs.vue | 98 +- .../components/PermissionMonitoring.test.ts | 21 +- .../src/components/PermissionMonitoring.vue | 138 +- .../PermissionTemplateSelector.test.ts | 21 + .../components/PermissionTemplateSelector.vue | 148 ++- .../components/PermissionTrendCharts.test.ts | 57 +- .../src/components/PermissionTrendCharts.vue | 1088 +++++++++++----- .../src/components/SecurityCenter.test.ts | 10 + frontend/src/components/SecurityCenter.vue | 27 +- frontend/src/components/WebLayout.vue | 19 +- .../src/components/layout/navigation.test.ts | 28 + frontend/src/components/layout/navigation.ts | 19 +- frontend/src/locales/zh-CN.ts | 2 +- frontend/src/router.test.ts | 2 + frontend/src/router/index.ts | 12 +- frontend/src/styles/main.css | 19 +- frontend/src/types/api.ts | 62 + frontend/src/types/desktopCommands.test.ts | 2 +- .../src/views/DesktopProjectEntry.test.ts | 12 +- frontend/src/views/DesktopProjectEntry.vue | 39 +- frontend/src/views/WebWorkbenchEntry.test.ts | 6 + frontend/src/views/WebWorkbenchEntry.vue | 37 +- frontend/src/views/admin/AuditLogs.test.ts | 61 +- frontend/src/views/admin/AuditLogs.vue | 395 +++--- frontend/src/views/admin/EmailSettings.vue | 589 ++++++--- .../views/admin/PermissionManagement.test.ts | 58 +- .../src/views/admin/PermissionManagement.vue | 1153 +++++++++++++---- frontend/src/views/admin/ProjectForm.vue | 65 +- frontend/src/views/admin/Projects.test.ts | 28 +- frontend/src/views/admin/Projects.vue | 138 +- .../SystemMonitoringPage.desktop.test.ts | 6 + .../src/views/admin/SystemMonitoringPage.vue | 16 +- frontend/src/views/admin/UserForm.vue | 74 -- frontend/src/views/admin/Users.vue | 372 ++++-- .../src/views/admin/UsersLoginStatus.test.ts | 23 + 47 files changed, 4071 insertions(+), 1555 deletions(-) create mode 100644 frontend/src/components/PermissionTemplateSelector.test.ts create mode 100644 frontend/src/components/layout/navigation.test.ts diff --git a/backend/app/api/v1/permission_monitoring.py b/backend/app/api/v1/permission_monitoring.py index 8140dce0..102dced3 100644 --- a/backend/app/api/v1/permission_monitoring.py +++ b/backend/app/api/v1/permission_monitoring.py @@ -602,6 +602,7 @@ async def get_access_logs( end_time: Optional[datetime] = Query(None), client_ip: Optional[str] = Query(None), client_type: Optional[str] = Query(None), + event_type: str = Query("all", pattern="^(all|permission)$"), keyword: Optional[str] = Query(None), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), @@ -660,7 +661,14 @@ async def get_access_logs( keyword_conditions.append(PermissionAccessLog.ip_address.in_(location_ips)) conditions.append(or_(*keyword_conditions)) - include_security_logs = scope.is_admin and study_id is None and role is None and endpoint_key is None and allowed is not True + include_security_logs = ( + event_type != "permission" + and scope.is_admin + and study_id is None + and role is None + and endpoint_key is None + and allowed is not True + ) event_conditions = list(conditions) if include_security_logs: matching_security_event = select(SecurityAccessLog.id).where( @@ -1118,81 +1126,352 @@ async def get_trends( _=Depends(get_current_user), period: str = Query("24h", pattern="^(24h|7d|30d)$"), ) -> dict: - """获取趋势数据(从快照表或实时聚合)""" + """获取权限检查趋势、周期摘要和归因数据。""" scope = await resolve_monitoring_scope(db, _) if not scope.is_admin and not scope.study_ids: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") now = datetime.now(timezone.utc) - period_map = {"24h": timedelta(hours=24), "7d": timedelta(days=7), "30d": timedelta(days=30)} - start_time = now - period_map[period] + bucket_delta, bucket_count = _trend_bucket_config(period) + current_bucket = _trend_bucket_time(now, period) + start_time = current_bucket - bucket_delta * (bucket_count - 1) + current_duration = now - start_time + previous_end = start_time + previous_start = previous_end - current_duration - # 先尝试从快照表获取 - snapshot_query = ( - select(PermissionMetricSnapshot) - .where(PermissionMetricSnapshot.bucket_time >= start_time) - .order_by(PermissionMetricSnapshot.bucket_time) + log_buckets = await _load_trend_log_buckets(db, scope, start_time, now, period) + cache_buckets, current_cache, previous_cache = await _load_trend_cache_buckets( + db, + scope, + previous_start, + previous_end, + start_time, + now, + period, ) - result = await db.execute(snapshot_query) - snapshots = result.scalars().all() + summary = await _load_trend_summary(db, scope, start_time, now) + previous_summary = await _load_trend_summary(db, scope, previous_start, previous_end) + summary.update(current_cache) + previous_summary.update(previous_cache) + previous_summary.pop("observed_end_at", None) - if snapshots and scope.is_admin: - return { - "period": period, - "data_points": [ - { - "bucket_time": s.bucket_time.isoformat(), - "total_checks": s.total_checks, - "allowed_checks": s.allowed_checks, - "denied_checks": s.denied_checks, - "avg_elapsed_ms": round(s.avg_elapsed_ms, 2), - "max_elapsed_ms": round(s.max_elapsed_ms, 2), - "cache_hits": s.cache_hits, - "cache_misses": s.cache_misses, - "cache_hit_rate": round( - s.cache_hits / (s.cache_hits + s.cache_misses) * 100, 1 - ) if (s.cache_hits + s.cache_misses) > 0 else 0, - "error_count": s.error_count, - } - for s in snapshots - ], - } - - # 如果没有快照数据,从原始日志实时聚合(适用于刚部署时)。 - # 这里使用 Python 分桶,避免 SQLite 测试库不支持 PostgreSQL date_trunc。 - trend_query = select( - PermissionAccessLog.created_at, - PermissionAccessLog.allowed, - PermissionAccessLog.elapsed_ms, - ).where(PermissionAccessLog.created_at >= start_time) - result = await db.execute( - _apply_monitoring_scope_to_log_query(trend_query, scope) - ) - buckets: dict[datetime, dict[str, float | int]] = defaultdict( - lambda: {"total": 0, "allowed": 0, "denied": 0, "elapsed_sum": 0.0, "max_ms": 0.0} - ) - for created_at, allowed, elapsed_ms in result.all(): - bucket = _trend_bucket_time(created_at, period) - buckets[bucket]["total"] += 1 - buckets[bucket]["allowed" if allowed else "denied"] += 1 - buckets[bucket]["elapsed_sum"] += float(elapsed_ms or 0) - buckets[bucket]["max_ms"] = max(float(buckets[bucket]["max_ms"]), float(elapsed_ms or 0)) + data_points = [] + for index in range(bucket_count): + bucket_time = start_time + bucket_delta * index + values = log_buckets.get(bucket_time, _empty_trend_bucket()) + cache_values = cache_buckets.get(bucket_time, {"cache_hits": 0, "cache_misses": 0}) + cache_total = int(cache_values["cache_hits"]) + int(cache_values["cache_misses"]) + total = int(values["total"]) + data_points.append( + { + "bucket_time": bucket_time.isoformat(), + "sample_state": "partial" if bucket_time == current_bucket else "complete", + "total_checks": total, + "allowed_checks": int(values["allowed"]), + "denied_checks": int(values["denied"]), + "avg_elapsed_ms": round(float(values["elapsed_sum"]) / total, 2) if total else 0, + "max_elapsed_ms": round(float(values["max_ms"]), 2), + "cache_hits": int(cache_values["cache_hits"]), + "cache_misses": int(cache_values["cache_misses"]), + "cache_hit_rate": round(int(cache_values["cache_hits"]) / cache_total * 100, 1) if cache_total else 0, + "cache_sample_available": cache_total > 0, + "error_count": 0, + } + ) return { "period": period, - "data_points": [ + "generated_at": now.isoformat(), + "range": { + "start_at": start_time.isoformat(), + "end_at": now.isoformat(), + "previous_start_at": previous_start.isoformat(), + "previous_end_at": previous_end.isoformat(), + "bucket_seconds": int(bucket_delta.total_seconds()), + "bucket_count": bucket_count, + "timezone": "UTC", + "observed_end_at": summary.pop("observed_end_at"), + }, + "summary": summary, + "previous_summary": previous_summary, + "attribution": await _load_trend_attribution(db, scope, start_time, now), + "data_points": data_points, + } + + +def _trend_bucket_config(period: str) -> tuple[timedelta, int]: + return { + "24h": (timedelta(hours=1), 24), + "7d": (timedelta(hours=6), 28), + "30d": (timedelta(days=1), 30), + }[period] + + +def _empty_trend_bucket() -> dict[str, float | int]: + return {"total": 0, "allowed": 0, "denied": 0, "elapsed_sum": 0.0, "max_ms": 0.0} + + +def _merge_trend_bucket(target: dict[str, float | int], *, total: int, allowed: int, denied: int, elapsed_sum: float, max_ms: float) -> None: + target["total"] = int(target["total"]) + total + target["allowed"] = int(target["allowed"]) + allowed + target["denied"] = int(target["denied"]) + denied + target["elapsed_sum"] = float(target["elapsed_sum"]) + elapsed_sum + target["max_ms"] = max(float(target["max_ms"]), max_ms) + + +async def _load_trend_log_buckets( + db: AsyncSession, + scope: MonitoringScope, + start_time: datetime, + end_time: datetime, + period: str, +) -> dict[datetime, dict[str, float | int]]: + """PostgreSQL 先按小时聚合,SQLite 测试库使用 Python 分桶。""" + buckets: dict[datetime, dict[str, float | int]] = defaultdict(_empty_trend_bucket) + dialect_name = db.get_bind().dialect.name + if dialect_name == "postgresql": + hour_bucket = func.date_trunc("hour", PermissionAccessLog.created_at).label("hour_bucket") + query = select( + hour_bucket, + func.count().label("total"), + func.count().filter(PermissionAccessLog.allowed.is_(True)).label("allowed"), + func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied"), + func.coalesce(func.sum(PermissionAccessLog.elapsed_ms), 0).label("elapsed_sum"), + func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_ms"), + ).where( + PermissionAccessLog.created_at >= start_time, + PermissionAccessLog.created_at <= end_time, + ).group_by(hour_bucket) + result = await db.execute(_apply_monitoring_scope_to_log_query(query, scope)) + for row in result.all(): + bucket_time = _trend_bucket_time(row.hour_bucket, period) + _merge_trend_bucket( + buckets[bucket_time], + total=int(row.total or 0), + allowed=int(row.allowed or 0), + denied=int(row.denied or 0), + elapsed_sum=float(row.elapsed_sum or 0), + max_ms=float(row.max_ms or 0), + ) + return buckets + + query = select( + PermissionAccessLog.created_at, + PermissionAccessLog.allowed, + PermissionAccessLog.elapsed_ms, + ).where( + PermissionAccessLog.created_at >= start_time, + PermissionAccessLog.created_at <= end_time, + ) + result = await db.execute(_apply_monitoring_scope_to_log_query(query, scope)) + for created_at, allowed, elapsed_ms in result.all(): + bucket_time = _trend_bucket_time(created_at, period) + _merge_trend_bucket( + buckets[bucket_time], + total=1, + allowed=1 if allowed else 0, + denied=0 if allowed else 1, + elapsed_sum=float(elapsed_ms or 0), + max_ms=float(elapsed_ms or 0), + ) + return buckets + + +def _percentile(values: list[float], percentile: float) -> float: + if not values: + return 0.0 + ordered = sorted(values) + position = (len(ordered) - 1) * percentile + lower = int(position) + upper = min(lower + 1, len(ordered) - 1) + fraction = position - lower + return ordered[lower] + (ordered[upper] - ordered[lower]) * fraction + + +async def _load_trend_summary( + db: AsyncSession, + scope: MonitoringScope, + start_time: datetime, + end_time: datetime, +) -> dict: + query = select( + func.count().label("total"), + func.count().filter(PermissionAccessLog.allowed.is_(True)).label("allowed"), + func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied"), + func.count().filter(PermissionAccessLog.elapsed_ms > 50).label("slow"), + func.count(func.distinct(PermissionAccessLog.study_id)).label("studies"), + func.count(func.distinct(PermissionAccessLog.user_id)).label("users"), + func.count(func.distinct(PermissionAccessLog.endpoint_key)).label("endpoints"), + func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"), + func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_ms"), + func.max(PermissionAccessLog.created_at).label("observed_end_at"), + ).where( + PermissionAccessLog.created_at >= start_time, + PermissionAccessLog.created_at <= end_time, + ) + row = (await db.execute(_apply_monitoring_scope_to_log_query(query, scope))).one() + if db.get_bind().dialect.name == "postgresql": + percentile_query = select( + func.percentile_cont(0.95).within_group(PermissionAccessLog.elapsed_ms) + ).where( + PermissionAccessLog.created_at >= start_time, + PermissionAccessLog.created_at <= end_time, + ) + percentile_result = await db.execute( + _apply_monitoring_scope_to_log_query(percentile_query, scope) + ) + p95_ms = float(percentile_result.scalar() or 0) + else: + elapsed_query = select(PermissionAccessLog.elapsed_ms).where( + PermissionAccessLog.created_at >= start_time, + PermissionAccessLog.created_at <= end_time, + ) + elapsed_result = await db.execute( + _apply_monitoring_scope_to_log_query(elapsed_query, scope) + ) + p95_ms = _percentile( + [float(value) for value in elapsed_result.scalars().all()], + 0.95, + ) + total = int(row.total or 0) + denied = int(row.denied or 0) + return { + "total_checks": total, + "allowed_checks": int(row.allowed or 0), + "denied_checks": denied, + "deny_rate": round(denied / total * 100, 2) if total else 0, + "avg_elapsed_ms": round(float(row.avg_ms or 0), 2), + "p95_elapsed_ms": round(p95_ms, 2), + "max_elapsed_ms": round(float(row.max_ms or 0), 2), + "slow_check_count": int(row.slow or 0), + "active_study_count": int(row.studies or 0), + "active_user_count": int(row.users or 0), + "active_endpoint_count": int(row.endpoints or 0), + "cache_hits": 0, + "cache_misses": 0, + "cache_hit_rate": 0, + "cache_sample_available": False, + "observed_end_at": row.observed_end_at.isoformat() if row.observed_end_at else None, + } + + +async def _load_trend_cache_buckets( + db: AsyncSession, + scope: MonitoringScope, + previous_start: datetime, + previous_end: datetime, + current_start: datetime, + current_end: datetime, + period: str, +) -> tuple[dict[datetime, dict[str, int]], dict, dict]: + empty_summary = {"cache_hits": 0, "cache_misses": 0, "cache_hit_rate": 0, "cache_sample_available": False} + if not scope.is_admin: + return {}, dict(empty_summary), dict(empty_summary) + result = await db.execute( + select(PermissionMetricSnapshot).where( + PermissionMetricSnapshot.bucket_time >= previous_start, + PermissionMetricSnapshot.bucket_time <= current_end, + ) + ) + buckets: dict[datetime, dict[str, int]] = defaultdict(lambda: {"cache_hits": 0, "cache_misses": 0}) + current_counts = {"cache_hits": 0, "cache_misses": 0} + previous_counts = {"cache_hits": 0, "cache_misses": 0} + for snapshot in result.scalars().all(): + snapshot_time = snapshot.bucket_time + if snapshot_time.tzinfo is None: + snapshot_time = snapshot_time.replace(tzinfo=timezone.utc) + if snapshot_time >= current_start: + current_counts["cache_hits"] += int(snapshot.cache_hits or 0) + current_counts["cache_misses"] += int(snapshot.cache_misses or 0) + bucket = buckets[_trend_bucket_time(snapshot_time, period)] + bucket["cache_hits"] += int(snapshot.cache_hits or 0) + bucket["cache_misses"] += int(snapshot.cache_misses or 0) + elif snapshot_time < previous_end: + previous_counts["cache_hits"] += int(snapshot.cache_hits or 0) + previous_counts["cache_misses"] += int(snapshot.cache_misses or 0) + + def serialize(counts: dict[str, int]) -> dict: + total = counts["cache_hits"] + counts["cache_misses"] + return { + **counts, + "cache_hit_rate": round(counts["cache_hits"] / total * 100, 2) if total else 0, + "cache_sample_available": total > 0, + } + + return dict(buckets), serialize(current_counts), serialize(previous_counts) + + +async def _load_trend_attribution( + db: AsyncSession, + scope: MonitoringScope, + start_time: datetime, + end_time: datetime, +) -> dict: + base_conditions = ( + PermissionAccessLog.created_at >= start_time, + PermissionAccessLog.created_at <= end_time, + ) + denied_query = select( + PermissionAccessLog.endpoint_key, + PermissionAccessLog.role, + func.count().label("denied_count"), + ).where(*base_conditions, PermissionAccessLog.allowed.is_(False)).group_by( + PermissionAccessLog.endpoint_key, + PermissionAccessLog.role, + ).order_by(desc("denied_count")).limit(5) + denied_rows = (await db.execute(_apply_monitoring_scope_to_log_query(denied_query, scope))).all() + + slow_query = select( + PermissionAccessLog.endpoint_key, + func.count().label("sample_count"), + func.count().filter(PermissionAccessLog.elapsed_ms > 50).label("slow_count"), + func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"), + func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_ms"), + ).where(*base_conditions).group_by(PermissionAccessLog.endpoint_key).order_by( + desc("slow_count"), desc("avg_ms"), desc("sample_count") + ).limit(5) + slow_rows = (await db.execute(_apply_monitoring_scope_to_log_query(slow_query, scope))).all() + + client_type = func.coalesce(PermissionAccessLog.client_type, "unknown") + client_version = func.coalesce(PermissionAccessLog.client_version, "-") + client_platform = func.coalesce(PermissionAccessLog.client_platform, "-") + client_query = select( + client_type.label("client_type"), + client_version.label("client_version"), + client_platform.label("client_platform"), + func.count().label("total_count"), + func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied_count"), + func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"), + ).where(*base_conditions).group_by(client_type, client_version, client_platform).order_by( + desc("total_count") + ).limit(5) + client_rows = (await db.execute(_apply_monitoring_scope_to_log_query(client_query, scope))).all() + + return { + "top_denied": [ + {"endpoint_key": row.endpoint_key, "role": row.role, "denied_count": int(row.denied_count)} + for row in denied_rows + ], + "top_slow": [ { - "bucket_time": bucket.isoformat(), - "total_checks": values["total"], - "allowed_checks": values["allowed"], - "denied_checks": values["denied"], - "avg_elapsed_ms": round(float(values["elapsed_sum"]) / int(values["total"]), 2), - "max_elapsed_ms": round(float(values["max_ms"]), 2), - "cache_hits": 0, - "cache_misses": 0, - "cache_hit_rate": 0, - "error_count": 0, + "endpoint_key": row.endpoint_key, + "sample_count": int(row.sample_count), + "slow_count": int(row.slow_count), + "avg_elapsed_ms": round(float(row.avg_ms), 2), + "max_elapsed_ms": round(float(row.max_ms), 2), } - for bucket, values in sorted(buckets.items()) + for row in slow_rows + ], + "client_breakdown": [ + { + "client_type": row.client_type, + "client_version": row.client_version, + "client_platform": row.client_platform, + "total_count": int(row.total_count), + "denied_count": int(row.denied_count), + "deny_rate": round(int(row.denied_count) / int(row.total_count) * 100, 2) if row.total_count else 0, + "avg_elapsed_ms": round(float(row.avg_ms), 2), + } + for row in client_rows ], } diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index f9f80787..bbeee149 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -9,7 +9,7 @@ from app.schemas.common import PaginatedResponse from app.crud import user as user_crud from app.crud import member as member_crud from app.utils.pagination import paginate -from app.schemas.user import UserCreate, UserLoginActivityRead, UserRead, UserStatus, UserUpdate +from app.schemas.user import LoginStatus, UserCreate, UserLoginActivityRead, UserRead, UserStatus, UserUpdate from app.services.user_login_sessions import get_login_summaries, list_login_activities router = APIRouter() @@ -21,11 +21,16 @@ async def list_users( limit: int = 100, keyword: str | None = Query(default=None), user_status: UserStatus | None = Query(default=None, alias="status"), + login_status: LoginStatus | None = Query(default=None), db: AsyncSession = Depends(get_db_session), current_user=Depends(require_roles(["ADMIN"])), ) -> PaginatedResponse[UserRead]: - users = await user_crud.list_users(db, skip=skip, limit=limit, keyword=keyword, status=user_status) - total_users = await user_crud.count_users(db, keyword=keyword, status=user_status) + users = await user_crud.list_users( + db, skip=skip, limit=limit, keyword=keyword, status=user_status, login_status=login_status + ) + total_users = await user_crud.count_users( + db, keyword=keyword, status=user_status, login_status=login_status + ) summaries = await get_login_summaries(db, [user.id for user in users]) items = [] for user in users: diff --git a/backend/app/crud/user.py b/backend/app/crud/user.py index e792a6b5..c7bcae2d 100644 --- a/backend/app/crud/user.py +++ b/backend/app/crud/user.py @@ -1,7 +1,8 @@ from __future__ import annotations import uuid -from typing import Sequence +from datetime import datetime, timedelta, timezone +from typing import Literal, Sequence from sqlalchemy import delete, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -11,12 +12,14 @@ from app.core.config import ( PROTECTED_ADMIN_DEFAULT_PASSWORD, PROTECTED_ADMIN_EMAIL, PROTECTED_ADMIN_FULL_NAME, + settings, ) from app.core.security import hash_password from app.models.audit_log import AuditLog from app.models.permission_access_log import PermissionAccessLog from app.models.study_member import StudyMember from app.models.user import User, UserStatus +from app.models.user_login_session import UserLoginSession from app.schemas.user import UserCreate, UserRegisterRequest, UserUpdate @@ -81,7 +84,13 @@ async def update_user(db: AsyncSession, user: User, user_in: UserUpdate) -> User return user -def _apply_user_filters(query, *, keyword: str | None = None, status: UserStatus | None = None): +def _apply_user_filters( + query, + *, + keyword: str | None = None, + status: UserStatus | None = None, + login_status: Literal["ONLINE", "OFFLINE"] | None = None, +): if keyword: pattern = f"%{keyword.strip()}%" query = query.where( @@ -93,6 +102,18 @@ def _apply_user_filters(query, *, keyword: str | None = None, status: UserStatus ) if status is not None: query = query.where(User.status == status) + if login_status is not None: + cutoff = datetime.now(timezone.utc) - timedelta(seconds=settings.USER_SESSION_ONLINE_SECONDS) + has_online_session = ( + select(UserLoginSession.id) + .where( + UserLoginSession.user_id == User.id, + UserLoginSession.ended_at.is_(None), + UserLoginSession.last_seen_at >= cutoff, + ) + .exists() + ) + query = query.where(has_online_session if login_status == "ONLINE" else ~has_online_session) return query @@ -103,8 +124,9 @@ async def list_users( *, keyword: str | None = None, status: UserStatus | None = None, + login_status: Literal["ONLINE", "OFFLINE"] | None = None, ) -> Sequence[User]: - query = _apply_user_filters(select(User), keyword=keyword, status=status) + query = _apply_user_filters(select(User), keyword=keyword, status=status, login_status=login_status) result = await db.execute(query.order_by(User.created_at.desc()).offset(skip).limit(limit)) return result.scalars().all() @@ -114,8 +136,14 @@ async def count_users( *, keyword: str | None = None, status: UserStatus | None = None, + login_status: Literal["ONLINE", "OFFLINE"] | None = None, ) -> int: - query = _apply_user_filters(select(func.count()).select_from(User), keyword=keyword, status=status) + query = _apply_user_filters( + select(func.count()).select_from(User), + keyword=keyword, + status=status, + login_status=login_status, + ) result = await db.execute(query) return int(result.scalar_one() or 0) diff --git a/backend/tests/test_api_permissions.py b/backend/tests/test_api_permissions.py index 0ded8ed5..d4c42ae9 100644 --- a/backend/tests/test_api_permissions.py +++ b/backend/tests/test_api_permissions.py @@ -565,6 +565,16 @@ def test_project_permission_config_is_system_level_permission(): assert "PM" in SYSTEM_PERMISSIONS["system:permissions:project_config"]["roles"] +def test_pm_system_management_navigation_matches_backend_permission_contract(): + """PM 系统管理导航中的审计日志和权限管理应与后端角色权限保持一致。""" + assert "PM" in API_ENDPOINT_PERMISSIONS["audit_logs:read"]["default_roles"] + assert "PM" in SYSTEM_PERMISSIONS["system:permissions:read"]["roles"] + assert "PM" in SYSTEM_PERMISSIONS["system:permissions:project_config"]["roles"] + + audit_route = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "audit_logs.py" + assert 'require_api_permission("audit_logs:read")' in audit_route.read_text() + + def test_project_api_permission_routes_use_system_project_config_permission(): """项目权限矩阵 API 应明确依赖 system:permissions:project_config。""" route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "api_permissions.py" diff --git a/backend/tests/test_permission_monitoring_api.py b/backend/tests/test_permission_monitoring_api.py index 4481085c..60f7033b 100644 --- a/backend/tests/test_permission_monitoring_api.py +++ b/backend/tests/test_permission_monitoring_api.py @@ -69,7 +69,19 @@ async def test_system_permission_monitoring_definitions_are_admin_only(): assert all("PM" not in item["roles"] for item in monitoring_items) -async def _seed_permission_log(db_session, study_id: uuid.UUID, user_id: uuid.UUID, *, allowed: bool, elapsed_ms: float) -> None: +async def _seed_permission_log( + db_session, + study_id: uuid.UUID, + user_id: uuid.UUID, + *, + allowed: bool, + elapsed_ms: float, + endpoint_key: str = "admin.permissions.read", + role: str = "PM", + client_type: str | None = None, + client_version: str | None = None, + client_platform: str | None = None, +) -> None: study_exists = ( await db_session.execute(text("SELECT id FROM studies WHERE id = :id"), {"id": str(study_id)}) ).scalar_one_or_none() @@ -119,21 +131,24 @@ async def _seed_permission_log(db_session, study_id: uuid.UUID, user_id: uuid.UU """ INSERT INTO permission_access_logs (id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, - request_snapshot, created_at) + client_type, client_version, client_platform, request_snapshot, created_at) VALUES (:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, - :request_snapshot, CURRENT_TIMESTAMP) + :client_type, :client_version, :client_platform, :request_snapshot, CURRENT_TIMESTAMP) """ ), { "id": str(uuid.uuid4()), "study_id": str(study_id), "user_id": str(user_id), - "endpoint_key": "admin.permissions.read", - "role": "PM", + "endpoint_key": endpoint_key, + "role": role, "allowed": allowed, "elapsed_ms": elapsed_ms, "ip_address": "127.0.0.1", + "client_type": client_type, + "client_version": client_version, + "client_platform": client_platform, "request_snapshot": None, }, ) @@ -159,6 +174,53 @@ async def test_get_permission_metrics(db_session): assert data["check_metrics"]["denied_checks"] == 1 +@pytest.mark.asyncio +async def test_get_trends_returns_dense_summary_comparison_and_attribution(db_session): + baseline = await permission_monitoring.get_trends(db=db_session, _=AdminUserStub(), period="24h") + study_id = uuid.uuid4() + user_id = uuid.uuid4() + await _seed_permission_log( + db_session, + study_id, + user_id, + allowed=True, + elapsed_ms=5, + endpoint_key="subjects.read", + client_type="web", + client_version="0.1.0", + client_platform="macos", + ) + await _seed_permission_log( + db_session, + study_id, + user_id, + allowed=False, + elapsed_ms=80, + endpoint_key="subjects.export", + client_type="web", + client_version="0.1.0", + client_platform="macos", + ) + + data = await permission_monitoring.get_trends(db=db_session, _=AdminUserStub(), period="24h") + + assert data["period"] == "24h" + assert data["range"]["bucket_seconds"] == 3600 + assert len(data["data_points"]) == 24 + assert data["data_points"][-1]["sample_state"] == "partial" + assert data["summary"]["total_checks"] == baseline["summary"]["total_checks"] + 2 + assert data["summary"]["denied_checks"] == baseline["summary"]["denied_checks"] + 1 + assert data["summary"]["slow_check_count"] == baseline["summary"]["slow_check_count"] + 1 + assert data["summary"]["active_study_count"] == baseline["summary"]["active_study_count"] + 1 + assert data["summary"]["active_user_count"] == baseline["summary"]["active_user_count"] + 1 + assert data["summary"]["active_endpoint_count"] >= 2 + assert data["summary"]["max_elapsed_ms"] >= data["summary"]["p95_elapsed_ms"] + assert data["previous_summary"]["total_checks"] == 0 + assert any(item["endpoint_key"] == "subjects.export" for item in data["attribution"]["top_denied"]) + assert data["attribution"]["top_slow"][0]["endpoint_key"] == "subjects.export" + assert any(item["client_type"] == "web" for item in data["attribution"]["client_breakdown"]) + + @pytest.mark.asyncio async def test_get_cache_statistics(db_session): """测试获取缓存统计""" @@ -1206,6 +1268,26 @@ async def test_access_logs_include_security_events_for_admin(db_session, monkeyp assert items_by_type["permission"]["request_snapshot"]["path"] == "/api/v1/projects/overview" assert items_by_type["security"]["request_snapshot"]["headers"]["authorization"] == "[redacted]" + permission_only = await permission_monitoring.get_access_logs( + db=db_session, + _=AdminUserStub(), + study_id=None, + user_id=None, + endpoint_key=None, + role=None, + allowed=None, + start_time=None, + end_time=None, + client_ip=None, + client_type=None, + event_type="permission", + keyword=None, + page=1, + page_size=50, + ) + assert permission_only["total"] == 1 + assert permission_only["items"][0]["event_type"] == "permission" + @pytest.mark.asyncio async def test_access_logs_ip_ranking_aggregates_by_ip_total(db_session): diff --git a/backend/tests/test_user_login_sessions.py b/backend/tests/test_user_login_sessions.py index 9d740fe9..343ace6e 100644 --- a/backend/tests/test_user_login_sessions.py +++ b/backend/tests/test_user_login_sessions.py @@ -4,6 +4,7 @@ import uuid import pytest from app.core.config import settings +from app.crud import user as user_crud from app.models.user import User, UserStatus from app.models.user_login_session import UserLoginSession from app.services.user_login_sessions import get_login_summaries, session_id_from_payload @@ -59,3 +60,44 @@ async def test_login_summary_marks_recent_unended_sessions_online(db_session, mo assert summaries[user.id].status == "ONLINE" assert summaries[user.id].active_session_count == 1 assert summaries[user.id].client_type == "desktop" + + +@pytest.mark.asyncio +async def test_user_list_filters_online_and_offline_accounts(db_session, monkeypatch): + now = datetime.now(timezone.utc) + monkeypatch.setattr(settings, "USER_SESSION_ONLINE_SECONDS", 300) + online_user = User( + id=uuid.uuid4(), + email="online-filter@example.com", + password_hash="hash", + full_name="Online Filter", + clinical_department="IT", + status=UserStatus.ACTIVE, + ) + offline_user = User( + id=uuid.uuid4(), + email="offline-filter@example.com", + password_hash="hash", + full_name="Offline Filter", + clinical_department="IT", + status=UserStatus.ACTIVE, + ) + db_session.add_all([online_user, offline_user]) + db_session.add( + UserLoginSession( + id=uuid.uuid4(), + user_id=online_user.id, + client_type="web", + login_at=now - timedelta(minutes=1), + last_seen_at=now - timedelta(seconds=10), + ) + ) + await db_session.commit() + + online = await user_crud.list_users(db_session, login_status="ONLINE") + offline = await user_crud.list_users(db_session, login_status="OFFLINE") + + assert online_user.id in {user.id for user in online} + assert offline_user.id not in {user.id for user in online} + assert offline_user.id in {user.id for user in offline} + assert await user_crud.count_users(db_session, login_status="ONLINE") == len(online) diff --git a/frontend/src/api/projectPermissions.ts b/frontend/src/api/projectPermissions.ts index 67ece223..b2949f70 100644 --- a/frontend/src/api/projectPermissions.ts +++ b/frontend/src/api/projectPermissions.ts @@ -80,6 +80,7 @@ export const fetchAccessLogs = (params: { end_time?: string; client_ip?: string; client_type?: string; + event_type?: "all" | "permission"; keyword?: string; page?: number; page_size?: number; diff --git a/frontend/src/components/ApiEndpointPermissions.test.ts b/frontend/src/components/ApiEndpointPermissions.test.ts index 44842edc..64161bf9 100644 --- a/frontend/src/components/ApiEndpointPermissions.test.ts +++ b/frontend/src/components/ApiEndpointPermissions.test.ts @@ -74,11 +74,15 @@ describe("ApiEndpointPermissions.vue", () => { it("keeps matrix read-only and renders authorization states", async () => { const source = readFileSync(resolve(__dirname, "./ApiEndpointPermissions.vue"), "utf8"); - expect(source).not.toContain("只读查看各角色权限覆盖"); + expect(source).toContain("有效权限矩阵"); + expect(source).toContain(">只读"); expect(source).toContain("permission-state--allowed"); expect(source).toContain("permission-state--disabled"); - expect(source).toContain("CircleCloseFilled"); - expect(source).not.toContain('? "✓" : "—"'); + expect(source).not.toContain("CircleCloseFilled"); + expect(source).toContain(""); + expect(source).toContain('filterAuthorization.value === "different"'); + expect(source).toContain('max-height="calc(100dvh - 330px)"'); + expect(source).toContain('label="权限类型" width="120" fixed="left"'); expect(source).not.toContain("defineEmits"); expect(source).not.toContain("el-checkbox"); }); diff --git a/frontend/src/components/ApiEndpointPermissions.vue b/frontend/src/components/ApiEndpointPermissions.vue index 85319d22..367ba2db 100644 --- a/frontend/src/components/ApiEndpointPermissions.vue +++ b/frontend/src/components/ApiEndpointPermissions.vue @@ -2,7 +2,11 @@
- 项目级权限矩阵 +
+ 有效权限矩阵 + 只读 +
+ 显示 {{ filteredOperations.length }} / {{ displayOperations.length }} 项权限
+ + + + + + + 重置
@@ -37,10 +48,12 @@ :data="filteredOperations" border stripe + max-height="calc(100dvh - 330px)" + empty-text="未找到匹配的权限" :span-method="spanMethod" class="perm-matrix-table" > - + - + @@ -105,7 +118,7 @@ diff --git a/frontend/src/components/PermissionTrendCharts.test.ts b/frontend/src/components/PermissionTrendCharts.test.ts index 897dfeaa..5a114707 100644 --- a/frontend/src/components/PermissionTrendCharts.test.ts +++ b/frontend/src/components/PermissionTrendCharts.test.ts @@ -5,39 +5,56 @@ import { resolve } from "node:path"; const readSource = () => readFileSync(resolve(__dirname, "./PermissionTrendCharts.vue"), "utf8"); describe("PermissionTrendCharts", () => { - it("renders explicit empty states instead of zero-value charts when samples are unavailable", () => { + it("uses accurate permission trend language and removes the misleading all-period option", () => { const source = readSource(); - expect(source).toContain("暂无有效响应时间样本"); - expect(source).toContain("暂无缓存访问样本"); - expect(source).toContain("暂无权限检查样本"); - expect(source).toContain("const hasResponseSamples = computed"); - expect(source).toContain("const hasCacheSamples = computed"); + expect(source).toContain("权限检查趋势"); + expect(source).toContain("权限检查量"); + expect(source).toContain("权限检查耗时"); + expect(source).toContain("权限拒绝率"); + expect(source).toContain("权限缓存命中率"); + expect(source).not.toContain("系统性能趋势"); + expect(source).not.toContain('{ value: "all", label: "全部" }'); }); - it("sorts points chronologically and includes the date in 24-hour labels", () => { + it("renders dense period summaries with previous-period and sample context", () => { const source = readSource(); - expect(source).toContain('String(d.getHours()).padStart(2, "0")'); - expect(source).toContain("dataPoints.value = [...res.data.data_points].sort("); - expect(source).toContain("new Date(left.bucket_time).getTime() - new Date(right.bucket_time).getTime()"); + expect(source).toContain('class="metric-strip"'); + expect(source).toContain("P95 检查耗时"); + expect(source).toContain("活跃范围"); + expect(source).toContain("较上期"); + expect(source).toContain("cache_sample_available"); + expect(source).toContain("不等于命中率为 0"); }); - it("keeps period selection, update time and refresh together in the tab toolbar", () => { + it("uses honest chart semantics and visible health thresholds", () => { const source = readSource(); - expect(source).toContain('class="toolbar-actions"'); - expect(source).toContain("最近成功更新:{{ formatLastUpdated(lastUpdatedAt) }}"); - expect(source).toContain('@click="loadData">刷新'); + expect(source).toContain('stack: "checks"'); + expect(source).toContain("smooth: false"); + expect(source).toContain('formatter: "慢检查 50ms"'); + expect(source).toContain('formatter: "健康线 80%"'); + expect(source).toContain("connectNulls: false"); }); - it("uses compact chart surfaces while preserving a two-column desktop layout", () => { + it("provides denial, latency and client attribution with access-log drilldown", () => { const source = readSource(); - expect(source).toContain("padding: 0 12px 12px"); - expect(source).toContain("grid-template-columns: repeat(2, 1fr)"); - expect(source).toContain("min-height: 276px"); - expect(source).toContain("height: 212px"); - expect(source).toContain("gap: 10px"); + expect(source).toContain("拒绝最多"); + expect(source).toContain("耗时最高"); + expect(source).toContain("客户端表现"); + expect(source).toContain('@click="onChecksChartClick"'); + expect(source).toContain('emit("drilldown"'); + expect(source).toContain("openAttributionLogs"); + }); + + it("keeps the information-dense layout responsive", () => { + const source = readSource(); + + expect(source).toContain("grid-template-columns: repeat(5, minmax(0, 1fr))"); + expect(source).toContain("grid-template-columns: minmax(0, 2.1fr)"); + expect(source).toContain("grid-template-columns: repeat(3, minmax(0, 1fr))"); + expect(source).toContain("@media (max-width: 900px)"); }); }); diff --git a/frontend/src/components/PermissionTrendCharts.vue b/frontend/src/components/PermissionTrendCharts.vue index 0fe25213..49019d07 100644 --- a/frontend/src/components/PermissionTrendCharts.vue +++ b/frontend/src/components/PermissionTrendCharts.vue @@ -1,474 +1,916 @@ diff --git a/frontend/src/components/SecurityCenter.test.ts b/frontend/src/components/SecurityCenter.test.ts index 213d1aa7..74018a4b 100644 --- a/frontend/src/components/SecurityCenter.test.ts +++ b/frontend/src/components/SecurityCenter.test.ts @@ -39,6 +39,16 @@ describe("SecurityCenter", () => { expect(source).not.toContain('
\n { + const source = readSource(); + + expect(source).toMatch(/\.security-table-wrap \{[\s\S]*?padding: 0;[\s\S]*?overflow: hidden;/); + expect(source).toMatch(/\.table-head \{[\s\S]*?margin-bottom: 0;[\s\S]*?padding: 10px 12px;/); + expect(source).toMatch(/\.pagination-wrap \{[\s\S]*?padding: 8px 12px;[\s\S]*?border-top:/); + expect(source).toContain(".security-table :deep(.el-scrollbar__wrap)"); + expect(source).toContain(".security-table :deep(.el-scrollbar__bar.is-horizontal)"); + }); + it("keeps risk levels on the page and opens abnormal sources in a compact ranking dialog", () => { const source = readSource(); diff --git a/frontend/src/components/SecurityCenter.vue b/frontend/src/components/SecurityCenter.vue index 409107fc..caa770ed 100644 --- a/frontend/src/components/SecurityCenter.vue +++ b/frontend/src/components/SecurityCenter.vue @@ -938,7 +938,8 @@ defineExpose({ refresh: loadSecurityEvents }); .security-table-wrap { min-width: 0; - padding: 10px; + padding: 0; + overflow: hidden; border: 1px solid var(--audit-border); border-radius: var(--card-radius); background: #fff; @@ -964,6 +965,14 @@ defineExpose({ refresh: loadSecurityEvents }); padding: 0 8px; } +.security-table :deep(.el-scrollbar__wrap) { + overflow-x: hidden; +} + +.security-table :deep(.el-scrollbar__bar.is-horizontal) { + display: none; +} + .time-cell, .ip-cell, .location-cell, @@ -1222,7 +1231,9 @@ defineExpose({ refresh: loadSecurityEvents }); .table-head { align-items: center; flex-wrap: wrap; - margin-bottom: 10px; + margin-bottom: 0; + padding: 10px 12px; + border-bottom: 1px solid var(--audit-border); } .table-head .section-title-group { @@ -1490,15 +1501,12 @@ defineExpose({ refresh: loadSecurityEvents }); text-align: right; } -.security-table-wrap { - overflow: hidden; -} - .pagination-wrap { display: flex; justify-content: flex-end; - margin-top: 10px; - padding: 8px 16px 0; + margin-top: 0; + padding: 8px 12px; + border-top: 1px solid var(--audit-border); } .category-pill { @@ -1806,8 +1814,7 @@ defineExpose({ refresh: loadSecurityEvents }); grid-template-columns: repeat(2, minmax(0, 1fr)); } - .security-analysis-card, - .security-table-wrap { + .security-analysis-card { padding: 10px; } diff --git a/frontend/src/components/WebLayout.vue b/frontend/src/components/WebLayout.vue index c4cf19d8..ee7f6569 100644 --- a/frontend/src/components/WebLayout.vue +++ b/frontend/src/components/WebLayout.vue @@ -34,7 +34,7 @@ {{ TEXT.menu.projectManagement }} - + {{ TEXT.menu.auditLogs }} @@ -55,7 +55,7 @@ {{ TEXT.menu.permissionManagement }} 系统级权限 - 项目权限配置 + 项目访问控制 @@ -414,6 +414,7 @@ import { } from "@element-plus/icons-vue"; import { ElMessageBox } from "element-plus"; import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions"; +import { isApiPermissionAllowed } from "../utils/apiPermissionValue"; import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager"; import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager"; import { @@ -446,6 +447,12 @@ const isAdmin = computed(() => !!auth.user?.is_admin); const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1"); const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || ""); const canAccessAdminPermissions = computed(() => isAdmin.value || projectRole.value === "PM"); +const canAccessAuditLogs = computed(() => + isAdmin.value || ( + projectRole.value === "PM" && + isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["audit_logs:read"]) + ), +); const canAccessProjectPath = (path: string) => hasProjectPermission(study.currentPermissions, projectRole.value, getProjectRoutePermission(path), isAdmin.value); const canAccessAnyProjectPath = (paths: string[]) => paths.some((path) => canAccessProjectPath(path)); @@ -844,7 +851,7 @@ const breadcrumbs = computed(() => { const items: any[] = []; if (route.path.startsWith("/admin")) { - // 顶级管理后台菜单(按权限过滤),供末级面包屑横跳 + // 顶级系统管理菜单(按权限过滤),供末级面包屑横跳 const topItems: { label: string; path: string }[] = []; if (isAdmin.value) topItems.push({ label: TEXT.menu.accountManagement, path: "/admin/users" }); topItems.push({ label: TEXT.menu.projectManagement, path: "/admin/projects" }); @@ -856,10 +863,10 @@ const breadcrumbs = computed(() => { // 权限管理子页,供「权限管理」父级与末级横跳 const permItems = [ { label: "系统级权限", path: "/admin/permissions/system" }, - { label: "项目权限配置", path: "/admin/permissions/project" }, + { label: "项目访问控制", path: "/admin/permissions/project" }, ]; - // 首项「管理后台」为分组标签,不可点击 + // 首项「系统管理」为分组标签,不可点击 items.push({ label: TEXT.menu.admin }); // 第二级:当前所属的顶级模块,带下拉横跳到其它顶级模块 @@ -890,7 +897,7 @@ const breadcrumbs = computed(() => { } // 项目详情:项目名由 viewContext 作为末项提供,继续走通用逻辑 } else { - // 顶级页面:模块名带同级下拉,可在管理后台各模块间横跳 + // 顶级页面:模块名带同级下拉,可在系统管理各模块间横跳 items.push({ label: curTop?.label || (route.meta?.title as string), path: route.path, diff --git a/frontend/src/components/layout/navigation.test.ts b/frontend/src/components/layout/navigation.test.ts new file mode 100644 index 00000000..28e3cac2 --- /dev/null +++ b/frontend/src/components/layout/navigation.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { buildAdminNavigationItems } from "./navigation"; + +const labels = (items: ReturnType) => items.map((item) => item.label); + +describe("system management navigation", () => { + it("exposes project management, audit logs, and permission management to PMs", () => { + const items = buildAdminNavigationItems({ + hasUser: true, + isAdmin: false, + canAccessAdminPermissions: true, + canAccessAuditLogs: true, + }); + + expect(labels(items)).toEqual(["项目管理", "审计日志", "权限管理"]); + }); + + it("does not expose PM system modules to a non-PM account", () => { + const items = buildAdminNavigationItems({ + hasUser: true, + isAdmin: false, + canAccessAdminPermissions: false, + canAccessAuditLogs: false, + }); + + expect(labels(items)).toEqual(["项目管理"]); + }); +}); diff --git a/frontend/src/components/layout/navigation.ts b/frontend/src/components/layout/navigation.ts index e3b3206a..f932dac2 100644 --- a/frontend/src/components/layout/navigation.ts +++ b/frontend/src/components/layout/navigation.ts @@ -58,6 +58,7 @@ export const buildAdminNavigationItems = (options: { hasUser: boolean; isAdmin: boolean; canAccessAdminPermissions: boolean; + canAccessAuditLogs: boolean; }): LayoutNavigationItem[] => { if (!options.hasUser) return []; @@ -78,15 +79,17 @@ export const buildAdminNavigationItems = (options: { icon: "project", keywords: ["project"], }); + if (options.isAdmin || options.canAccessAuditLogs) { + items.push({ + label: TEXT.menu.auditLogs, + path: "/admin/audit-logs", + group: TEXT.menu.admin, + icon: "audit", + keywords: ["audit"], + }); + } if (options.isAdmin) { items.push( - { - label: TEXT.menu.auditLogs, - path: "/admin/audit-logs", - group: TEXT.menu.admin, - icon: "audit", - keywords: ["audit"], - }, { label: TEXT.menu.systemMonitoring, path: "/admin/system-monitoring", @@ -128,7 +131,7 @@ export const buildAdminNavigationItems = (options: { keywords: ["permission", "system"], }, { - label: "项目权限配置", + label: "项目访问控制", path: "/admin/permissions/project", group: TEXT.menu.permissionManagement, icon: "key", diff --git a/frontend/src/locales/zh-CN.ts b/frontend/src/locales/zh-CN.ts index 63c85c31..60bbc2fa 100644 --- a/frontend/src/locales/zh-CN.ts +++ b/frontend/src/locales/zh-CN.ts @@ -276,7 +276,7 @@ export const TEXT = { }, }, menu: { - admin: "管理后台", + admin: "系统管理", accountManagement: "账号管理", projectManagement: "项目管理", auditLogs: "审计日志", diff --git a/frontend/src/router.test.ts b/frontend/src/router.test.ts index 30e2cff6..a338493c 100644 --- a/frontend/src/router.test.ts +++ b/frontend/src/router.test.ts @@ -100,6 +100,8 @@ describe("admin project route permissions", () => { expect(source).toContain("const workbenchEntryPath = resolveWorkbenchEntryPath(isDesktopRuntime)"); expect(source).toContain("next({ path: workbenchEntryPath });"); expect(source).toContain('if (isDesktopRuntime && token && !isAdmin && to.path.startsWith("/admin"))'); + expect(source).toContain("const canAccessProjectManagement = studyStore.currentStudyRole === \"PM\""); + expect(source).toContain("if (!canAccessProjectManagement)"); expect(source).toContain('next({ path: DESKTOP_PROJECT_ENTRY_PATH });'); expect(source).toContain("if (token && isWorkbenchEntryPath(to.path) && studyStore.currentStudy)"); expect(source).not.toContain("if (token && !studyStore.currentStudy && !isDesktopRuntime)"); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index a422227b..18a90e40 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -438,7 +438,7 @@ const routes: RouteRecordRaw[] = [ path: "permissions/project", name: "AdminPermissionsProject", component: PermissionManagement, - meta: { title: "项目权限配置", systemPermission: SYSTEM_PERMISSION_PROJECT_CONFIG }, + meta: { title: "项目访问控制", systemPermission: SYSTEM_PERMISSION_PROJECT_CONFIG }, }, ], }, @@ -592,8 +592,14 @@ router.beforeEach(async (to, _from, next) => { } if (isDesktopRuntime && token && !isAdmin && to.path.startsWith("/admin")) { - next({ path: DESKTOP_PROJECT_ENTRY_PATH }); - return; + const hasPmProject = studyStore.currentStudyRole === "PM" || (studyStore.currentStudy as any)?.role_in_study === "PM"; + if (!hasPmProject) await studyStore.ensureDefaultPmStudy().catch(() => {}); + const canAccessProjectManagement = studyStore.currentStudyRole === "PM" || (studyStore.currentStudy as any)?.role_in_study === "PM"; + if (!canAccessProjectManagement) { + next({ path: DESKTOP_PROJECT_ENTRY_PATH }); + return; + } + if (!studyStore.currentPermissions) await studyStore.loadCurrentStudyPermissions().catch(() => {}); } if (isDesktopRuntime && token && isAdmin && to.path.startsWith("/admin") && studyStore.currentStudy) { diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index b904d8cd..a93f0eaf 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -1060,7 +1060,8 @@ body.is-desktop-runtime .el-dialog { body.is-desktop-runtime .profile-settings-dialog, body.is-desktop-runtime .desktop-preferences-dialog, body.is-desktop-runtime .user-form-dialog, -body.is-desktop-runtime .reset-password-dialog { +body.is-desktop-runtime .reset-password-dialog, +body.is-desktop-runtime .project-form-dialog { overflow: visible !important; border: none !important; border-radius: 0 !important; @@ -1112,13 +1113,15 @@ body.is-desktop-runtime .el-dialog__body { /* 账号表单去掉外框后,内容区仍需作为标题与底栏之间的连续面板。 */ body.is-desktop-runtime .user-form-dialog .el-dialog__body, -body.is-desktop-runtime .reset-password-dialog .el-dialog__body { +body.is-desktop-runtime .reset-password-dialog .el-dialog__body, +body.is-desktop-runtime .project-form-dialog .el-dialog__body { background: rgba(248, 250, 252, 0.98); } /* 账号操作弹窗保留单层面板:以小圆角和细边框明确边界,关闭按钮置于标题栏内。 */ body.is-desktop-runtime .user-form-dialog, -body.is-desktop-runtime .reset-password-dialog { +body.is-desktop-runtime .reset-password-dialog, +body.is-desktop-runtime .project-form-dialog { padding: 0 !important; overflow: hidden !important; border: 1px solid rgba(148, 163, 184, 0.5) !important; @@ -1126,7 +1129,8 @@ body.is-desktop-runtime .reset-password-dialog { } body.is-desktop-runtime .user-form-dialog .el-dialog__headerbtn, -body.is-desktop-runtime .reset-password-dialog .el-dialog__headerbtn { +body.is-desktop-runtime .reset-password-dialog .el-dialog__headerbtn, +body.is-desktop-runtime .project-form-dialog .el-dialog__headerbtn { top: 5px; right: 7px; width: 28px; @@ -1222,6 +1226,7 @@ body.is-desktop-runtime .msgbox-fade-leave-to .el-message-box { :root[data-ctms-theme="dark"] body.is-desktop-runtime .desktop-preferences-dialog, :root[data-ctms-theme="dark"] body.is-desktop-runtime .user-form-dialog, :root[data-ctms-theme="dark"] body.is-desktop-runtime .reset-password-dialog, +:root[data-ctms-theme="dark"] body.is-desktop-runtime .project-form-dialog, :root[data-ctms-theme="dark"] body.is-desktop-runtime .desktop-preferences-dialog .el-dialog__body { background: transparent !important; } @@ -1244,12 +1249,14 @@ body.is-desktop-runtime .msgbox-fade-leave-to .el-message-box { } :root[data-ctms-theme="dark"] body.is-desktop-runtime .user-form-dialog .el-dialog__body, -:root[data-ctms-theme="dark"] body.is-desktop-runtime .reset-password-dialog .el-dialog__body { +:root[data-ctms-theme="dark"] body.is-desktop-runtime .reset-password-dialog .el-dialog__body, +:root[data-ctms-theme="dark"] body.is-desktop-runtime .project-form-dialog .el-dialog__body { background: rgba(17, 24, 39, 0.98); } :root[data-ctms-theme="dark"] body.is-desktop-runtime .user-form-dialog, -:root[data-ctms-theme="dark"] body.is-desktop-runtime .reset-password-dialog { +:root[data-ctms-theme="dark"] body.is-desktop-runtime .reset-password-dialog, +:root[data-ctms-theme="dark"] body.is-desktop-runtime .project-form-dialog { border-color: rgba(71, 85, 105, 0.9) !important; } diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 1d9423d2..56fb1a2d 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -549,6 +549,7 @@ export interface AuditLogItem { // 系统监测 - 趋势数据 export interface TrendDataPoint { bucket_time: string; + sample_state: "complete" | "partial"; total_checks: number; allowed_checks: number; denied_checks: number; @@ -557,11 +558,72 @@ export interface TrendDataPoint { cache_hits: number; cache_misses: number; cache_hit_rate: number; + cache_sample_available: boolean; error_count: number; } +export interface TrendPeriodSummary { + total_checks: number; + allowed_checks: number; + denied_checks: number; + deny_rate: number; + avg_elapsed_ms: number; + p95_elapsed_ms: number; + max_elapsed_ms: number; + slow_check_count: number; + active_study_count: number; + active_user_count: number; + active_endpoint_count: number; + cache_hits: number; + cache_misses: number; + cache_hit_rate: number; + cache_sample_available: boolean; +} + +export interface TrendDeniedAttribution { + endpoint_key: string; + role: string; + denied_count: number; +} + +export interface TrendSlowAttribution { + endpoint_key: string; + sample_count: number; + slow_count: number; + avg_elapsed_ms: number; + max_elapsed_ms: number; +} + +export interface TrendClientAttribution { + client_type: string; + client_version: string; + client_platform: string; + total_count: number; + denied_count: number; + deny_rate: number; + avg_elapsed_ms: number; +} + export interface TrendsResponse { period: string; + generated_at: string; + range: { + start_at: string; + end_at: string; + previous_start_at: string; + previous_end_at: string; + bucket_seconds: number; + bucket_count: number; + timezone: string; + observed_end_at: string | null; + }; + summary: TrendPeriodSummary; + previous_summary: TrendPeriodSummary; + attribution: { + top_denied: TrendDeniedAttribution[]; + top_slow: TrendSlowAttribution[]; + client_breakdown: TrendClientAttribution[]; + }; data_points: TrendDataPoint[]; } diff --git a/frontend/src/types/desktopCommands.test.ts b/frontend/src/types/desktopCommands.test.ts index bf025c80..d676e90e 100644 --- a/frontend/src/types/desktopCommands.test.ts +++ b/frontend/src/types/desktopCommands.test.ts @@ -13,7 +13,7 @@ const commands: DesktopCommand[] = [ { id: "hidden", title: "系统监控", - group: "管理后台", + group: "系统管理", visible: false, run: () => {}, }, diff --git a/frontend/src/views/DesktopProjectEntry.test.ts b/frontend/src/views/DesktopProjectEntry.test.ts index 1845bc4b..f639712a 100644 --- a/frontend/src/views/DesktopProjectEntry.test.ts +++ b/frontend/src/views/DesktopProjectEntry.test.ts @@ -5,20 +5,26 @@ import { resolve } from "node:path"; const readEntryView = () => readFileSync(resolve(__dirname, "./DesktopProjectEntry.vue"), "utf8"); describe("DesktopProjectEntry", () => { - it("offers admin backend and explicit project entry without direct in-project switching", () => { + it("offers system management access only to admins and authorized project PMs", () => { const source = readEntryView(); expect(source).toContain("工作台总控"); expect(source).toContain("选择目标项目"); expect(source).toContain("Desktop Workbench"); expect(source).toContain('class="entry-background-grid"'); - expect(source).toContain("管理后台"); + expect(source).toContain("系统管理"); expect(source).toContain("ADMIN SYSTEM"); + expect(source).toContain("SYSTEM MANAGEMENT"); + expect(source).toContain("系统管理"); + expect(source).toContain('v-if="canEnterManagement"'); + expect(source).toContain('projects.value.some((project) => project.role_in_study === "PM")'); expect(source).toContain("project-cards-grid"); expect(source).toContain("card-action-bar"); expect(source).toContain("进入项目工作空间"); - expect(source).toContain('router.push("/admin/users")'); + expect(source).toContain('router.push(isAdmin.value ? "/admin/users" : "/admin/projects")'); expect(source).toContain("studyStore.clearCurrentStudy()"); + expect(source).toContain("studyStore.setCurrentStudy(pmProject)"); + expect(source).toContain("projects.value.find((project) => project.role_in_study === \"PM\")"); expect(source).toContain("fetchStudies()"); expect(source).toContain("studyStore.setCurrentStudy(project)"); expect(source).toContain("studyStore.loadCurrentStudyPermissions()"); diff --git a/frontend/src/views/DesktopProjectEntry.vue b/frontend/src/views/DesktopProjectEntry.vue index 8c8edbd9..0b2811a6 100644 --- a/frontend/src/views/DesktopProjectEntry.vue +++ b/frontend/src/views/DesktopProjectEntry.vue @@ -32,20 +32,22 @@
- - - -