权限监控与管理界面全面美化

重新设计权限系统所有 UI 组件的视觉风格,统一配色、圆角、阴影和交互动效:
- 实时概览:统计卡片加图标和渐变色条,健康评分改为环形进度,告警改为卡片式
- 趋势分析:图表卡片加彩色图标标识,ECharts 配色升级为渐变面积填充
- 访问日志:指标卡片带图标,日志审计改为卡片式入口,IP排行前三高亮
- IP属地:工具栏重设计,排行列表前三渐变高亮,指标卡片统一新风格
- 系统级权限:从 el-table 改为自定义卡片列表,模块块独立圆角卡片
- 项目权限配置:空状态引导优化,成员表格加头像,工具栏加背景容器
- 角色概览卡片:加进度条可视化,hover 微动效
- 接口权限矩阵:工具栏分离布局,表格圆角包裹
- 角色管理抽屉:侧边栏选中态渐变,操作行 hover 高亮

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Cheng Zhou
2026-05-20 14:36:35 +08:00
parent e95eeed90e
commit 6cefa620e4
68 changed files with 6927 additions and 984 deletions
@@ -0,0 +1,59 @@
"""add permission monitoring tables
Revision ID: 20260519_01
Revises: 20260518_01
Create Date: 2026-05-19 10:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "20260519_01"
down_revision: Union[str, None] = "20260518_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"permission_access_logs",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("study_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("studies.id"), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False),
sa.Column("endpoint_key", sa.String(100), nullable=False),
sa.Column("role", sa.String(30), nullable=False),
sa.Column("allowed", sa.Boolean(), nullable=False),
sa.Column("elapsed_ms", sa.Float(), nullable=False),
sa.Column("ip_address", sa.String(45), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_perm_log_study_created", "permission_access_logs", ["study_id", "created_at"])
op.create_index("ix_perm_log_user_created", "permission_access_logs", ["user_id", "created_at"])
op.create_index("ix_perm_log_endpoint_created", "permission_access_logs", ["endpoint_key", "created_at"])
op.create_index("ix_perm_log_created_at", "permission_access_logs", ["created_at"])
op.create_index("ix_perm_log_allowed", "permission_access_logs", ["allowed", "created_at"])
op.create_table(
"permission_metric_snapshots",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("bucket_time", sa.DateTime(timezone=True), nullable=False),
sa.Column("total_checks", sa.Integer(), nullable=False, server_default="0"),
sa.Column("allowed_checks", sa.Integer(), nullable=False, server_default="0"),
sa.Column("denied_checks", sa.Integer(), nullable=False, server_default="0"),
sa.Column("avg_elapsed_ms", sa.Float(), nullable=False, server_default="0"),
sa.Column("max_elapsed_ms", sa.Float(), nullable=False, server_default="0"),
sa.Column("cache_hits", sa.Integer(), nullable=False, server_default="0"),
sa.Column("cache_misses", sa.Integer(), nullable=False, server_default="0"),
sa.Column("error_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_perm_snapshot_bucket", "permission_metric_snapshots", ["bucket_time"])
def downgrade() -> None:
op.drop_table("permission_metric_snapshots")
op.drop_table("permission_access_logs")
@@ -0,0 +1,42 @@
"""add security access logs
Revision ID: 20260520_01
Revises: 20260519_01
Create Date: 2026-05-20 10:40:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "20260520_01"
down_revision: Union[str, None] = "20260519_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"security_access_logs",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("method", sa.String(12), nullable=False),
sa.Column("path", sa.String(500), nullable=False),
sa.Column("status_code", sa.Integer(), nullable=False),
sa.Column("elapsed_ms", sa.Float(), nullable=False),
sa.Column("client_ip", sa.String(45), nullable=True),
sa.Column("user_agent", sa.String(500), nullable=True),
sa.Column("auth_status", sa.String(30), nullable=False),
sa.Column("user_identifier", sa.String(80), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_security_log_created_at", "security_access_logs", ["created_at"])
op.create_index("ix_security_log_ip_created", "security_access_logs", ["client_ip", "created_at"])
op.create_index("ix_security_log_status_created", "security_access_logs", ["status_code", "created_at"])
op.create_index("ix_security_log_auth_created", "security_access_logs", ["auth_status", "created_at"])
def downgrade() -> None:
op.drop_table("security_access_logs")
+12 -3
View File
@@ -16,6 +16,7 @@ from app.core.project_permissions import (
get_missing_prerequisites,
)
from app.models.api_endpoint_registry import ApiEndpointRegistry
from app.models.study import Study
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, PROJECT_PERMISSION_ROLES, OPERATION_PREREQUISITES
router = APIRouter(prefix="/api-permissions", tags=["api-permissions"])
@@ -24,6 +25,13 @@ router = APIRouter(prefix="/api-permissions", tags=["api-permissions"])
study_router = APIRouter(prefix="/api-permissions", tags=["api-permissions"])
async def _get_configurable_roles(db: AsyncSession, study_id: uuid.UUID) -> list[str]:
result = await db.execute(select(Study).where(Study.id == study_id))
study = result.scalar_one_or_none()
active_roles = [role for role in (study.active_roles if study else []) if isinstance(role, str) and role.strip()]
return list(dict.fromkeys([*PROJECT_PERMISSION_ROLES, *active_roles]))
@router.get(
"/operations",
summary="获取系统中所有权限操作",
@@ -167,7 +175,7 @@ async def get_study_api_permissions(
# 构建返回格式(get_api_endpoint_permissions 已返回 {role: {key: {"allowed": bool}}}
result: dict[str, dict[str, dict[str, bool]]] = {}
for role in PROJECT_PERMISSION_ROLES:
for role in await _get_configurable_roles(db, study_id):
if role == "ADMIN":
continue
result[role] = {}
@@ -203,10 +211,11 @@ async def update_study_api_permissions(
}
"""
# 验证输入
configurable_roles = set(await _get_configurable_roles(db, study_id))
for role in payload.keys():
if role == "ADMIN":
continue
if role not in PROJECT_PERMISSION_ROLES:
if role not in configurable_roles:
raise ValueError(f"无效的角色: {role}")
# 替换权限配置
@@ -216,7 +225,7 @@ async def update_study_api_permissions(
permissions = await get_api_endpoint_permissions(db, study_id)
result: dict[str, dict[str, dict[str, bool]]] = {}
for role in PROJECT_PERMISSION_ROLES:
for role in await _get_configurable_roles(db, study_id):
if role == "ADMIN":
continue
result[role] = {}
+540 -35
View File
@@ -1,26 +1,38 @@
"""权限系统监控API
提供权限系统的监控数据和告警信息。
提供权限系统的监控数据、访问日志、趋势分析和告警信息。
"""
from typing import Annotated
from __future__ import annotations
from fastapi import APIRouter, Depends, status
import uuid
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import Optional
from app.core.deps import get_current_user
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy import func, select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session
from app.core.permission_monitor import evaluate_permission_system_health, get_permission_monitor
from app.models.permission_access_log import PermissionAccessLog
from app.models.permission_metric_snapshot import PermissionMetricSnapshot
from app.models.security_access_log import SecurityAccessLog
from app.models.user import User
from app.services.ip_location import resolve_ip_location
router = APIRouter(prefix="/permission-monitoring", tags=["permission-monitoring"])
# ═══════════════════════════════════════════
# 原有端点(保持兼容)
# ═══════════════════════════════════════════
@router.get("/metrics", status_code=status.HTTP_200_OK)
async def get_permission_metrics(
_=Depends(get_current_user),
) -> dict:
"""获取权限系统指标
返回权限检查、缓存等运行指标。
"""
monitor = get_permission_monitor()
return monitor.get_metrics()
@@ -29,10 +41,6 @@ async def get_permission_metrics(
async def get_cache_statistics(
_=Depends(get_current_user),
) -> dict:
"""获取缓存统计信息
返回缓存命中率、缓存项目数等信息。
"""
monitor = get_permission_monitor()
return monitor.get_cache_stats()
@@ -43,15 +51,6 @@ async def get_alerts(
limit: int = 100,
_=Depends(get_current_user),
) -> dict:
"""获取告警列表
Args:
level: 告警级别过滤(info, warning, error
limit: 返回的最大告警数
Returns:
告警列表
"""
monitor = get_permission_monitor()
alerts = monitor.get_alerts(level=level, limit=limit)
return {
@@ -59,15 +58,10 @@ async def get_alerts(
"alerts": alerts,
}
@router.post("/reset-metrics", status_code=status.HTTP_200_OK)
async def reset_metrics(
_=Depends(get_current_user),
) -> dict:
"""重置监控指标
清除所有累积的指标数据,重新开始统计。
"""
monitor = get_permission_monitor()
monitor.reset_metrics()
return {"message": "指标已重置"}
@@ -77,10 +71,6 @@ async def reset_metrics(
async def clear_alerts(
_=Depends(get_current_user),
) -> dict:
"""清除所有告警
删除所有累积的告警记录。
"""
monitor = get_permission_monitor()
monitor.clear_alerts()
return {"message": "告警已清除"}
@@ -90,12 +80,527 @@ async def clear_alerts(
async def permission_system_health(
_=Depends(get_current_user),
) -> dict:
"""权限系统健康检查
返回权限系统的健康状态。
"""
monitor = get_permission_monitor()
metrics = monitor.get_metrics()
cache_stats = monitor.get_cache_stats()
return evaluate_permission_system_health(metrics, cache_stats)
# ═══════════════════════════════════════════
# 新增端点:访问日志
# ═══════════════════════════════════════════
@router.get("/access-logs", status_code=status.HTTP_200_OK)
async def get_access_logs(
db: AsyncSession = Depends(get_db_session),
_=Depends(get_current_user),
study_id: Optional[uuid.UUID] = Query(None),
user_id: Optional[uuid.UUID] = Query(None),
endpoint_key: Optional[str] = Query(None),
role: Optional[str] = Query(None),
allowed: Optional[bool] = Query(None),
start_time: Optional[datetime] = Query(None),
end_time: Optional[datetime] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
) -> dict:
"""分页查询权限访问日志"""
conditions = []
if study_id:
conditions.append(PermissionAccessLog.study_id == study_id)
if user_id:
conditions.append(PermissionAccessLog.user_id == user_id)
if endpoint_key:
conditions.append(PermissionAccessLog.endpoint_key == endpoint_key)
if role:
conditions.append(PermissionAccessLog.role == role)
if allowed is not None:
conditions.append(PermissionAccessLog.allowed == allowed)
if start_time:
conditions.append(PermissionAccessLog.created_at >= start_time)
if end_time:
conditions.append(PermissionAccessLog.created_at <= end_time)
query = (
select(PermissionAccessLog, User.full_name)
.outerjoin(User, PermissionAccessLog.user_id == User.id)
)
count_query = select(func.count()).select_from(PermissionAccessLog)
summary_query = select(
func.count().label("total_count"),
func.count(func.distinct(PermissionAccessLog.user_id)).label("unique_user_count"),
func.count(func.distinct(PermissionAccessLog.ip_address)).label("unique_ip_count"),
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied_count"),
func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_elapsed_ms"),
).select_from(PermissionAccessLog)
user_stats_query = (
select(
PermissionAccessLog.user_id,
User.full_name,
PermissionAccessLog.role,
PermissionAccessLog.ip_address.label("sample_ip_address"),
func.count().label("total_count"),
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied_count"),
func.count(func.distinct(PermissionAccessLog.ip_address)).label("unique_ip_count"),
func.max(PermissionAccessLog.created_at).label("last_seen_at"),
)
.outerjoin(User, PermissionAccessLog.user_id == User.id)
.group_by(PermissionAccessLog.ip_address, PermissionAccessLog.user_id, User.full_name, PermissionAccessLog.role)
.order_by(desc("total_count"), desc("last_seen_at"))
.limit(10)
)
for condition in conditions:
query = query.where(condition)
count_query = count_query.where(condition)
summary_query = summary_query.where(condition)
user_stats_query = user_stats_query.where(condition)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
summary_result = await db.execute(summary_query)
summary_row = summary_result.one()
user_stats_result = await db.execute(user_stats_query)
user_stats_rows = user_stats_result.all()
query = query.order_by(desc(PermissionAccessLog.created_at))
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
rows = result.all()
items = []
for log, full_name in rows:
ip_location = resolve_ip_location(log.ip_address)
items.append(
{
"id": str(log.id),
"study_id": str(log.study_id),
"user_id": str(log.user_id),
"user_name": full_name or "未知用户",
"endpoint_key": log.endpoint_key,
"role": log.role,
"allowed": log.allowed,
"elapsed_ms": round(log.elapsed_ms, 2),
"ip_address": log.ip_address,
"ip_location": ip_location.location,
"ip_country": ip_location.country,
"ip_province": ip_location.province,
"ip_city": ip_location.city,
"ip_isp": ip_location.isp,
"created_at": log.created_at.isoformat(),
}
)
user_stats = []
for stat in user_stats_rows:
sample_location = resolve_ip_location(stat.sample_ip_address)
user_stats.append(
{
"user_id": str(stat.user_id),
"user_name": stat.full_name or "未知用户",
"role": stat.role,
"total_count": stat.total_count,
"denied_count": stat.denied_count,
"unique_ip_count": stat.unique_ip_count,
"sample_ip_address": stat.sample_ip_address,
"last_seen_at": stat.last_seen_at.isoformat() if stat.last_seen_at else None,
"primary_location": sample_location.location,
}
)
return {
"total": total,
"page": page,
"page_size": page_size,
"summary": {
"total_count": summary_row.total_count,
"unique_user_count": summary_row.unique_user_count,
"unique_ip_count": summary_row.unique_ip_count,
"denied_count": summary_row.denied_count,
"avg_elapsed_ms": round(float(summary_row.avg_elapsed_ms), 2),
},
"user_stats": user_stats,
"items": items,
}
def _security_account_label(auth_status: str, user_identifier: str | None, user_names: dict[str, str] | None = None) -> str:
if auth_status == "AUTHENTICATED" and user_identifier:
return (user_names or {}).get(user_identifier) or user_identifier
if auth_status == "INVALID_TOKEN":
return "无效令牌"
return "未知账号"
@router.get("/security-logs", status_code=status.HTTP_200_OK)
async def get_security_access_logs(
db: AsyncSession = Depends(get_db_session),
_=Depends(get_current_user),
status_min: Optional[int] = Query(None, ge=100, le=599),
auth_status: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
) -> dict:
"""查询底层安全访问日志,覆盖匿名、无效令牌和异常状态请求。"""
conditions = []
if status_min is not None:
conditions.append(SecurityAccessLog.status_code >= status_min)
if auth_status:
conditions.append(SecurityAccessLog.auth_status == auth_status)
query = select(SecurityAccessLog)
count_query = select(func.count()).select_from(SecurityAccessLog)
summary_query = select(
func.count().label("total_count"),
func.count().filter(SecurityAccessLog.auth_status == "ANONYMOUS").label("anonymous_count"),
func.count().filter(SecurityAccessLog.auth_status == "INVALID_TOKEN").label("invalid_token_count"),
func.count().filter(SecurityAccessLog.status_code >= 400).label("error_count"),
).select_from(SecurityAccessLog)
for condition in conditions:
query = query.where(condition)
count_query = count_query.where(condition)
summary_query = summary_query.where(condition)
total_result = await db.execute(count_query)
summary_result = await db.execute(summary_query)
result = await db.execute(
query.order_by(desc(SecurityAccessLog.created_at))
.offset((page - 1) * page_size)
.limit(page_size)
)
summary_row = summary_result.one()
logs = result.scalars().all()
user_ids: list[uuid.UUID] = []
for log in logs:
if log.auth_status != "AUTHENTICATED" or not log.user_identifier:
continue
try:
user_ids.append(uuid.UUID(log.user_identifier))
except ValueError:
continue
user_names: dict[str, str] = {}
if user_ids:
users_result = await db.execute(select(User.id, User.full_name).where(User.id.in_(user_ids)))
user_names = {str(row.id): row.full_name for row in users_result.all()}
items = []
for log in logs:
items.append(
{
"id": str(log.id),
"method": log.method,
"path": log.path,
"status_code": log.status_code,
"elapsed_ms": round(log.elapsed_ms, 2),
"client_ip": log.client_ip,
"user_agent": log.user_agent,
"auth_status": log.auth_status,
"user_identifier": log.user_identifier,
"account_label": _security_account_label(log.auth_status, log.user_identifier, user_names),
"created_at": log.created_at.isoformat(),
}
)
return {
"total": total_result.scalar() or 0,
"page": page,
"page_size": page_size,
"summary": {
"total_count": summary_row.total_count,
"anonymous_count": summary_row.anonymous_count,
"invalid_token_count": summary_row.invalid_token_count,
"error_count": summary_row.error_count,
},
"items": items,
}
# ═══════════════════════════════════════════
# 新增端点:趋势数据
# ═══════════════════════════════════════════
@router.get("/trends", status_code=status.HTTP_200_OK)
async def get_trends(
db: AsyncSession = Depends(get_db_session),
_=Depends(get_current_user),
period: str = Query("24h", pattern="^(24h|7d|30d)$"),
) -> dict:
"""获取趋势数据(从快照表或实时聚合)"""
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]
# 先尝试从快照表获取
snapshot_query = (
select(PermissionMetricSnapshot)
.where(PermissionMetricSnapshot.bucket_time >= start_time)
.order_by(PermissionMetricSnapshot.bucket_time)
)
result = await db.execute(snapshot_query)
snapshots = result.scalars().all()
if snapshots:
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。
result = await db.execute(
select(
PermissionAccessLog.created_at,
PermissionAccessLog.allowed,
PermissionAccessLog.elapsed_ms,
).where(PermissionAccessLog.created_at >= start_time)
)
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))
return {
"period": period,
"data_points": [
{
"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,
}
for bucket, values in sorted(buckets.items())
],
}
def _trend_bucket_time(value: datetime, period: str) -> datetime:
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
if period == "24h":
return value.replace(minute=0, second=0, microsecond=0)
if period == "7d":
return value.replace(hour=(value.hour // 6) * 6, minute=0, second=0, microsecond=0)
return value.replace(hour=0, minute=0, second=0, microsecond=0)
# ═══════════════════════════════════════════
# 新增端点:被拒绝最多的权限
# ═══════════════════════════════════════════
@router.get("/top-denied", status_code=status.HTTP_200_OK)
async def get_top_denied(
db: AsyncSession = Depends(get_db_session),
_=Depends(get_current_user),
days: int = Query(7, ge=1, le=90),
limit: int = Query(20, ge=1, le=100),
) -> dict:
"""获取被拒绝最多的权限"""
start_time = datetime.now(timezone.utc) - timedelta(days=days)
query = (
select(
PermissionAccessLog.endpoint_key,
PermissionAccessLog.role,
func.count().label("denied_count"),
)
.where(
PermissionAccessLog.created_at >= start_time,
PermissionAccessLog.allowed.is_(False),
)
.group_by(PermissionAccessLog.endpoint_key, PermissionAccessLog.role)
.order_by(desc("denied_count"))
.limit(limit)
)
result = await db.execute(query)
rows = result.all()
return {
"days": days,
"items": [
{
"endpoint_key": row.endpoint_key,
"role": row.role,
"denied_count": row.denied_count,
}
for row in rows
],
}
# ═══════════════════════════════════════════
# 新增端点:IP 属地统计
# ═══════════════════════════════════════════
@router.get("/ip-locations", status_code=status.HTTP_200_OK)
async def get_ip_locations(
db: AsyncSession = Depends(get_db_session),
_=Depends(get_current_user),
days: int = Query(7, ge=1, le=90),
limit: int = Query(20, ge=1, le=100),
) -> dict:
"""获取 IP 省市属地统计。"""
start_time = datetime.now(timezone.utc) - timedelta(days=days)
query = (
select(
PermissionAccessLog.ip_address,
PermissionAccessLog.user_id,
PermissionAccessLog.allowed,
)
.where(
PermissionAccessLog.created_at >= start_time,
PermissionAccessLog.ip_address.is_not(None),
)
)
result = await db.execute(query)
buckets: dict[tuple[str, str, str, str], dict] = {}
all_ip_addresses: set[str] = set()
all_user_ids: set[uuid.UUID] = set()
total_count = 0
allowed_count = 0
denied_count = 0
for ip_address, user_id, allowed in result.all():
ip_info = resolve_ip_location(ip_address)
key = (ip_info.country, ip_info.province, ip_info.city, ip_info.isp)
bucket = buckets.setdefault(
key,
{
"country": ip_info.country,
"province": ip_info.province,
"city": ip_info.city,
"isp": ip_info.isp,
"location": ip_info.location,
"total_count": 0,
"allowed_count": 0,
"denied_count": 0,
"ip_addresses": set(),
"user_ids": set(),
},
)
bucket["total_count"] += 1
bucket["allowed_count" if allowed else "denied_count"] += 1
bucket["ip_addresses"].add(ip_address)
bucket["user_ids"].add(user_id)
total_count += 1
if allowed:
allowed_count += 1
else:
denied_count += 1
all_ip_addresses.add(ip_address)
all_user_ids.add(user_id)
items = sorted(buckets.values(), key=lambda item: item["total_count"], reverse=True)[:limit]
return {
"days": days,
"summary": {
"total_count": total_count,
"allowed_count": allowed_count,
"denied_count": denied_count,
"unique_ip_count": len(all_ip_addresses),
"unique_user_count": len(all_user_ids),
},
"items": [
{
"country": item["country"],
"province": item["province"],
"city": item["city"],
"isp": item["isp"],
"location": item["location"],
"total_count": item["total_count"],
"allowed_count": item["allowed_count"],
"denied_count": item["denied_count"],
"unique_ip_count": len(item["ip_addresses"]),
"unique_user_count": len(item["user_ids"]),
}
for item in items
],
}
# ═══════════════════════════════════════════
# 新增端点:实时统计摘要(从DB获取)
# ═══════════════════════════════════════════
@router.get("/stats-summary", status_code=status.HTTP_200_OK)
async def get_stats_summary(
db: AsyncSession = Depends(get_db_session),
_=Depends(get_current_user),
) -> dict:
"""获取基于数据库的统计摘要"""
now = datetime.now(timezone.utc)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
hour_ago = now - timedelta(hours=1)
# 今日统计
today_result = await db.execute(
select(
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.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"),
func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_ms"),
).where(PermissionAccessLog.created_at >= today_start)
)
today = today_result.one()
# 最近一小时
hour_result = await db.execute(
select(
func.count().label("total"),
func.count().filter(PermissionAccessLog.allowed.is_(True)).label("allowed"),
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied"),
).where(PermissionAccessLog.created_at >= hour_ago)
)
hour = hour_result.one()
# 总记录数
total_result = await db.execute(
select(func.count()).select_from(PermissionAccessLog)
)
total_logs = total_result.scalar() or 0
return {
"total_logs": total_logs,
"today": {
"total_checks": today.total,
"allowed_checks": today.allowed,
"denied_checks": today.denied,
"avg_elapsed_ms": round(float(today.avg_ms), 2),
"max_elapsed_ms": round(float(today.max_ms), 2),
"allow_rate": round(today.allowed / today.total * 100, 1) if today.total > 0 else 0,
"deny_rate": round(today.denied / today.total * 100, 1) if today.total > 0 else 0,
},
"last_hour": {
"total_checks": hour.total,
"allowed_checks": hour.allowed,
"denied_checks": hour.denied,
"requests_per_minute": round(hour.total / 60, 1),
},
}
+18 -1
View File
@@ -15,6 +15,23 @@ from app.models.study import Study
router = APIRouter(prefix="/active-roles", tags=["active-roles"])
def _normalize_active_roles(value: object) -> list[str]:
if not isinstance(value, list):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="active_roles 必须是数组")
roles: list[str] = []
for item in value:
role = str(item or "").strip()
if not role:
continue
if role == "ADMIN":
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ADMIN 不能作为项目角色")
if len(role) > 20:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="项目角色长度不能超过20个字符")
if role not in roles:
roles.append(role)
return roles
@router.get("", summary="获取项目已生效角色列表")
async def get_active_roles(
study_id: uuid.UUID,
@@ -39,6 +56,6 @@ async def update_active_roles(
study = result.scalar_one_or_none()
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
study.active_roles = payload.get("active_roles", [])
study.active_roles = _normalize_active_roles(payload.get("active_roles", []))
await db.commit()
return {"active_roles": study.active_roles}
+42 -6
View File
@@ -409,7 +409,7 @@ API_ENDPOINT_PERMISSIONS = {
"default_roles": ["PM"],
},
# 项目概览
"overview:read": {
"project_overview:read": {
"module": "project_overview",
"action": "read",
"description": "查询项目概览",
@@ -583,6 +583,13 @@ API_ENDPOINT_PERMISSIONS = {
"default_roles": ["PM", "CRA"],
"prerequisite_permissions": ["subjects:read", "sites:read"],
},
"subject_pds:delete": {
"module": "subjects",
"action": "write",
"description": "删除参与者PDS",
"default_roles": ["PM"],
"prerequisite_permissions": ["subjects:read", "sites:read"],
},
# 审计日志管理
"audit_logs:list": {
"module": "audit_export",
@@ -665,18 +672,43 @@ API_ENDPOINT_PERMISSIONS = {
"default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW"],
},
# 项目里程碑管理
"milestones:list": {
"project_milestones:read": {
"module": "project_milestones",
"action": "read",
"description": "查询项目里程碑列表",
"default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
},
"milestones:update": {
"project_milestones:update": {
"module": "project_milestones",
"action": "write",
"description": "更新项目里程碑",
"default_roles": ["PM"],
},
# 启动与授权
"startup_auth:create": {
"module": "startup_auth",
"action": "write",
"description": "创建启动会或培训授权",
"default_roles": ["PM"],
},
"startup_auth:read": {
"module": "startup_auth",
"action": "read",
"description": "查询启动会或培训授权",
"default_roles": ["PM", "CRA", "PV", "IMP"],
},
"startup_auth:update": {
"module": "startup_auth",
"action": "write",
"description": "更新启动会或培训授权",
"default_roles": ["PM"],
},
"startup_auth:delete": {
"module": "startup_auth",
"action": "write",
"description": "删除培训授权",
"default_roles": ["PM"],
},
# 附件管理
"attachments:create": {
"module": "attachments",
@@ -981,12 +1013,16 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
},
"startup_auth": {
"read": [
"startup_auth:read",
"budget:list",
"budget:read",
"timeline:list",
"timeline:read",
],
"write": [
"startup_auth:create",
"startup_auth:update",
"startup_auth:delete",
"budget:create",
"budget:update",
"budget:delete",
@@ -997,7 +1033,7 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
},
"project_overview": {
"read": [
"overview:read",
"project_overview:read",
],
"write": [],
},
@@ -1083,10 +1119,10 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
},
"project_milestones": {
"read": [
"milestones:list",
"project_milestones:read",
],
"write": [
"milestones:update",
"project_milestones:update",
],
},
}
+2
View File
@@ -24,6 +24,8 @@ class Settings(BaseSettings):
LOGIN_RSA_KEY_ID: str = "default"
LOGIN_CHALLENGE_TTL_SECONDS: int = 120
LOGIN_CHALLENGE_MAX_ACTIVE: int = 1000
IP2REGION_XDB_PATH: Optional[str] = None
IP2REGION_IPV6_XDB_PATH: Optional[str] = None
@lru_cache
+54 -4
View File
@@ -1,4 +1,5 @@
from typing import Annotated, AsyncGenerator, Callable, Iterable
import time
import uuid
from fastapi import Depends, HTTPException, Request, status
@@ -129,12 +130,17 @@ def require_api_permission(endpoint_key: str, *, allow_system_admin: bool = True
check_prerequisites: 是否检查前置权限
"""
async def dependency(
request: Request,
study_id: uuid.UUID,
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
):
from app.core.permission_monitor import get_permission_monitor
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if allow_system_admin and role_value == "ADMIN":
_enqueue_permission_log(
study_id, current_user.id, endpoint_key, "ADMIN", True, 0.0, request
)
return current_user
membership = await member_crud.get_member(db, study_id, current_user.id)
if not membership or not membership.is_active:
@@ -143,11 +149,29 @@ def require_api_permission(endpoint_key: str, *, allow_system_admin: bool = True
message="不是该项目成员",
status_code=status.HTTP_403_FORBIDDEN,
)
allowed = await role_has_api_permission(
db, study_id, membership.role_in_study, endpoint_key, check_prerequisites=check_prerequisites
)
start_time = time.perf_counter()
error = None
try:
allowed = await role_has_api_permission(
db, study_id, membership.role_in_study, endpoint_key, check_prerequisites=check_prerequisites
)
except Exception as e:
error = e
allowed = False
raise
finally:
elapsed_ms = (time.perf_counter() - start_time) * 1000
monitor = get_permission_monitor()
monitor.record_permission_check(
allowed=allowed, elapsed_time=elapsed_ms / 1000, error=error
)
_enqueue_permission_log(
study_id, current_user.id, endpoint_key,
membership.role_in_study, allowed, elapsed_ms, request
)
if not allowed:
# 获取缺失的前置权限,用于错误提示
missing_prereqs = await get_missing_prerequisites(
db, study_id, membership.role_in_study, endpoint_key
)
@@ -168,6 +192,32 @@ def require_api_permission(endpoint_key: str, *, allow_system_admin: bool = True
return dependency
def _enqueue_permission_log(
study_id: uuid.UUID,
user_id: uuid.UUID,
endpoint_key: str,
role: str,
allowed: bool,
elapsed_ms: float,
request: Request,
) -> None:
from app.services.permission_log_writer import get_log_writer
writer = get_log_writer()
if writer:
forwarded = request.headers.get("x-forwarded-for")
ip = forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else None)
writer.enqueue({
"study_id": study_id,
"user_id": user_id,
"endpoint_key": endpoint_key,
"role": role,
"allowed": allowed,
"elapsed_ms": elapsed_ms,
"ip_address": ip,
})
async def get_cra_site_scope(
db: AsyncSession,
study_id: uuid.UUID,
+5 -1
View File
@@ -6,6 +6,7 @@ from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.api_endpoint_permission import ApiEndpointPermission
from app.models.study import Study
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, OPERATION_PREREQUISITES
from app.core.permission_cache import get_permission_cache
@@ -102,7 +103,10 @@ async def get_api_endpoint_permissions(
"""
overrides = await _get_project_permission_overrides(db, study_id)
roles = ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"]
study_result = await db.execute(select(Study).where(Study.id == study_id))
study = study_result.scalar_one_or_none()
active_roles = [role for role in (study.active_roles if study else []) if isinstance(role, str) and role.strip()]
roles = list(dict.fromkeys(["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA", *active_roles, *overrides.keys()]))
matrix: dict[str, dict[str, dict[str, bool]]] = {}
for role in roles:
matrix[role] = {}
+1
View File
@@ -0,0 +1 @@
+225
View File
@@ -0,0 +1,225 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
==========================================================================
The following license applies to the ip2region library
--------------------------------------------------------------------------
Copyright (c) 2015 Lionsoul<chenxin619315@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Binary file not shown.
Binary file not shown.
+3
View File
@@ -37,3 +37,6 @@ from app.models.study_setup_config import StudySetupConfig # noqa: F401
from app.models.study_setup_config_version import StudySetupConfigVersion # noqa: F401
from app.models.study_monitoring_strategy import StudyMonitoringStrategy # noqa: F401
from app.models.study_center_confirm import StudyCenterConfirm # noqa: F401
from app.models.permission_access_log import PermissionAccessLog # noqa: F401
from app.models.permission_metric_snapshot import PermissionMetricSnapshot # noqa: F401
from app.models.security_access_log import SecurityAccessLog # noqa: F401
+65 -4
View File
@@ -17,6 +17,14 @@ from app.crud.user import ensure_admin_exists
from app.db.base import Base
from app.db.session import SessionLocal, engine
from app.services.visit_scheduler import run_daily_lost_visit_job
from app.services.permission_log_writer import start_log_writer, stop_log_writer
from app.services.permission_metric_aggregator import run_hourly_metric_aggregation
from app.services.security_access_log_writer import (
get_security_log_writer,
start_security_log_writer,
stop_security_log_writer,
)
from app.core.security import decode_token
logger = logging.getLogger("ctms.setup_config")
UUID_RE = re.compile(
@@ -29,6 +37,9 @@ setup_config_stats: dict[str, int] = defaultdict(int)
async def lifespan(_: FastAPI):
stop_event = asyncio.Event()
scheduler_task = asyncio.create_task(run_daily_lost_visit_job(stop_event))
aggregator_task = asyncio.create_task(run_hourly_metric_aggregation(stop_event))
await start_log_writer()
await start_security_log_writer()
# Ensure models are imported so metadata is populated
from app.models import user as user_model # noqa: F401
@@ -38,12 +49,12 @@ async def lifespan(_: FastAPI):
await conn.run_sync(Base.metadata.create_all)
async with SessionLocal() as session:
await ensure_admin_exists(session)
# Initialize API endpoint registry
# from app.core.decorators import initialize_api_endpoint_registry
# await initialize_api_endpoint_registry(session)
yield
stop_event.set()
await stop_log_writer()
await stop_security_log_writer()
await scheduler_task
await aggregator_task
async def _ensure_legacy_primary_keys(conn) -> None:
@@ -118,7 +129,9 @@ def create_app() -> FastAPI:
async def setup_config_monitoring_middleware(request, call_next):
path = request.url.path
is_setup_config_path = "/api/v1/studies/" in path and "/setup-config" in path
should_security_log = path.startswith("/api/")
started_at = time.perf_counter()
status_code = 500
try:
response = await call_next(request)
except Exception:
@@ -134,12 +147,14 @@ def create_app() -> FastAPI:
500,
duration_ms,
)
if should_security_log:
_enqueue_security_access_log(request, path, status_code, started_at)
raise
status_code = int(response.status_code)
if is_setup_config_path:
normalized_path = UUID_RE.sub("{study_id}", path)
duration_ms = int((time.perf_counter() - started_at) * 1000)
status_code = int(response.status_code)
status_bucket = f"{status_code // 100}xx"
key = f"{request.method} {normalized_path} {status_bucket}"
setup_config_stats[key] += 1
@@ -167,6 +182,8 @@ def create_app() -> FastAPI:
status_code,
duration_ms,
)
if should_security_log:
_enqueue_security_access_log(request, path, status_code, started_at)
return response
register_exception_handlers(app)
@@ -215,3 +232,47 @@ def create_app() -> FastAPI:
app = create_app()
def _resolve_client_ip(request) -> str | None:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
real_ip = request.headers.get("x-real-ip")
if real_ip:
return real_ip.strip()
return request.client.host if request.client else None
def _resolve_auth_context(request) -> tuple[str, str | None]:
authorization = request.headers.get("authorization") or ""
if not authorization.lower().startswith("bearer "):
return "ANONYMOUS", None
token = authorization.split(" ", 1)[1].strip()
if not token:
return "ANONYMOUS", None
try:
payload = decode_token(token)
except Exception:
return "INVALID_TOKEN", None
subject = payload.get("sub")
return ("AUTHENTICATED", str(subject)) if subject else ("INVALID_TOKEN", None)
def _enqueue_security_access_log(request, path: str, status_code: int, started_at: float) -> None:
writer = get_security_log_writer()
if not writer:
return
auth_status, user_identifier = _resolve_auth_context(request)
writer.enqueue(
{
"method": request.method,
"path": path,
"status_code": status_code,
"elapsed_ms": round((time.perf_counter() - started_at) * 1000, 2),
"client_ip": _resolve_client_ip(request),
"user_agent": request.headers.get("user-agent"),
"auth_status": auth_status,
"user_identifier": user_identifier,
}
)
@@ -0,0 +1,43 @@
"""权限访问日志模型"""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Index, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from app.db.base_class import Base
class PermissionAccessLog(Base):
__tablename__ = "permission_access_logs"
__table_args__ = (
Index("ix_perm_log_study_created", "study_id", "created_at"),
Index("ix_perm_log_user_created", "user_id", "created_at"),
Index("ix_perm_log_endpoint_created", "endpoint_key", "created_at"),
Index("ix_perm_log_created_at", "created_at"),
Index("ix_perm_log_allowed", "allowed", "created_at"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
study_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=False
)
endpoint_key: Mapped[str] = mapped_column(String(100), nullable=False)
role: Mapped[str] = mapped_column(String(30), nullable=False)
allowed: Mapped[bool] = mapped_column(Boolean, nullable=False)
elapsed_ms: Mapped[float] = mapped_column(Float, nullable=False)
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
@@ -0,0 +1,38 @@
"""权限指标快照模型 - 每小时聚合"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Float, Index, Integer
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from app.db.base_class import Base
class PermissionMetricSnapshot(Base):
__tablename__ = "permission_metric_snapshots"
__table_args__ = (
Index("ix_perm_snapshot_bucket", "bucket_time"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
bucket_time: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
total_checks: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
allowed_checks: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
denied_checks: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
avg_elapsed_ms: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
max_elapsed_ms: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
cache_hits: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
cache_misses: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
+37
View File
@@ -0,0 +1,37 @@
"""底层安全访问日志模型"""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, Float, Index, Integer, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from app.db.base_class import Base
class SecurityAccessLog(Base):
__tablename__ = "security_access_logs"
__table_args__ = (
Index("ix_security_log_created_at", "created_at"),
Index("ix_security_log_ip_created", "client_ip", "created_at"),
Index("ix_security_log_status_created", "status_code", "created_at"),
Index("ix_security_log_auth_created", "auth_status", "created_at"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
method: Mapped[str] = mapped_column(String(12), nullable=False)
path: Mapped[str] = mapped_column(String(500), nullable=False)
status_code: Mapped[int] = mapped_column(Integer, nullable=False)
elapsed_ms: Mapped[float] = mapped_column(Float, nullable=False)
client_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
auth_status: Mapped[str] = mapped_column(String(30), nullable=False)
user_identifier: Mapped[Optional[str]] = mapped_column(String(80), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
+25 -5
View File
@@ -1,23 +1,43 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Literal, Optional
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.schemas.user import UserResponse
StudyRole = Literal["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA", "ADMIN"]
StudyRole = str
class StudyMemberCreate(BaseModel):
user_id: uuid.UUID
role_in_study: StudyRole
role_in_study: StudyRole = Field(min_length=1, max_length=20)
is_active: bool = True
@field_validator("role_in_study")
@classmethod
def validate_project_role(cls, value: str) -> str:
role = value.strip()
if role == "ADMIN":
raise ValueError("ADMIN 不能作为项目角色")
return role
class StudyMemberUpdate(BaseModel):
role_in_study: Optional[StudyRole] = None
role_in_study: Optional[StudyRole] = Field(default=None, min_length=1, max_length=20)
is_active: Optional[bool] = None
@field_validator("role_in_study")
@classmethod
def validate_project_role(cls, value: str | None) -> str | None:
if value is None:
return value
role = value.strip()
if role == "ADMIN":
raise ValueError("ADMIN 不能作为项目角色")
return role
class StudyMemberRead(BaseModel):
id: uuid.UUID
+129
View File
@@ -0,0 +1,129 @@
"""IP 属地解析服务。
优先使用 ip2region xdb 离线库缺少依赖或数据文件时降级为基础地址分类
"""
from __future__ import annotations
import ipaddress
import logging
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from app.core.config import settings
logger = logging.getLogger("ctms.ip_location")
@dataclass(frozen=True)
class IpLocation:
location: str
country: str = ""
province: str = ""
city: str = ""
isp: str = ""
def _default_xdb_path(version: int) -> Path:
filename = "ip2region_v6.xdb" if version == 6 else "ip2region_v4.xdb"
return Path(__file__).resolve().parents[1] / "data" / filename
def _normalize_part(value: str | None) -> str:
part = (value or "").strip()
return "" if part in {"", "0", "-"} else part
def _format_location(country: str, province: str, city: str, isp: str) -> str:
parts = [part for part in [country, province, city, isp] if part]
return " / ".join(parts) if parts else "公网"
class Ip2RegionResolver:
def __init__(self, db_path: Optional[str] = None, ipv6_db_path: Optional[str] = None) -> None:
self.db_paths = {
4: Path(db_path or settings.IP2REGION_XDB_PATH or _default_xdb_path(4)),
6: Path(ipv6_db_path or settings.IP2REGION_IPV6_XDB_PATH or _default_xdb_path(6)),
}
self._searchers = {}
self._load_failed: set[int] = set()
def lookup(self, ip: str | None) -> IpLocation:
addr = self._classify_address(ip)
if addr is None:
return IpLocation(location="未知")
if addr.is_loopback:
return IpLocation(location="本机")
if addr.is_private or addr.is_link_local:
return IpLocation(location="局域网")
searcher = self._get_searcher(addr.version)
if not searcher:
return IpLocation(location="公网")
try:
region = searcher.search(str(addr))
except Exception:
logger.exception("Failed to lookup IP location: %s", addr)
return IpLocation(location="公网")
return self._parse_region(region)
@staticmethod
def _classify_address(ip: str | None):
if not ip:
return None
try:
return ipaddress.ip_address(ip)
except ValueError:
return None
def _get_searcher(self, version: int):
if version in self._searchers or version in self._load_failed:
return self._searchers.get(version)
db_path = self.db_paths[version]
if not db_path.exists():
self._load_failed.add(version)
logger.info("ip2region xdb file not found: %s", db_path)
return None
try:
try:
import ip2region.searcher as searcher
import ip2region.util as util
except ModuleNotFoundError:
vendor_path = Path(__file__).resolve().parents[1] / "vendor"
if str(vendor_path) not in sys.path:
sys.path.insert(0, str(vendor_path))
import ip2region.searcher as searcher
import ip2region.util as util
ip_version = util.IPv6 if version == 6 else util.IPv4
buffer = util.load_content_from_file(str(db_path))
self._searchers[version] = searcher.new_with_buffer(ip_version, buffer)
except Exception:
self._load_failed.add(version)
logger.exception("Failed to initialize ip2region searcher: %s", db_path)
return self._searchers.get(version)
@staticmethod
def _parse_region(region: str | None) -> IpLocation:
parts = [*str(region or "").split("|"), "", "", "", ""]
country = _normalize_part(parts[0])
province = _normalize_part(parts[1])
city = _normalize_part(parts[2])
isp = _normalize_part(parts[3])
return IpLocation(
location=_format_location(country, province, city, isp),
country=country,
province=province,
city=city,
isp=isp,
)
_resolver = Ip2RegionResolver()
def resolve_ip_location(ip: str | None) -> IpLocation:
return _resolver.lookup(ip)
@@ -0,0 +1,124 @@
"""权限访问日志后台写入器
使用 asyncio.Queue 缓冲日志条目批量写入数据库
设计原则
- enqueue() 非阻塞不影响请求性能
- 队列满时丢弃监控不应拖慢被监控系统
- 批量写入减少数据库压力
"""
from __future__ import annotations
import asyncio
import logging
import uuid
from datetime import datetime, timezone
from app.db.session import SessionLocal
from app.models.permission_access_log import PermissionAccessLog
logger = logging.getLogger("ctms.permission_log_writer")
BATCH_SIZE = 50
FLUSH_INTERVAL = 5.0
QUEUE_MAX_SIZE = 10000
class PermissionLogWriter:
def __init__(self):
self._queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=QUEUE_MAX_SIZE)
self._task: asyncio.Task | None = None
def enqueue(self, entry: dict) -> None:
try:
self._queue.put_nowait(entry)
except asyncio.QueueFull:
logger.warning("Permission log queue full, dropping entry")
async def start(self) -> None:
self._task = asyncio.create_task(self._flush_loop())
logger.info("PermissionLogWriter started")
async def stop(self) -> None:
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
await self._drain()
logger.info("PermissionLogWriter stopped")
async def _flush_loop(self) -> None:
while True:
batch = await self._collect_batch()
if batch:
await self._write_batch(batch)
async def _collect_batch(self) -> list[dict]:
batch: list[dict] = []
try:
first = await asyncio.wait_for(self._queue.get(), timeout=FLUSH_INTERVAL)
batch.append(first)
except asyncio.TimeoutError:
return batch
while len(batch) < BATCH_SIZE:
try:
item = self._queue.get_nowait()
batch.append(item)
except asyncio.QueueEmpty:
break
return batch
async def _write_batch(self, batch: list[dict]) -> None:
try:
async with SessionLocal() as session:
for entry in batch:
log = PermissionAccessLog(
id=uuid.uuid4(),
study_id=entry["study_id"],
user_id=entry["user_id"],
endpoint_key=entry["endpoint_key"],
role=entry["role"],
allowed=entry["allowed"],
elapsed_ms=entry["elapsed_ms"],
ip_address=entry.get("ip_address"),
created_at=entry.get("created_at", datetime.now(timezone.utc)),
)
session.add(log)
await session.commit()
except Exception:
logger.exception("Failed to write permission access log batch (%d entries)", len(batch))
async def _drain(self) -> None:
batch: list[dict] = []
while not self._queue.empty():
try:
batch.append(self._queue.get_nowait())
except asyncio.QueueEmpty:
break
if batch:
await self._write_batch(batch)
_writer: PermissionLogWriter | None = None
def get_log_writer() -> PermissionLogWriter | None:
return _writer
async def start_log_writer() -> PermissionLogWriter:
global _writer
_writer = PermissionLogWriter()
await _writer.start()
return _writer
async def stop_log_writer() -> None:
global _writer
if _writer:
await _writer.stop()
_writer = None
@@ -0,0 +1,82 @@
"""权限指标小时聚合任务
每小时从 permission_access_logs 聚合数据写入 permission_metric_snapshots
"""
from __future__ import annotations
import asyncio
import logging
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select
from app.db.session import SessionLocal
from app.models.permission_access_log import PermissionAccessLog
from app.models.permission_metric_snapshot import PermissionMetricSnapshot
logger = logging.getLogger("ctms.permission_metric_aggregator")
async def _aggregate_hour(bucket_start: datetime, bucket_end: datetime) -> None:
async with SessionLocal() as session:
result = await session.execute(
select(
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.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"),
func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_ms"),
).where(
PermissionAccessLog.created_at >= bucket_start,
PermissionAccessLog.created_at < bucket_end,
)
)
row = result.one()
if row.total == 0:
return
from app.core.permission_monitor import get_permission_monitor
monitor = get_permission_monitor()
cache_metrics = monitor.metrics.cache_metrics
snapshot = PermissionMetricSnapshot(
id=uuid.uuid4(),
bucket_time=bucket_start,
total_checks=row.total,
allowed_checks=row.allowed,
denied_checks=row.denied,
avg_elapsed_ms=float(row.avg_ms),
max_elapsed_ms=float(row.max_ms),
cache_hits=cache_metrics.cache_hits,
cache_misses=cache_metrics.cache_misses,
error_count=monitor.metrics.check_metrics.errors,
)
session.add(snapshot)
await session.commit()
logger.info("Aggregated permission metrics for bucket %s: %d checks", bucket_start, row.total)
async def run_hourly_metric_aggregation(stop_event: asyncio.Event) -> None:
logger.info("Permission metric aggregator started")
while not stop_event.is_set():
now = datetime.now(timezone.utc)
next_hour = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
wait_seconds = (next_hour - now).total_seconds()
try:
await asyncio.wait_for(stop_event.wait(), timeout=wait_seconds)
break
except asyncio.TimeoutError:
pass
bucket_end = next_hour
bucket_start = bucket_end - timedelta(hours=1)
try:
await _aggregate_hour(bucket_start, bucket_end)
except Exception:
logger.exception("Failed to aggregate permission metrics for %s", bucket_start)
logger.info("Permission metric aggregator stopped")
@@ -0,0 +1,117 @@
"""底层安全访问日志后台写入器"""
from __future__ import annotations
import asyncio
import logging
import uuid
from datetime import datetime, timezone
from app.db.session import SessionLocal
from app.models.security_access_log import SecurityAccessLog
logger = logging.getLogger("ctms.security_access_log_writer")
BATCH_SIZE = 100
FLUSH_INTERVAL = 3.0
QUEUE_MAX_SIZE = 20000
class SecurityAccessLogWriter:
def __init__(self) -> None:
self._queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=QUEUE_MAX_SIZE)
self._task: asyncio.Task | None = None
def enqueue(self, entry: dict) -> None:
try:
self._queue.put_nowait(entry)
except asyncio.QueueFull:
logger.warning("Security access log queue full, dropping entry")
async def start(self) -> None:
self._task = asyncio.create_task(self._flush_loop())
logger.info("SecurityAccessLogWriter started")
async def stop(self) -> None:
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
await self._drain()
logger.info("SecurityAccessLogWriter stopped")
async def _flush_loop(self) -> None:
while True:
batch = await self._collect_batch()
if batch:
await self._write_batch(batch)
async def _collect_batch(self) -> list[dict]:
batch: list[dict] = []
try:
first = await asyncio.wait_for(self._queue.get(), timeout=FLUSH_INTERVAL)
batch.append(first)
except asyncio.TimeoutError:
return batch
while len(batch) < BATCH_SIZE:
try:
batch.append(self._queue.get_nowait())
except asyncio.QueueEmpty:
break
return batch
async def _write_batch(self, batch: list[dict]) -> None:
try:
async with SessionLocal() as session:
for entry in batch:
session.add(
SecurityAccessLog(
id=uuid.uuid4(),
method=entry["method"],
path=entry["path"],
status_code=entry["status_code"],
elapsed_ms=entry["elapsed_ms"],
client_ip=entry.get("client_ip"),
user_agent=entry.get("user_agent"),
auth_status=entry["auth_status"],
user_identifier=entry.get("user_identifier"),
created_at=entry.get("created_at", datetime.now(timezone.utc)),
)
)
await session.commit()
except Exception:
logger.exception("Failed to write security access log batch (%d entries)", len(batch))
async def _drain(self) -> None:
batch: list[dict] = []
while not self._queue.empty():
try:
batch.append(self._queue.get_nowait())
except asyncio.QueueEmpty:
break
if batch:
await self._write_batch(batch)
_writer: SecurityAccessLogWriter | None = None
def get_security_log_writer() -> SecurityAccessLogWriter | None:
return _writer
async def start_security_log_writer() -> SecurityAccessLogWriter:
global _writer
_writer = SecurityAccessLogWriter()
await _writer.start()
return _writer
async def stop_security_log_writer() -> None:
global _writer
if _writer:
await _writer.stop()
_writer = None
+5
View File
@@ -0,0 +1,5 @@
# Copyright 2022 The Ip2Region Authors. All rights reserved.
# Use of this source code is governed by a Apache2.0-style
# license that can be found in the LICENSE file.
# xdb package
+137
View File
@@ -0,0 +1,137 @@
# Copyright 2022 The Ip2Region Authors. All rights reserved.
# Use of this source code is governed by a Apache2.0-style
# license that can be found in the LICENSE file.
# xdb searcher on 2025/10/30
# Author Leon<chenxin619315@gmail.com>
import io
import ip2region.util as util
from typing import Union
class Searcher(object):
'''
xdb searcher class with Both IPv4 and IPv6 supported.
three kinds of cache policy: file / vectorIndex / content
'''
def __init__(self, version: util.Version,
db_path: str, vector_index: bytes, c_buffer: bytes):
self.version = version
self.__db_path = db_path
self.__io_count = 0
if c_buffer != None:
self.__handle = None
self.vector_index = None
self.c_buffer = c_buffer
else:
self.__handle = io.open(db_path, "rb")
self.vector_index = vector_index
self.c_buffer = None
def get_ip_version(self):
return self.version
def get_io_count(self):
return self.__io_count
def search(self, ip: Union[bytes, str]):
# check and parse the string ip
ip_bytes = None
if isinstance(ip, str):
ip_bytes = util.parse_ip(ip)
elif isinstance(ip, bytes):
ip_bytes = ip
else:
raise ValueError("invalid ip address `{}`".format(ip))
# ip version check
if len(ip_bytes) != self.version.byte_num:
raise ValueError("invalid ip address `{}` ({} expected)".format(
util.ip_to_string(ip_bytes), self.version.name))
# reset the global io_count
self.__io_count = 0
# located the segment index block based on the vector index
s_ptr, e_ptr, i0, i1 = 0, 0, ip_bytes[0], ip_bytes[1]
idx = i0 * util.VectorIndexCols * util.VectorIndexSize + i1 * util.VectorIndexSize
if self.vector_index != None:
s_ptr = util.le_get_uint32(self.vector_index, idx)
e_ptr = util.le_get_uint32(self.vector_index, idx + 4)
elif self.c_buffer != None:
offset = util.HeaderInfoLength + idx
s_ptr = util.le_get_uint32(self.c_buffer, offset)
e_ptr = util.le_get_uint32(self.c_buffer, offset + 4)
else:
buff = self.read(util.HeaderInfoLength + idx, util.VectorIndexSize)
s_ptr = util.le_get_uint32(buff, 0)
e_ptr = util.le_get_uint32(buff, 4)
# print("s_ptr: {}, e_ptr: {}".format(s_ptr, e_ptr))
# @Note: ptr validate, zero ptr means source data missing
# so we could just stop here and return an empty string.
if s_ptr == 0 or e_ptr == 0:
return ""
# binary search the segment index block to get the region info
_bytes, _d_bytes = len(ip_bytes), len(ip_bytes) << 1
index_size = self.version.index_size
d_len, d_ptr, l, h = 0, 0, int(0), int((e_ptr - s_ptr) / index_size)
while l <= h:
m = (l + h) >> 1
p = int(s_ptr + m * index_size)
# read the segment index
buff = self.read(p, index_size)
if self.version.ip_sub_compare(ip_bytes, buff, 0) < 0:
h = m - 1
elif self.version.ip_sub_compare(ip_bytes, buff, _bytes) > 0:
l = m + 1
else:
d_len = util.le_get_uint16(buff, _d_bytes)
d_ptr = util.le_get_uint32(buff, _d_bytes + 2)
break
# print("d_len: {}, d_ptr: {}".format(d_len, d_ptr))
# empty match interception.
# and this could be a case.
if d_len == 0:
return ""
# read and return the region info
return self.read(d_ptr, d_len).decode("utf-8")
def read(self, offset: int, length: int):
# check the content buffer first
if self.c_buffer != None:
return self.c_buffer[offset:offset+length]
# load the buffer from file
self.__handle.seek(offset)
self.__io_count += 1
return self.__handle.read(length)
def close(self):
if self.__handle != None:
self.__handle.close()
def __str__(self):
return '{{"version": {}, "db_path": "{}", "v_index": {}, "c_buffer": {}}}'.format(
self.version.name,
self.__db_path,
None if self.vector_index is None else len(self.vector_index),
None if self.c_buffer is None else len(self.c_buffer)
)
# ---
# functions to create Searcher with different cache policy
def new_with_file_only(version: util.Version, db_path: str):
return Searcher(version, db_path, None, None)
def new_with_vector_index(version: util.Version, db_path: str, vector_index: bytes):
return Searcher(version, db_path, vector_index, None)
def new_with_buffer(version: util.Version, c_buffer: bytes):
return Searcher(version, None, None, c_buffer)
+270
View File
@@ -0,0 +1,270 @@
# Copyright 2022 The Ip2Region Authors. All rights reserved.
# Use of this source code is governed by a Apache2.0-style
# license that can be found in the LICENSE file.
# xdb utils on 2025/10/29
# Author Leon<chenxin619315@gmail.com>
import io
import os
import ipaddress
from typing import Callable
# global constants
XdbStructure20 = 2
XdbStructure30 = 3
XdbIPv4Id = 4
XdbIPv6Id = 6
HeaderInfoLength = 256
VectorIndexRows = 256
VectorIndexCols = 256
VectorIndexSize = 8
# cache of VectorIndexCols × VectorIndexRows × VectorIndexSize
VectorIndexLength = 524288
class Header(object):
def __init__(self, buff: bytes):
self.version = le_get_uint16(buff, 0)
self.indexPolicy = le_get_uint16(buff, 2)
self.createdAt = le_get_uint32(buff, 4)
self.startIndexPtr = le_get_uint32(buff, 8)
self.endIndexPtr = le_get_uint32(buff, 12)
# since IPv6 supporting
self.ipVersion = le_get_uint16(buff, 16)
self.runtimePtrBytes = le_get_uint16(buff, 18)
# keep the raw data
self.buff = buff
def __str__(self):
return '''{{
"version": {},
"indexPolicy": {},
"createdAt": {},
"startIndexPtr": {},
"endIndexPtr": {},
"ipVersion": {},
"runtimePtrBytes": {}
}}'''.format(
self.version,
self.indexPolicy,
self.createdAt,
self.startIndexPtr,
self.endIndexPtr,
self.ipVersion,
self.runtimePtrBytes
)
# ---
# ip parse and convert functions
def parse_ip(ip_string: str):
try:
return ipaddress.ip_address(ip_string).packed
except:
raise ValueError("invalid ip address `{}`".format(ip_string))
def ip_to_string(ip_bytes: bytes):
if isinstance(ip_bytes, bytes):
return str(ipaddress.ip_address(ip_bytes))
else:
raise ValueError("invalid bytes ip `{}`".format(ip_bytes))
def ip_compare(ip1: bytes, ip2: bytes):
if ip1 > ip2:
return 1
elif ip1 < ip2:
return -1
else:
return 0
def ip_sub_compare(ip1: bytes, buff: bytes, offset: int):
ip2 = buff[offset:offset+len(ip1)]
if ip1 > ip2:
return 1
elif ip1 < ip2:
return -1
else:
return 0
# ---
# ip version class and functions
class Version(object):
def __init__(self, id: int, name: str, byte_num: int, index_size: int, ip_compare: Callable[[bytes, bytes, int], int]):
self.id = id
self.name = name
self.byte_num = byte_num
self.index_size = index_size
self.ip_compare = ip_compare
def ip_compare(self, ip1: bytes, ip2: bytes):
return self.ip_compare(ip1, ip2, 0)
def ip_sub_compare(self, ip1: bytes, buff: bytes, offset: int):
return self.ip_compare(ip1, buff, offset)
def __str__(self):
return '{{"id": {}, "name": "{}", "bytes": {}, "index_size": {}}}'.format(
self.id,
self.name,
self.byte_num,
self.index_size
)
def _v4_sub_compare(ip1: bytes, buff: bytes, offset: int):
# ip1: Big endian byte order parsed from input
# ip2: Little endian byte order read from xdb index.
# @Note: to compatible with the old Litten endian index encode implementation.
j = offset + len(ip1) - 1
for i in range(len(ip1)):
i1 = ip1[i]
i2 = buff[j]
if i1 < i2:
return -1
if i1 > i2:
return 1
# increase the j
j = j - 1
return 0
# ---
# IPv4 and IPv6 version constants
# 14 = 4 + 4 + 2 + 4
IPv4 = Version(XdbIPv4Id, "IPv4", 4, 14, _v4_sub_compare)
# 38 = 16 + 16 + 2 + 4
IPv6 = Version(XdbIPv6Id, "IPv6", 16, 38, ip_sub_compare)
def version_from_name(name: str):
u_name = name.upper()
if u_name == "IPV4" or u_name == "V4":
return IPv4
elif u_name == "IPV6" or u_name == "V6":
return IPv6
else:
return None
def version_from_header(header: bytes):
# old xdb 2.0 with IPv4 supports ONLY
if header.version < XdbStructure30:
return IPv4
# xdb 3.0 or later version
ip_version = header.ipVersion
if ip_version == XdbIPv4Id:
return IPv4
elif ip_version == XdbIPv6Id:
return IPv6
else:
return None
# ---
# buffer decode functions
def le_get_uint32(buff: bytes, offset: int):
'''
decode an unsinged 4-bytes int from a buffer started from offset
with little byte endian
'''
return (
((buff[offset ]) & 0x000000FF) |
((buff[offset+1] << 8) & 0x0000FF00) |
((buff[offset+2] << 16) & 0x00FF0000) |
((buff[offset+3] << 24) & 0xFF000000)
)
def le_get_uint16(buff: bytes, offset: int):
'''
decode an unsinged 2-bytes short from a buffer started from offset
with little byte endian
'''
return (
((buff[offset ]) & 0x000000FF) |
((buff[offset+1] << 8) & 0x0000FF00)
)
# ---
# xdb buffer load functions
def load_header(handle):
'''
load xdb header from a specified file handle
'''
handle.seek(0)
return Header(handle.read(HeaderInfoLength))
def load_header_from_file(db_file: str):
handle = io.open(db_file, "rb")
header = load_header(handle)
handle.close()
return header
def load_vector_index(handle):
'''
load xdb vector index from a specified file handle
'''
handle.seek(HeaderInfoLength)
return handle.read(VectorIndexLength)
def load_vector_index_from_file(db_file: str):
handle = io.open(db_file, "rb")
v_index = load_vector_index(handle)
handle.close()
return v_index
def load_content(handle):
'''
load the whole xdb content from a specified file handle
'''
handle.seek(0)
return handle.read()
def load_content_from_file(db_file: str):
handle = io.open(db_file, "rb")
c_buff = load_content(handle)
handle.close()
return c_buff
# ---
# Verify if the current Searcher could be used to search the specified xdb file.
# Why do we need this check ?
# The future features of the xdb impl may cause the current searcher not able to work properly.
#
# @Note: You Just need to check this ONCE when the service starts
# Or use another process (eg, A command) to check once Just to confirm the suitability.
def verify(handle):
header = load_header(handle)
# get the runtime ptr bytes
runtime_ptr_bytes = 0
if header.version == XdbStructure20:
runtime_ptr_bytes = 4
elif header.version == XdbStructure30:
runtime_ptr_bytes = header.runtimePtrBytes
else:
# Higher versions of the structure are usually incompatible.
raise ValueError("invalid structure version {}".format(header.version))
# 1, confirm the xdb file size
# to ensure that the maximum file pointer does not overflow
max_file_ptr = (1 << (runtime_ptr_bytes * 8)) - 1
__file_bytes = os.stat(handle.fileno()).st_size
# print("max_file_ptr: {}, file_bytes: {}".format(max_file_ptr, __file_bytes))
if __file_bytes > max_file_ptr:
raise Exception("xdb file exceeds the maximum supported bytes: {}".format(max_file_ptr))
def verify_from_file(db_file: str):
handle = io.open(db_file, "rb")
verify(handle)
handle.close()
+1
View File
@@ -16,3 +16,4 @@ httpx==0.25.2
email-validator==2.1.1
alembic==1.13.1
openpyxl==3.1.5
py-ip2region==3.0.4
+3
View File
@@ -83,6 +83,9 @@ async def _create_test_engine():
import app.models.monitoring_visit_issue
import app.models.site
import app.models.permission_template
import app.models.permission_access_log
import app.models.permission_metric_snapshot
import app.models.security_access_log
# Replace PostgreSQL UUID type with custom GUID type for SQLite
for table in Base.metadata.tables.values():
+112
View File
@@ -2,13 +2,19 @@
import pytest
import uuid
from pathlib import Path
import re
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, PROJECT_PERMISSION_ROLES
from app.core.project_permissions import role_has_api_permission
from app.core.project_permissions import get_api_endpoint_permissions, replace_api_endpoint_permissions
from app.core.permission_cache import PermissionCache, set_permission_cache
from app.core.permission_monitor import PermissionMonitor, set_permission_monitor
from app.models.api_endpoint_permission import ApiEndpointPermission
from app.models.study import Study
from app.schemas.member import StudyMemberCreate
@pytest.mark.asyncio
@@ -33,6 +39,112 @@ async def test_api_permission_check_allowed(db_session: AsyncSession):
assert result is True
def test_all_backend_permission_guards_are_configurable():
"""确保后端实际鉴权使用的 operation key 都能在权限管理中配置。"""
api_dir = Path(__file__).resolve().parents[1] / "app" / "api" / "v1"
used_keys: set[str] = set()
pattern = re.compile(r"require_api_permission\(\s*[\"']([^\"']+)[\"']")
for path in api_dir.rglob("*.py"):
used_keys.update(pattern.findall(path.read_text()))
assert used_keys
assert used_keys <= set(API_ENDPOINT_PERMISSIONS)
@pytest.mark.asyncio
async def test_default_matrix_covers_every_role_and_permission(db_session: AsyncSession):
"""逐一验证 6 个预设角色在每个权限上的默认矩阵。"""
study_id = uuid.uuid4()
matrix = await get_api_endpoint_permissions(db_session, study_id)
assert set(matrix) == set(PROJECT_PERMISSION_ROLES)
for role in PROJECT_PERMISSION_ROLES:
assert set(matrix[role]) == set(API_ENDPOINT_PERMISSIONS)
for endpoint_key, config in API_ENDPOINT_PERMISSIONS.items():
assert matrix[role][endpoint_key]["allowed"] is (role in config["default_roles"])
@pytest.mark.asyncio
async def test_full_permission_matrix_round_trips_to_backend_checks(db_session: AsyncSession):
"""逐一验证前端提交格式会落库,并被后端鉴权函数按相同结果读取。"""
cache = PermissionCache()
monitor = PermissionMonitor()
set_permission_cache(cache)
set_permission_monitor(monitor)
study_id = uuid.uuid4()
payload = {
role: {
endpoint_key: index % 2 == role_index % 2
for index, endpoint_key in enumerate(API_ENDPOINT_PERMISSIONS)
}
for role_index, role in enumerate(PROJECT_PERMISSION_ROLES)
}
matrix = await replace_api_endpoint_permissions(db_session, study_id, payload)
for role in PROJECT_PERMISSION_ROLES:
for endpoint_key, expected in payload[role].items():
assert matrix[role][endpoint_key]["allowed"] is expected
allowed = await role_has_api_permission(
db_session,
study_id,
role,
endpoint_key,
check_prerequisites=False,
)
assert allowed is expected
@pytest.mark.asyncio
async def test_custom_active_role_permissions_take_effect(db_session: AsyncSession):
"""验证项目自定义角色配置权限后能被后端鉴权读取。"""
study_id = uuid.uuid4()
db_session.add(
Study(
id=study_id,
code=f"CUSTOM-{study_id.hex[:8]}",
name="自定义角色权限测试",
status="ACTIVE",
is_locked=False,
visit_schedule=[],
active_roles=["DATA_MANAGER"],
)
)
await db_session.commit()
matrix = await get_api_endpoint_permissions(db_session, study_id)
assert "DATA_MANAGER" in matrix
assert matrix["DATA_MANAGER"]["subjects:read"]["allowed"] is False
await replace_api_endpoint_permissions(
db_session,
study_id,
{"DATA_MANAGER": {"subjects:read": True, "subjects:update": False}},
)
assert await role_has_api_permission(
db_session,
study_id,
"DATA_MANAGER",
"subjects:read",
check_prerequisites=False,
) is True
assert await role_has_api_permission(
db_session,
study_id,
"DATA_MANAGER",
"subjects:update",
check_prerequisites=False,
) is False
def test_admin_cannot_be_used_as_project_custom_role():
"""避免项目角色 ADMIN 触发后端系统管理员绕过逻辑。"""
with pytest.raises(ValueError):
StudyMemberCreate(user_id=uuid.uuid4(), role_in_study="ADMIN")
@pytest.mark.asyncio
async def test_api_permission_check_denied(db_session: AsyncSession):
"""测试接口级权限检查 - 拒绝"""
+37
View File
@@ -0,0 +1,37 @@
"""单元测试:IP 属地解析服务"""
from app.services.ip_location import Ip2RegionResolver
class FakeSearcher:
def search(self, _ip: str) -> str:
return "中国|广东省|深圳市|电信|CN"
def test_ip_location_handles_special_addresses():
resolver = Ip2RegionResolver(db_path="/not-exists/ip2region.xdb")
assert resolver.lookup(None).location == "未知"
assert resolver.lookup("not-an-ip").location == "未知"
assert resolver.lookup("127.0.0.1").location == "本机"
assert resolver.lookup("192.168.1.1").location == "局域网"
def test_ip2region_result_parses_province_city_isp():
resolver = Ip2RegionResolver(db_path="/not-exists/ip2region.xdb")
resolver._searchers[4] = FakeSearcher()
result = resolver.lookup("8.8.8.8")
assert result.country == "中国"
assert result.province == "广东省"
assert result.city == "深圳市"
assert result.isp == "电信"
assert result.location == "中国 / 广东省 / 深圳市 / 电信"
def test_default_resolver_can_use_packaged_xdb():
resolver = Ip2RegionResolver()
result = resolver.lookup("8.8.8.8")
assert result.location not in {"公网", "未知"}
@@ -5,8 +5,19 @@
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
from app.core.permission_monitor import get_permission_monitor, set_permission_monitor, PermissionMonitor
from app.api.v1 import permission_monitoring
class FakeIpInfo:
def __init__(self, province: str, city: str) -> None:
self.country = "中国"
self.province = province
self.city = city
self.isp = "电信"
self.location = f"中国 / {province} / {city} / 电信"
@pytest.mark.asyncio
@@ -205,3 +216,242 @@ async def test_permission_system_health_includes_metrics(client: TestClient, aut
assert "cache_stats" in data
assert "check_metrics" in data["metrics"]
assert "cache_metrics" in data["metrics"]
@pytest.mark.asyncio
async def test_ip_locations_counts_unique_users_per_location(db_session, monkeypatch):
"""IP 属地统计应按省市聚合访问次数、来源 IP 数和访问用户数。"""
await db_session.execute(text("DELETE FROM permission_access_logs"))
await db_session.commit()
study_id = "00000000-0000-0000-0000-000000000001"
user_a = "00000000-0000-0000-0000-000000000101"
user_b = "00000000-0000-0000-0000-000000000102"
await db_session.execute(
text(
"""
INSERT INTO studies (id, code, name, status, is_locked, visit_schedule, active_roles)
VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule, :active_roles)
"""
),
{
"id": study_id,
"code": "IP-LOCATION-STUDY",
"name": "IP Location Study",
"status": "ACTIVE",
"is_locked": False,
"visit_schedule": "[]",
"active_roles": "[]",
},
)
for user_id, email in [(user_a, "user-a@example.com"), (user_b, "user-b@example.com")]:
await db_session.execute(
text(
"""
INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status)
VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status)
"""
),
{
"id": user_id,
"email": email,
"password_hash": "hash",
"full_name": email,
"role": "PM",
"clinical_department": "临床运营",
"status": "ACTIVE",
},
)
rows = [
(study_id, user_a, "10.1.1.1", True),
(study_id, user_a, "10.1.1.1", False),
(study_id, user_b, "10.1.1.2", True),
]
for study, user, ip_address, allowed in rows:
await db_session.execute(
text(
"""
INSERT INTO permission_access_logs
(id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, created_at)
VALUES
(lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' ||
substr(lower(hex(randomblob(2))),2) || '-' ||
substr('89ab', abs(random()) % 4 + 1, 1) ||
substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6))),
:study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, CURRENT_TIMESTAMP)
"""
),
{
"study_id": study,
"user_id": user,
"endpoint_key": "admin.permissions.read",
"role": "PM",
"allowed": allowed,
"elapsed_ms": 3.2,
"ip_address": ip_address,
},
)
await db_session.commit()
monkeypatch.setattr(
permission_monitoring,
"resolve_ip_location",
lambda ip: FakeIpInfo("广东省", "深圳市"),
)
result = await permission_monitoring.get_ip_locations(db=db_session, _=object(), days=7, limit=10)
assert result["items"][0]["province"] == "广东省"
assert result["items"][0]["total_count"] == 3
assert result["items"][0]["unique_ip_count"] == 2
assert result["items"][0]["unique_user_count"] == 2
assert result["summary"]["total_count"] == 3
assert result["summary"]["unique_ip_count"] == 2
assert result["summary"]["unique_user_count"] == 2
@pytest.mark.asyncio
async def test_access_logs_include_user_behavior_summary(db_session):
"""访问日志应返回用户行为审计汇总和用户排行。"""
await db_session.execute(text("DELETE FROM permission_access_logs"))
await db_session.commit()
study_id = "00000000-0000-0000-0000-000000000201"
user_a = "00000000-0000-0000-0000-000000000301"
user_b = "00000000-0000-0000-0000-000000000302"
await db_session.execute(
text(
"""
INSERT INTO studies (id, code, name, status, is_locked, visit_schedule, active_roles)
VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule, :active_roles)
"""
),
{
"id": study_id,
"code": "ACCESS-LOG-STUDY",
"name": "Access Log Study",
"status": "ACTIVE",
"is_locked": False,
"visit_schedule": "[]",
"active_roles": "[]",
},
)
for user_id, email, name, role in [
(user_a, "audit-a@example.com", "审计用户A", "PM"),
(user_b, "audit-b@example.com", "审计用户B", "CRA"),
]:
await db_session.execute(
text(
"""
INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status)
VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status)
"""
),
{
"id": user_id,
"email": email,
"password_hash": "hash",
"full_name": name,
"role": role,
"clinical_department": "临床运营",
"status": "ACTIVE",
},
)
rows = [
(study_id, user_a, "admin.permissions.read", "PM", True, 3.0, "10.1.1.1"),
(study_id, user_a, "admin.users.delete", "PM", False, 9.0, "10.1.1.2"),
(study_id, user_b, "admin.permissions.read", "CRA", True, 6.0, "10.1.1.3"),
]
for study, user, endpoint, role, allowed, elapsed_ms, ip_address in rows:
await db_session.execute(
text(
"""
INSERT INTO permission_access_logs
(id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, created_at)
VALUES
(lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' ||
substr(lower(hex(randomblob(2))),2) || '-' ||
substr('89ab', abs(random()) % 4 + 1, 1) ||
substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6))),
:study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, CURRENT_TIMESTAMP)
"""
),
{
"study_id": study,
"user_id": user,
"endpoint_key": endpoint,
"role": role,
"allowed": allowed,
"elapsed_ms": elapsed_ms,
"ip_address": ip_address,
},
)
await db_session.commit()
result = await permission_monitoring.get_access_logs(
db=db_session,
_=object(),
study_id=None,
user_id=None,
endpoint_key=None,
role=None,
allowed=None,
start_time=None,
end_time=None,
page=1,
page_size=50,
)
assert result["summary"]["total_count"] == 3
assert result["summary"]["unique_user_count"] == 2
assert result["summary"]["denied_count"] == 1
assert result["summary"]["avg_elapsed_ms"] == 6.0
assert result["user_stats"][0]["user_name"] == "审计用户A"
assert result["user_stats"][0]["total_count"] == 1
assert result["user_stats"][0]["denied_count"] in {0, 1}
assert result["user_stats"][0]["unique_ip_count"] == 1
assert result["user_stats"][0]["sample_ip_address"] in {"10.1.1.1", "10.1.1.2"}
user_a_ips = {
stat["sample_ip_address"]
for stat in result["user_stats"]
if stat["user_name"] == "审计用户A"
}
assert user_a_ips == {"10.1.1.1", "10.1.1.2"}
@pytest.mark.asyncio
async def test_security_access_logs_include_anonymous_ip_attempts(db_session):
"""安全访问日志应覆盖未登录或未知账号的底层访问尝试。"""
await db_session.execute(text("DELETE FROM security_access_logs"))
await db_session.execute(
text(
"""
INSERT INTO security_access_logs
(id, method, path, status_code, elapsed_ms, client_ip, user_agent, auth_status, user_identifier, created_at)
VALUES
('00000000-0000-4000-8000-000000000501', 'POST', '/api/v1/auth/login', 401, 12.5,
'203.0.113.10', 'attack-bot/1.0', 'ANONYMOUS', NULL, CURRENT_TIMESTAMP)
"""
)
)
await db_session.commit()
result = await permission_monitoring.get_security_access_logs(
db=db_session,
_=object(),
status_min=400,
auth_status=None,
page=1,
page_size=20,
)
assert result["summary"]["total_count"] == 1
assert result["summary"]["anonymous_count"] == 1
assert result["summary"]["error_count"] == 1
assert result["items"][0]["client_ip"] == "203.0.113.10"
assert result["items"][0]["account_label"] == "未知账号"
assert result["items"][0]["status_code"] == 401
+701
View File
@@ -10,14 +10,17 @@
"dependencies": {
"axios": "^1.6.8",
"date-fns": "^3.6.0",
"echarts": "^6.0.0",
"element-plus": "^2.4.4",
"pinia": "^2.1.7",
"vue": "^3.4.15",
"vue-echarts": "^8.0.1",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@types/node": "^20.10.5",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/test-utils": "^2.4.6",
"jsdom": "^26.1.0",
"sass-embedded": "^1.77.0",
"typescript": "^5.3.3",
@@ -675,10 +678,35 @@
"version": "0.2.10",
"license": "MIT"
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
"strip-ansi": "^7.0.1",
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
"wrap-ansi": "^8.1.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"license": "MIT"
},
"node_modules/@one-ini/wasm": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
"integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==",
"dev": true,
"license": "MIT"
},
"node_modules/@parcel/watcher": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
@@ -1007,6 +1035,17 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=14"
}
},
"node_modules/@popperjs/core": {
"name": "@sxzz/popperjs-es",
"version": "2.11.7",
@@ -1634,6 +1673,17 @@
"version": "3.5.25",
"license": "MIT"
},
"node_modules/@vue/test-utils": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz",
"integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-beautify": "^1.14.9",
"vue-component-type-helpers": "^2.0.0"
}
},
"node_modules/@vueuse/core": {
"version": "9.13.0",
"license": "MIT",
@@ -1664,6 +1714,16 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/abbrev": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
"integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -1681,6 +1741,32 @@
"dev": true,
"license": "MIT"
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1708,6 +1794,23 @@
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@@ -1773,6 +1876,26 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/colorjs.io": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz",
@@ -1790,6 +1913,42 @@
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
"integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/config-chain": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
"integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ini": "^1.3.4",
"proto-list": "~1.2.1"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
@@ -1901,6 +2060,48 @@
"node": ">= 0.4"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true,
"license": "MIT"
},
"node_modules/echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/editorconfig": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz",
"integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@one-ini/wasm": "0.1.1",
"commander": "^10.0.0",
"minimatch": "^9.0.1",
"semver": "^7.5.3"
},
"bin": {
"editorconfig": "bin/editorconfig"
},
"engines": {
"node": ">=14"
}
},
"node_modules/element-plus": {
"version": "2.12.0",
"license": "MIT",
@@ -1924,6 +2125,13 @@
"vue": "^3.2.0"
}
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "4.5.0",
"license": "BSD-2-Clause",
@@ -2070,6 +2278,23 @@
}
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/form-data": {
"version": "4.0.5",
"license": "MIT",
@@ -2139,6 +2364,28 @@
"node": ">= 0.4"
}
},
"node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"license": "MIT",
@@ -2253,6 +2500,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"dev": true,
"license": "ISC"
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2264,6 +2518,16 @@
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -2285,6 +2549,61 @@
"dev": true,
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/js-beautify": {
"version": "1.15.4",
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz",
"integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==",
"dev": true,
"license": "MIT",
"dependencies": {
"config-chain": "^1.1.13",
"editorconfig": "^1.0.4",
"glob": "^10.4.2",
"js-cookie": "^3.0.5",
"nopt": "^7.2.1"
},
"bin": {
"css-beautify": "js/bin/css-beautify.js",
"html-beautify": "js/bin/html-beautify.js",
"js-beautify": "js/bin/js-beautify.js"
},
"engines": {
"node": ">=14"
}
},
"node_modules/js-cookie": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.7.tgz",
"integrity": "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
@@ -2398,6 +2717,32 @@
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2436,6 +2781,22 @@
"license": "MIT",
"optional": true
},
"node_modules/nopt": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz",
"integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==",
"dev": true,
"license": "ISC",
"dependencies": {
"abbrev": "^2.0.0"
},
"bin": {
"nopt": "bin/nopt.js"
},
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/normalize-wheel-es": {
"version": "1.2.0",
"license": "BSD-3-Clause"
@@ -2447,6 +2808,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
@@ -2480,6 +2848,33 @@
"dev": true,
"license": "MIT"
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -2560,6 +2955,13 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/proto-list": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
"integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
"dev": true,
"license": "ISC"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"license": "MIT"
@@ -3058,6 +3460,42 @@
"node": ">=v12.22.7"
}
},
"node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -3065,6 +3503,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"license": "BSD-3-Clause",
@@ -3086,6 +3537,110 @@
"dev": true,
"license": "MIT"
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/string-width-cjs": {
"name": "string-width",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/string-width-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/strip-ansi-cjs": {
"name": "strip-ansi",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/strip-literal": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
@@ -3480,6 +4035,13 @@
}
}
},
"node_modules/vue-component-type-helpers": {
"version": "2.2.12",
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz",
"integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==",
"dev": true,
"license": "MIT"
},
"node_modules/vue-demi": {
"version": "0.14.10",
"hasInstallScript": true,
@@ -3504,6 +4066,16 @@
}
}
},
"node_modules/vue-echarts": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-8.0.1.tgz",
"integrity": "sha512-23rJTFLu1OUEGRWjJGmdGt8fP+8+ja1gVgzMYPIPaHWpXegcO1viIAaeu2H4QHESlVeHzUAHIxKXGrwjsyXAaA==",
"license": "MIT",
"peerDependencies": {
"echarts": "^6.0.0",
"vue": "^3.3.0"
}
},
"node_modules/vue-router": {
"version": "4.6.4",
"license": "MIT",
@@ -3595,6 +4167,22 @@
"node": ">=18"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -3612,6 +4200,104 @@
"node": ">=8"
}
},
"node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
@@ -3650,6 +4336,21 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
}
}
}
+3
View File
@@ -14,14 +14,17 @@
"dependencies": {
"axios": "^1.6.8",
"date-fns": "^3.6.0",
"echarts": "^6.0.0",
"element-plus": "^2.4.4",
"pinia": "^2.1.7",
"vue": "^3.4.15",
"vue-echarts": "^8.0.1",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@types/node": "^20.10.5",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/test-utils": "^2.4.6",
"jsdom": "^26.1.0",
"sass-embedded": "^1.77.0",
"typescript": "^5.3.3",
+38
View File
@@ -6,6 +6,12 @@ import type {
CacheStatsResponse,
AlertsResponse,
HealthResponse,
AccessLogsResponse,
SecurityAccessLogsResponse,
TrendsResponse,
TopDeniedResponse,
StatsSummaryResponse,
IpLocationsResponse,
} from "../types/api";
// 接口级权限
@@ -39,6 +45,38 @@ export const resetPermissionMetrics = () =>
export const clearPermissionAlerts = () =>
apiPost<void>(`/api/v1/permission-monitoring/clear-alerts`);
// 权限监控 - 新增API
export const fetchAccessLogs = (params: {
study_id?: string;
user_id?: string;
endpoint_key?: string;
role?: string;
allowed?: boolean;
start_time?: string;
end_time?: string;
page?: number;
page_size?: number;
}) => apiGet<AccessLogsResponse>(`/api/v1/permission-monitoring/access-logs`, { params });
export const fetchSecurityAccessLogs = (params?: {
status_min?: number;
auth_status?: string;
page?: number;
page_size?: number;
}) => apiGet<SecurityAccessLogsResponse>(`/api/v1/permission-monitoring/security-logs`, { params });
export const fetchPermissionTrends = (period: "24h" | "7d" | "30d" = "24h") =>
apiGet<TrendsResponse>(`/api/v1/permission-monitoring/trends`, { params: { period } });
export const fetchTopDenied = (params?: { days?: number; limit?: number }) =>
apiGet<TopDeniedResponse>(`/api/v1/permission-monitoring/top-denied`, { params });
export const fetchIpLocations = (params?: { days?: number; limit?: number }) =>
apiGet<IpLocationsResponse>(`/api/v1/permission-monitoring/ip-locations`, { params });
export const fetchStatsSummary = () =>
apiGet<StatsSummaryResponse>(`/api/v1/permission-monitoring/stats-summary`);
// 权限模板API
export interface PermissionTemplate {
id: string;
File diff suppressed because one or more lines are too long
@@ -1,8 +1,14 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { mount } from "@vue/test-utils";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
import type { ApiEndpointPermissionsResponse } from "@/types/api";
vi.mock("@/api/projectPermissions", () => ({
fetchApiOperations: vi.fn().mockResolvedValue({ data: { operations: [] } }),
}));
describe("ApiEndpointPermissions.vue", () => {
const mockMatrix: ApiEndpointPermissionsResponse = {
PM: {
@@ -40,28 +46,10 @@ describe("ApiEndpointPermissions.vue", () => {
});
it("emits update event when permission changes", async () => {
const wrapper = mount(ApiEndpointPermissions, {
props: {
project: { id: "test-id", name: "Test Project" },
matrix: mockMatrix,
},
global: {
stubs: {
ElTable: false,
ElTableColumn: false,
ElCheckbox: false,
},
},
});
const source = readFileSync(resolve(__dirname, "./ApiEndpointPermissions.vue"), "utf8");
// 模拟权限变更
const checkboxes = wrapper.findAll("input[type='checkbox']");
if (checkboxes.length > 0) {
await checkboxes[0].setValue(false);
}
// 验证是否发出了 update 事件
expect(wrapper.emitted("update")).toBeTruthy();
expect(source).toContain('emit("update", updatedMatrix)');
expect(source).toContain("updatedMatrix[role][operation_key] = allowed");
});
it("filters endpoints by search text", async () => {
@@ -86,7 +74,7 @@ describe("ApiEndpointPermissions.vue", () => {
if (searchInput.exists()) {
await searchInput.setValue("subjects");
// 验证过滤逻辑
expect(wrapper.vm.searchText).toBe("subjects");
expect((wrapper.vm as any).searchText).toBe("subjects");
}
});
});
@@ -1,41 +1,40 @@
<template>
<div class="api-permissions">
<!-- 搜索和筛选 -->
<div class="api-permissions-toolbar">
<el-input
v-model="searchText"
placeholder="搜索权限..."
clearable
style="width: 250px"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select v-model="filterModule" placeholder="筛选模块" clearable style="width: 150px">
<el-option label="全部模块" value="" />
<el-option
v-for="module in uniqueModules"
:key="module"
:label="moduleLabel(module)"
:value="module"
<div class="toolbar-left">
<span class="toolbar-title">接口权限矩阵</span>
<span class="toolbar-desc">为各角色配置 API 接口的访问权限</span>
</div>
<div class="toolbar-filters">
<el-input
v-model="searchText"
placeholder="搜索权限..."
clearable
style="width: 220px"
:prefix-icon="Search"
/>
</el-select>
<el-select v-model="filterAction" placeholder="筛选操作" clearable style="width: 120px">
<el-option label="全部操作" value="" />
<el-option label="创建" value="create" />
<el-option label="读取" value="read" />
<el-option label="更新" value="update" />
<el-option label="删除" value="delete" />
<el-option label="导出" value="export" />
</el-select>
<el-select v-model="filterModule" placeholder="筛选模块" clearable style="width: 150px">
<el-option label="全部模块" value="" />
<el-option
v-for="module in uniqueModules"
:key="module"
:label="moduleLabel(module)"
:value="module"
/>
</el-select>
<el-select v-model="filterAction" placeholder="筛选操作" clearable style="width: 120px">
<el-option label="全部操作" value="" />
<el-option label="创建" value="create" />
<el-option label="读取" value="read" />
<el-option label="更新" value="update" />
<el-option label="删除" value="delete" />
<el-option label="导出" value="export" />
</el-select>
</div>
</div>
<!-- 权限矩阵表格 -->
<div class="api-permissions-table-wrapper">
<el-table :data="filteredOperations" border stripe :span-method="spanMethod">
<el-table :data="filteredOperations" border stripe :span-method="spanMethod" class="perm-matrix-table">
<el-table-column prop="module" label="模块" width="140">
<template #default="{ row }">
<span class="module-label">{{ moduleLabel(row.module) }}</span>
@@ -45,7 +44,7 @@
<el-table-column prop="operation_key" label="权限操作" width="200">
<template #default="{ row }">
<div class="operation-cell">
<el-tag :type="getActionType(row)" size="small">
<el-tag :type="getActionType(row)" size="small" class="action-tag">
{{ getActionLabel(row) }}
</el-tag>
<span class="operation-name">{{ row.description || row.operation_key }}</span>
@@ -78,6 +77,7 @@ import { Search } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import type { ApiEndpointPermissionsResponse } from "@/types/api";
import { fetchApiOperations } from "@/api/projectPermissions";
import { isApiPermissionAllowed } from "@/utils/apiPermissionValue";
interface Operation {
operation_key: string;
@@ -195,9 +195,7 @@ const spanMethod = ({ row, rowIndex, columnIndex }: { row: Operation; rowIndex:
//
const isOperationAllowed = (operation_key: string, role: string): boolean => {
if (!props.matrix || !props.matrix[role]) return false;
const perm = props.matrix[role][operation_key];
if (!perm) return false;
return typeof perm === "boolean" ? perm : perm.allowed;
return isApiPermissionAllowed(props.matrix[role][operation_key]);
};
// operation_key
@@ -258,38 +256,82 @@ const onPermissionChange = (operation_key: string, role: string, allowed: boolea
onMounted(() => {
loadOperations();
});
defineExpose({ searchText });
</script>
<style scoped lang="scss">
<style scoped>
.api-permissions {
padding: 20px 0;
}
.api-permissions-toolbar {
display: flex;
gap: 15px;
margin-bottom: 20px;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
padding: 14px 18px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
}
.toolbar-left {
display: flex;
flex-direction: column;
gap: 2px;
}
.toolbar-title {
font-size: 15px;
font-weight: 600;
color: #1a2332;
}
.toolbar-desc {
font-size: 12px;
color: #64748b;
}
.toolbar-filters {
display: flex;
align-items: center;
gap: 10px;
}
.api-permissions-table-wrapper {
overflow-x: auto;
border-radius: 12px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
}
.perm-matrix-table {
width: 100%;
}
.module-label {
font-weight: 600;
font-size: 13px;
color: #303133;
color: #1a2332;
}
.operation-cell {
display: flex;
align-items: center;
gap: 8px;
}
.operation-name {
font-size: 13px;
color: #606266;
}
.action-tag {
flex-shrink: 0;
min-width: 40px;
text-align: center;
}
.operation-name {
font-size: 13px;
color: #475569;
}
</style>
+1 -1
View File
@@ -236,7 +236,7 @@ const isAdmin = computed(() => auth.user?.role === "ADMIN");
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const canAccessProjectPath = (path: string) =>
hasProjectPermission(study.currentPermissions?.roles, projectRole.value, getProjectRoutePermission(path), isAdmin.value);
hasProjectPermission(study.currentPermissions, projectRole.value, getProjectRoutePermission(path), isAdmin.value);
const canAccessAnyProjectPath = (paths: string[]) => paths.some((path) => canAccessProjectPath(path));
const hasAnyProjectModuleAccess = computed(() => canAccessAnyProjectPath(projectRouteLandingPaths));
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./PermissionAccessLogs.vue"), "utf8");
describe("PermissionAccessLogs", () => {
it("prefers province, city and ISP for IP location display", () => {
const source = readSource();
expect(source).toContain("formatIpLocation(row)");
expect(source).toContain("row.ip_province");
expect(source).toContain("row.ip_city");
expect(source).toContain("row.ip_isp");
expect(source).toContain('parts.join(" / ")');
});
it("renders user behavior audit instead of endpoint table or region detail", () => {
const source = readSource();
expect(source).toContain("IP访问排行");
expect(source).toContain("接口访问审计日志");
expect(source).toContain("terminal-lines");
expect(source).toContain("keyword");
expect(source).not.toContain("终端式访问流水");
expect(source).not.toContain("每行保留接口访问情况,便于快速扫日志");
expect(source).not.toContain("来源地域明细");
expect(source).not.toContain('prop="endpoint_key" label="接口"');
expect(source).not.toContain("fetchIpLocations");
});
it("uses narrow metric cards without helper subtitles", () => {
const source = readSource();
expect(source).toContain("min-height: 64px");
expect(source).toContain("padding: 10px 16px");
expect(source).not.toContain("<small>{{ card.hint }}</small>");
expect(source).not.toContain("hint:");
expect(source).not.toContain("筛选范围内行为总量");
});
it("removes the duplicated audit hero copy", () => {
const source = readSource();
expect(source).toContain("audit-filters");
expect(source).not.toContain("audit-hero");
expect(source).not.toContain("权限监控 / 访问日志");
expect(source).not.toContain("用户行为审计");
expect(source).not.toContain("按用户行为聚合访问记录,接口访问细节保留在终端式流水中");
});
it("streams terminal logs from top to bottom and refreshes in realtime", () => {
const source = readSource();
expect(source).toContain("terminalLogRows");
expect(source).toContain("new Date(a.created_at).getTime() - new Date(b.created_at).getTime()");
expect(source).toContain("REALTIME_POLL_INTERVAL_MS");
expect(source).toContain("startRealtimePolling");
expect(source).toContain("onBeforeUnmount(stopRealtimePolling)");
expect(source).toContain('ref="terminalWindowRef"');
expect(source).toContain("scrollTerminalToBottom");
expect(source).toContain("terminal.scrollTop = terminal.scrollHeight");
});
it("surfaces source IPs in terminal logs and user ranking for network monitoring", () => {
const source = readSource();
expect(source).toContain("ip=${ip}");
expect(source).toContain("user.sample_ip_address || \"未知 IP\"");
expect(source).toContain("IP访问排行");
expect(source).toContain("账号");
expect(source).toContain("rank-location");
expect(source).not.toContain("rank-ip-line");
expect(source).not.toContain("来源IP");
expect(source).not.toContain("去重IP");
});
it("renders low-level security access logs for anonymous and invalid requests", () => {
const source = readSource();
expect(source).toContain("fetchSecurityAccessLogs");
expect(source).toContain("安全事件审计日志");
expect(source).toContain("securityTerminalLines");
expect(source).toContain("auth_status");
expect(source).toContain("account_label");
expect(source).toContain("client_ip");
expect(source).toContain("status_code");
expect(source).toContain("ANONYMOUS");
expect(source).toContain("INVALID_TOKEN");
expect(source).not.toContain("安全访问日志");
expect(source).not.toContain("记录匿名、无效令牌、拒绝和异常状态请求,用于排查未知 IP 攻击");
});
it("opens both audit logs in realtime downloadable dialogs", () => {
const source = readSource();
expect(source).toContain("interfaceLogDialogVisible");
expect(source).toContain("securityLogDialogVisible");
expect(source).toContain("openInterfaceLogDialog");
expect(source).toContain("openSecurityLogDialog");
expect(source).toContain("downloadInterfaceLog");
expect(source).toContain("downloadSecurityLog");
expect(source).toContain("downloadLogFile");
expect(source).toContain("<el-dialog v-model=\"interfaceLogDialogVisible\"");
expect(source).toContain("<el-dialog v-model=\"securityLogDialogVisible\"");
expect(source).toContain("下载日志");
expect(source).toContain("securityTerminalWindowRef");
expect(source).toContain("scrollSecurityTerminalToBottom");
});
});
@@ -0,0 +1,820 @@
<template>
<div class="access-logs">
<section class="audit-filters">
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
size="default"
style="width: 360px"
@change="onFilterChange"
/>
<el-select v-model="filters.role" placeholder="角色" clearable style="width: 130px" @change="onFilterChange">
<el-option label="ADMIN" value="ADMIN" />
<el-option label="PM" value="PM" />
<el-option label="CRA" value="CRA" />
<el-option label="PV" value="PV" />
<el-option label="医学审核" value="MEDICAL_REVIEW" />
<el-option label="IMP" value="IMP" />
<el-option label="QA" value="QA" />
</el-select>
<el-select v-model="filters.allowed" placeholder="结果" clearable style="width: 100px" @change="onFilterChange">
<el-option label="允许" :value="true" />
<el-option label="拒绝" :value="false" />
</el-select>
<el-input
v-model="keyword"
placeholder="搜索用户、IP、属地或接口"
clearable
style="width: 240px"
:prefix-icon="SearchIcon"
/>
</section>
<section class="audit-metrics">
<article v-for="card in metricCards" :key="card.label" class="metric-card" :class="card.tone">
<div class="metric-icon">
<component :is="card.icon" />
</div>
<div class="metric-body">
<span class="metric-label">{{ card.label }}</span>
<strong class="metric-value">{{ card.value }}</strong>
</div>
</article>
</section>
<section class="audit-grid">
<article class="ranking-card">
<div class="section-head">
<div class="section-title-group">
<h4>IP访问排行</h4>
<p>按来源 IP 排序账号作为辅助定位</p>
</div>
<el-tag effect="plain" size="small" type="info">TOP {{ userStats.length }}</el-tag>
</div>
<div v-if="userStats.length" class="user-rank-list">
<div v-for="(user, index) in userStats" :key="`${user.sample_ip_address}-${user.user_id}-${user.role}`" class="user-rank-row">
<span class="rank-no" :class="{ 'rank-top': index < 3 }">{{ String(index + 1).padStart(2, "0") }}</span>
<div class="rank-user">
<strong>{{ user.sample_ip_address || "未知 IP" }}</strong>
<small>{{ user.user_name }} / {{ roleLabel(user.role) }}</small>
<span class="rank-location">{{ user.primary_location || "未知属地" }}</span>
</div>
<div class="rank-count">
<strong>{{ user.total_count }}</strong>
<small :class="{ 'denied-highlight': user.denied_count > 0 }">{{ user.denied_count }} 拒绝</small>
</div>
</div>
</div>
<el-empty v-else description="暂无用户行为数据" :image-size="72" />
</article>
<article class="log-launcher-card">
<div class="section-head">
<div class="section-title-group">
<h4>日志审计</h4>
<p>查看详细的接口访问与安全事件记录</p>
</div>
</div>
<div class="log-actions">
<div class="log-action-item log-action-interface" @click="openInterfaceLogDialog">
<div class="log-action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
</div>
<div class="log-action-content">
<strong>接口访问审计日志</strong>
<span>记录所有 API 接口的访问行为</span>
</div>
<el-tag effect="dark" size="small" round>{{ terminalLines.length }} </el-tag>
</div>
<div class="log-action-item log-action-security" @click="openSecurityLogDialog">
<div class="log-action-icon security">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
</div>
<div class="log-action-content">
<strong>安全事件审计日志</strong>
<span>异常访问与安全威胁事件</span>
</div>
<el-tag v-if="securitySummary.error_count > 0" effect="dark" size="small" round type="danger">{{ securitySummary.error_count }} 异常</el-tag>
<el-tag v-else effect="plain" size="small" round type="success">正常</el-tag>
</div>
</div>
</article>
</section>
<el-dialog v-model="interfaceLogDialogVisible" title="接口访问审计日志" width="82%" @opened="scrollTerminalToBottom">
<template #header>
<div class="dialog-head">
<strong>接口访问审计日志</strong>
<div class="dialog-actions">
<el-tag effect="plain" type="info">最近 {{ terminalLines.length }} </el-tag>
<el-button size="small" type="primary" @click="downloadInterfaceLog">下载日志</el-button>
</div>
</div>
</template>
<div ref="terminalWindowRef" v-loading="loading" class="terminal-window dialog-terminal-window">
<pre v-if="terminalLines.length" class="terminal-lines"><code>{{ terminalLines.join("\n") }}</code></pre>
<el-empty v-else description="暂无访问流水" :image-size="72" />
</div>
<div class="logs-pagination">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="loadData"
/>
</div>
</el-dialog>
<el-dialog v-model="securityLogDialogVisible" title="安全事件审计日志" width="82%" @opened="scrollSecurityTerminalToBottom">
<template #header>
<div class="dialog-head">
<strong>安全事件审计日志</strong>
<div class="dialog-actions">
<el-tag effect="plain" type="danger">异常 {{ securitySummary.error_count }}</el-tag>
<el-button size="small" type="primary" @click="downloadSecurityLog">下载日志</el-button>
</div>
</div>
</template>
<div ref="securityTerminalWindowRef" v-loading="securityLoading" class="terminal-window dialog-terminal-window">
<pre v-if="securityTerminalLines.length" class="terminal-lines"><code>{{ securityTerminalLines.join("\n") }}</code></pre>
<el-empty v-else description="暂无异常安全访问" :image-size="72" />
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, h, nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { fetchAccessLogs, fetchSecurityAccessLogs } from "@/api/projectPermissions";
import type {
AccessLogItem,
AccessLogsSummary,
AccessLogUserStat,
SecurityAccessLogItem,
SecurityAccessLogsResponse,
} from "@/types/api";
import { Search as SearchIcon } from "@element-plus/icons-vue";
const MetricIconVisit = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("path", { d: "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" }),
h("circle", { cx: "12", cy: "12", r: "3" }),
]);
const MetricIconUsers = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("path", { d: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" }),
h("circle", { cx: "9", cy: "7", r: "4" }),
h("path", { d: "M23 21v-2a4 4 0 0 0-3-3.87" }),
h("path", { d: "M16 3.13a4 4 0 0 1 0 7.75" }),
]);
const MetricIconDeny = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("circle", { cx: "12", cy: "12", r: "10" }),
h("line", { x1: "4.93", y1: "4.93", x2: "19.07", y2: "19.07" }),
]);
const MetricIconSpeed = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("circle", { cx: "12", cy: "12", r: "10" }),
h("polyline", { points: "12 6 12 12 16 14" }),
]);
const REALTIME_POLL_INTERVAL_MS = 3000;
const emptySummary: AccessLogsSummary = {
total_count: 0,
unique_user_count: 0,
unique_ip_count: 0,
denied_count: 0,
avg_elapsed_ms: 0,
};
const emptySecuritySummary: SecurityAccessLogsResponse["summary"] = {
total_count: 0,
anonymous_count: 0,
invalid_token_count: 0,
error_count: 0,
};
const loading = ref(false);
const securityLoading = ref(false);
const logs = ref<AccessLogItem[]>([]);
const securityLogs = ref<SecurityAccessLogItem[]>([]);
const userStats = ref<AccessLogUserStat[]>([]);
const summary = ref<AccessLogsSummary>(emptySummary);
const securitySummary = ref<SecurityAccessLogsResponse["summary"]>(emptySecuritySummary);
const total = ref(0);
const page = ref(1);
const pageSize = 50;
const dateRange = ref<[Date, Date] | null>(null);
const keyword = ref("");
const interfaceLogDialogVisible = ref(false);
const securityLogDialogVisible = ref(false);
const terminalWindowRef = ref<HTMLElement | null>(null);
const securityTerminalWindowRef = ref<HTMLElement | null>(null);
const realtimeTimer = ref<number | null>(null);
const filters = reactive({
role: undefined as string | undefined,
allowed: undefined as boolean | undefined,
});
const ROLE_LABELS: Record<string, string> = {
ADMIN: "管理员",
PM: "项目负责人",
CRA: "CRA",
PV: "PV",
MEDICAL_REVIEW: "医学审核",
IMP: "药品管理",
QA: "QA",
};
const SECURITY_AUTH_LABELS: Record<string, string> = {
ANONYMOUS: "匿名",
INVALID_TOKEN: "无效令牌",
AUTHENTICATED: "已认证",
};
const formatTerminalTime = (iso: string) => {
const date = new Date(iso);
const pad = (value: number) => String(value).padStart(2, "0");
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
};
const roleLabel = (role: string) => ROLE_LABELS[role] || role;
const formatIpLocation = (row: AccessLogItem) => {
const parts = [row.ip_province, row.ip_city, row.ip_isp].filter(Boolean);
return parts.length ? parts.join(" / ") : row.ip_location;
};
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
const metricCards = computed(() => [
{
label: "访问次数",
value: formatNumber(summary.value.total_count),
tone: "blue",
icon: MetricIconVisit,
},
{
label: "访问用户数",
value: formatNumber(summary.value.unique_user_count),
tone: "cyan",
icon: MetricIconUsers,
},
{
label: "拒绝次数",
value: formatNumber(summary.value.denied_count),
tone: summary.value.denied_count > 0 ? "danger" : "violet",
icon: MetricIconDeny,
},
{
label: "平均耗时",
value: `${summary.value.avg_elapsed_ms.toFixed(1)}ms`,
tone: "indigo",
icon: MetricIconSpeed,
},
]);
const buildTerminalLine = (row: AccessLogItem) => {
const result = row.allowed ? "ALLOW" : "DENY ";
const user = row.user_name || "未知用户";
const ip = row.ip_address || "-";
const location = formatIpLocation(row) || "未知属地";
return `${formatTerminalTime(row.created_at)} [${result}] role=${roleLabel(row.role)} user=${user} ip=${ip} loc=${location} endpoint=${row.endpoint_key} cost=${row.elapsed_ms.toFixed(1)}ms`;
};
const buildSecurityTerminalLine = (row: SecurityAccessLogItem) => {
const ip = row.client_ip || "未知 IP";
const account = row.account_label || "未知账号";
const auth = SECURITY_AUTH_LABELS[row.auth_status] || row.auth_status;
const userAgent = row.user_agent || "-";
return `${formatTerminalTime(row.created_at)} [SECURITY] status=${row.status_code} auth_status=${row.auth_status}/${auth} account_label=${account} client_ip=${ip} method=${row.method} path=${row.path} ua=${userAgent} cost=${row.elapsed_ms.toFixed(1)}ms`;
};
const terminalLogRows = computed(() =>
[...logs.value].sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()),
);
const terminalLines = computed(() => {
const token = keyword.value.trim().toLowerCase();
const lines = terminalLogRows.value.map(buildTerminalLine);
if (!token) return lines;
return lines.filter((line) => line.toLowerCase().includes(token));
});
const securityTerminalLines = computed(() => {
const token = keyword.value.trim().toLowerCase();
const lines = [...securityLogs.value]
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
.map(buildSecurityTerminalLine);
if (!token) return lines;
return lines.filter((line) => line.toLowerCase().includes(token));
});
const onFilterChange = () => {
page.value = 1;
loadData();
};
const buildQueryParams = () => {
const params: Record<string, any> = {
page: page.value,
page_size: pageSize,
};
if (filters.role) params.role = filters.role;
if (filters.allowed !== undefined) params.allowed = filters.allowed;
if (dateRange.value) {
params.start_time = dateRange.value[0].toISOString();
params.end_time = dateRange.value[1].toISOString();
}
return params;
};
const scrollTerminalToBottom = () => {
nextTick(() => {
const terminal = terminalWindowRef.value;
if (!terminal) return;
terminal.scrollTop = terminal.scrollHeight;
});
};
const scrollSecurityTerminalToBottom = () => {
nextTick(() => {
const terminal = securityTerminalWindowRef.value;
if (!terminal) return;
terminal.scrollTop = terminal.scrollHeight;
});
};
const loadData = async (options: { silent?: boolean } = {}) => {
if (!options.silent) loading.value = true;
try {
const res = await fetchAccessLogs(buildQueryParams());
logs.value = res.data.items;
total.value = res.data.total;
summary.value = res.data.summary || emptySummary;
userStats.value = res.data.user_stats || [];
if (interfaceLogDialogVisible.value) scrollTerminalToBottom();
} catch {
if (options.silent) return;
logs.value = [];
total.value = 0;
summary.value = emptySummary;
userStats.value = [];
} finally {
loading.value = false;
}
};
const loadSecurityData = async (options: { silent?: boolean } = {}) => {
if (!options.silent) securityLoading.value = true;
try {
const res = await fetchSecurityAccessLogs({ status_min: 400, page: 1, page_size: 80 });
securityLogs.value = res.data.items;
securitySummary.value = res.data.summary || emptySecuritySummary;
if (securityLogDialogVisible.value) scrollSecurityTerminalToBottom();
} catch {
if (options.silent) return;
securityLogs.value = [];
securitySummary.value = emptySecuritySummary;
} finally {
securityLoading.value = false;
}
};
const stopRealtimePolling = () => {
if (!realtimeTimer.value) return;
window.clearInterval(realtimeTimer.value);
realtimeTimer.value = null;
};
const startRealtimePolling = () => {
stopRealtimePolling();
realtimeTimer.value = window.setInterval(() => {
loadData({ silent: true });
loadSecurityData({ silent: true });
}, REALTIME_POLL_INTERVAL_MS);
};
const refresh = () => {
loadData();
loadSecurityData();
};
const openInterfaceLogDialog = () => {
interfaceLogDialogVisible.value = true;
loadData({ silent: true });
scrollTerminalToBottom();
};
const openSecurityLogDialog = () => {
securityLogDialogVisible.value = true;
loadSecurityData({ silent: true });
scrollSecurityTerminalToBottom();
};
const downloadLogFile = (fileName: string, lines: string[]) => {
const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
link.click();
URL.revokeObjectURL(url);
};
const downloadInterfaceLog = () => {
downloadLogFile("interface-access-audit.log", terminalLines.value);
};
const downloadSecurityLog = () => {
downloadLogFile("security-event-audit.log", securityTerminalLines.value);
};
onMounted(() => {
refresh();
startRealtimePolling();
});
onBeforeUnmount(stopRealtimePolling);
defineExpose({ refresh });
</script>
<style scoped>
.access-logs {
--audit-ink: #1a2332;
--audit-muted: #64748b;
--audit-border: #e2e8f0;
--audit-blue: #3b82f6;
--audit-cyan: #06b6d4;
--audit-violet: #8b5cf6;
--audit-danger: #ef4444;
--audit-indigo: #6366f1;
--card-radius: 16px;
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
display: flex;
flex-direction: column;
gap: 16px;
}
.audit-filters {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
padding: 14px 18px;
background: #fff;
border-radius: var(--card-radius);
border: 1px solid var(--audit-border);
box-shadow: var(--card-shadow);
}
.audit-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.metric-card {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 14px;
min-height: 64px;
padding: 10px 16px;
border-radius: var(--card-radius);
background: #fff;
border: 1px solid var(--audit-border);
box-shadow: var(--card-shadow);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.metric-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
.metric-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
border-radius: var(--card-radius) var(--card-radius) 0 0;
}
.metric-card.blue::before { background: linear-gradient(90deg, #3b82f6, #60a5fa); }
.metric-card.cyan::before { background: linear-gradient(90deg, #06b6d4, #22d3ee); }
.metric-card.violet::before { background: linear-gradient(90deg, #8b5cf6, #a78bfa); }
.metric-card.danger::before { background: linear-gradient(90deg, #ef4444, #f87171); }
.metric-card.indigo::before { background: linear-gradient(90deg, #6366f1, #818cf8); }
.metric-icon {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 12px;
flex-shrink: 0;
}
.metric-icon svg {
width: 22px;
height: 22px;
}
.metric-card.blue .metric-icon { background: #eff6ff; color: #3b82f6; }
.metric-card.cyan .metric-icon { background: #ecfeff; color: #06b6d4; }
.metric-card.violet .metric-icon { background: #f5f3ff; color: #8b5cf6; }
.metric-card.danger .metric-icon { background: #fef2f2; color: #ef4444; }
.metric-card.indigo .metric-icon { background: #eef2ff; color: #6366f1; }
.metric-body {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.metric-label {
color: var(--audit-muted);
font-size: 13px;
font-weight: 500;
}
.metric-value {
color: var(--audit-ink);
font-size: 24px;
font-weight: 700;
line-height: 1.1;
}
.audit-grid {
display: grid;
grid-template-columns: 380px minmax(0, 1fr);
gap: 14px;
}
.ranking-card,
.log-launcher-card {
min-width: 0;
padding: 20px;
border: 1px solid var(--audit-border);
border-radius: var(--card-radius);
background: #fff;
box-shadow: var(--card-shadow);
}
.section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.section-title-group h4 {
margin: 0;
color: var(--audit-ink);
font-size: 16px;
font-weight: 600;
}
.section-title-group p {
margin: 4px 0 0;
color: var(--audit-muted);
font-size: 12px;
}
.user-rank-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.user-rank-row {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
padding: 12px 10px;
border-radius: 10px;
transition: background 0.15s ease;
}
.user-rank-row:hover {
background: #f8fafc;
}
.rank-no {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: #f1f5f9;
color: #94a3b8;
font-size: 12px;
font-weight: 700;
}
.rank-no.rank-top {
background: linear-gradient(135deg, #3b82f6, #6366f1);
color: #fff;
}
.rank-user,
.rank-count {
display: flex;
flex-direction: column;
gap: 3px;
}
.rank-user strong {
color: var(--audit-ink);
font-size: 14px;
font-weight: 600;
}
.rank-user small {
color: var(--audit-muted);
font-size: 12px;
}
.rank-location {
color: #94a3b8;
font-size: 11px;
}
.rank-count {
align-items: flex-end;
}
.rank-count strong {
color: var(--audit-ink);
font-size: 18px;
font-weight: 700;
}
.rank-count small {
color: var(--audit-muted);
font-size: 11px;
}
.rank-count .denied-highlight {
color: var(--audit-danger);
font-weight: 600;
}
.log-actions {
display: flex;
flex-direction: column;
gap: 12px;
}
.log-action-item {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 18px;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.log-action-interface {
background: linear-gradient(135deg, #f0f9ff, #e0f2fe);
border: 1px solid #bae6fd;
}
.log-action-interface:hover {
background: linear-gradient(135deg, #e0f2fe, #bae6fd);
box-shadow: 0 4px 12px rgba(14, 165, 233, 0.12);
transform: translateY(-1px);
}
.log-action-security {
background: linear-gradient(135deg, #fef2f2, #fee2e2);
border: 1px solid #fecaca;
}
.log-action-security:hover {
background: linear-gradient(135deg, #fee2e2, #fecaca);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.12);
transform: translateY(-1px);
}
.log-action-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 10px;
background: #fff;
flex-shrink: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.log-action-icon svg {
width: 20px;
height: 20px;
color: #0ea5e9;
}
.log-action-icon.security svg {
color: #ef4444;
}
.log-action-content {
flex: 1;
min-width: 0;
}
.log-action-content strong {
display: block;
color: var(--audit-ink);
font-size: 14px;
font-weight: 600;
}
.log-action-content span {
display: block;
margin-top: 2px;
color: var(--audit-muted);
font-size: 12px;
}
.dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
}
.dialog-head strong {
color: var(--audit-ink);
font-size: 17px;
}
.dialog-actions {
display: flex;
align-items: center;
gap: 8px;
}
.terminal-window {
min-height: 360px;
max-height: 520px;
overflow: auto;
padding: 16px;
border: 1px solid #1e293b;
border-radius: 12px;
background: linear-gradient(180deg, #1e293b, #0f172a);
}
.dialog-terminal-window {
height: 62vh;
min-height: 360px;
max-height: 68vh;
}
.terminal-lines {
margin: 0;
color: #e2e8f0;
font-family: "JetBrains Mono", "SFMono-Regular", "Cascadia Code", "Menlo", monospace;
font-size: 12.5px;
line-height: 1.9;
white-space: pre-wrap;
word-break: break-word;
}
.logs-pagination {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
@media (max-width: 1100px) {
.audit-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.audit-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.audit-metrics {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./PermissionIpLocations.vue"), "utf8");
const readMapSource = () => readFileSync(resolve(__dirname, "./chinaProvinceMap.ts"), "utf8");
describe("PermissionIpLocations", () => {
it("renders IP location visualization without duplicate region detail table", () => {
const source = readSource();
expect(source).toContain("fetchIpLocations");
expect(source).toContain("unique_ip_count");
expect(source).not.toContain("来源地域明细");
expect(source).not.toContain("table-card");
expect(source).not.toContain("<el-table :data=\"items\"");
});
it("renders an interactive China map and primary metric cards", () => {
const source = readSource();
expect(source).toContain("VChart");
expect(source).toContain("chinaMapOption");
expect(source).toContain("用户分布图");
expect(source).toContain("访问次数");
expect(source).toContain("访问用户数");
expect(source).toContain("来源 IP 数");
expect(source).toContain("summary");
});
it("uses the standard geojson.cn China map data instead of generated rectangles", () => {
const source = readSource();
const mapSource = readMapSource();
expect(source).toContain("../assets/china.json");
expect(mapSource).toContain("geojson.cn/api/china/1.6.3/china.json");
expect(mapSource).not.toContain("ProvinceBox");
expect(mapSource).not.toContain("provinceBoxes");
});
it("hides the map legend and disables map zoom interactions", () => {
const source = readSource();
expect(source).not.toContain("visualMap:");
expect(source).toContain("roam: false");
expect(source).not.toContain("scaleLimit");
});
it("uses narrow metric cards without helper subtitles", () => {
const source = readSource();
expect(source).toContain("min-height: 64px");
expect(source).toContain("padding: 10px 16px");
expect(source).toContain("right: -46px");
expect(source).toContain("bottom: -52px");
expect(source).toContain("border-radius: 18px");
expect(source).not.toContain("<small>{{ card.hint }}</small>");
expect(source).not.toContain("hint:");
expect(source).not.toContain("权限检查总量");
});
it("removes the duplicated hero copy above the map", () => {
const source = readSource();
expect(source).toContain("ip-locations-toolbar");
expect(source).not.toContain("权限监控 / IP属地");
expect(source).not.toContain("按访问日志聚合省市、用户与来源 IP");
expect(source).not.toContain("悬停省份查看访问次数、访问用户数和来源 IP 数");
expect(source).not.toContain("hero-desc");
expect(source).not.toContain("eyebrow");
});
it("places the period filter toolbar on the left", () => {
const source = readSource();
expect(source).toContain(".ip-hero");
expect(source).toContain("justify-content: flex-start");
expect(source).not.toContain("justify-content: flex-end");
});
});
@@ -0,0 +1,546 @@
<template>
<div class="ip-locations">
<section class="ip-hero">
<div class="ip-locations-toolbar">
<div class="toolbar-left">
<span class="toolbar-title">IP 属地分析</span>
<span class="toolbar-desc">查看访问来源的地理分布</span>
</div>
<div class="toolbar-actions">
<el-radio-group v-model="days" size="small" @change="loadData">
<el-radio-button :value="1">24小时</el-radio-button>
<el-radio-button :value="7">7</el-radio-button>
<el-radio-button :value="30">30</el-radio-button>
<el-radio-button :value="90">90</el-radio-button>
</el-radio-group>
<el-button :loading="loading" size="small" @click="loadData">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px; margin-right: 4px"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
刷新
</el-button>
</div>
</div>
</section>
<section class="metric-grid">
<article v-for="card in metricCards" :key="card.label" class="metric-card" :class="card.tone">
<div class="metric-icon">
<component :is="card.icon" />
</div>
<div class="metric-body">
<span class="metric-label">{{ card.label }}</span>
<strong class="metric-value">{{ card.value }}</strong>
</div>
</article>
</section>
<section v-loading="loading" class="distribution-card">
<div class="map-panel">
<div class="section-head">
<div class="section-title-group">
<h4>中国地图</h4>
<p>访问来源地理分布热力图</p>
</div>
<el-tag type="info" effect="plain" round size="small">{{ currentPeriodLabel }}</el-tag>
</div>
<v-chart :option="chinaMapOption" autoresize class="china-map" />
</div>
<aside class="rank-panel">
<div class="section-head compact">
<div class="section-title-group">
<h4>热点属地</h4>
<p>按访问次数排序</p>
</div>
</div>
<div v-if="topRegions.length" class="region-list">
<div v-for="region in topRegions" :key="region.key" class="region-row">
<span class="rank" :class="{ top: Number(region.rank) <= 3 }">{{ region.rank }}</span>
<span class="region-name">{{ region.name }}</span>
<span class="region-value">{{ region.total_count }}</span>
</div>
</div>
<el-empty v-else description="暂无属地数据" :image-size="72" />
</aside>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, h, onMounted, ref } from "vue";
import VChart from "vue-echarts";
import { registerMap, use } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { MapChart } from "echarts/charts";
import { TooltipComponent } from "echarts/components";
import chinaMapGeoJson from "../assets/china.json";
import { fetchIpLocations } from "../api/projectPermissions";
import type { IpLocationsResponse, IpLocationStatItem } from "../types/api";
import { normalizeProvinceName } from "./chinaProvinceMap";
use([CanvasRenderer, MapChart, TooltipComponent]);
registerMap("ctms-china", chinaMapGeoJson as any);
const IconGlobe = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("circle", { cx: "12", cy: "12", r: "10" }),
h("line", { x1: "2", y1: "12", x2: "22", y2: "12" }),
h("path", { d: "M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" }),
]);
const IconUsers = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("path", { d: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" }),
h("circle", { cx: "9", cy: "7", r: "4" }),
h("path", { d: "M23 21v-2a4 4 0 0 0-3-3.87" }),
h("path", { d: "M16 3.13a4 4 0 0 1 0 7.75" }),
]);
const IconWifi = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("path", { d: "M5 12.55a11 11 0 0 1 14.08 0" }),
h("path", { d: "M1.42 9a16 16 0 0 1 21.16 0" }),
h("path", { d: "M8.53 16.11a6 6 0 0 1 6.95 0" }),
h("line", { x1: "12", y1: "20", x2: "12.01", y2: "20" }),
]);
const IconShield = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("path", { d: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" }),
]);
const loading = ref(false);
const days = ref(7);
const items = ref<IpLocationStatItem[]>([]);
const summary = ref<IpLocationsResponse["summary"] | null>(null);
const periodLabels: Record<number, string> = {
1: "近 24 小时",
7: "近 7 天",
30: "近 30 天",
90: "近 90 天",
};
const currentPeriodLabel = computed(() => periodLabels[days.value] || `${days.value}`);
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
const formatLocation = (row: IpLocationStatItem) => {
const parts = [row.province, row.city].filter(Boolean);
return parts.length ? parts.join(" / ") : row.location;
};
const fallbackSummary = computed<IpLocationsResponse["summary"]>(() => ({
total_count: items.value.reduce((sum, row) => sum + row.total_count, 0),
allowed_count: items.value.reduce((sum, row) => sum + row.allowed_count, 0),
denied_count: items.value.reduce((sum, row) => sum + row.denied_count, 0),
unique_ip_count: items.value.reduce((sum, row) => sum + row.unique_ip_count, 0),
unique_user_count: items.value.reduce((sum, row) => sum + (row.unique_user_count || 0), 0),
}));
const activeSummary = computed(() => summary.value || fallbackSummary.value);
const deniedRate = computed(() => {
const total = activeSummary.value.total_count;
if (!total) return 0;
return Math.round((activeSummary.value.denied_count / total) * 1000) / 10;
});
const metricCards = computed(() => [
{
label: "访问次数",
value: formatNumber(activeSummary.value.total_count),
tone: "blue",
icon: IconGlobe,
},
{
label: "访问用户数",
value: formatNumber(activeSummary.value.unique_user_count),
tone: "cyan",
icon: IconUsers,
},
{
label: "来源 IP 数",
value: formatNumber(activeSummary.value.unique_ip_count),
tone: "indigo",
icon: IconWifi,
},
{
label: "拒绝占比",
value: `${deniedRate.value}%`,
tone: deniedRate.value > 10 ? "danger" : "violet",
icon: IconShield,
},
]);
const provinceData = computed(() => {
const provinceMap = new Map<string, IpLocationStatItem & { name: string }>();
items.value.forEach((item) => {
const name = normalizeProvinceName(item.province || item.city);
if (!name || item.country !== "中国") return;
const current = provinceMap.get(name);
if (!current) {
provinceMap.set(name, { ...item, name });
return;
}
current.total_count += item.total_count;
current.allowed_count += item.allowed_count;
current.denied_count += item.denied_count;
current.unique_ip_count += item.unique_ip_count;
current.unique_user_count += item.unique_user_count || 0;
});
return Array.from(provinceMap.values());
});
const chinaMapOption = computed(() => ({
tooltip: {
trigger: "item",
formatter: (params: any) => {
const data = params.data;
if (!data) return `${params.name}<br/>暂无访问数据`;
return [
`${params.name}`,
`访问次数:${formatNumber(data.value)}`,
`访问用户数:${formatNumber(data.unique_user_count || 0)}`,
`来源 IP 数:${formatNumber(data.unique_ip_count || 0)}`,
`拒绝次数:${formatNumber(data.denied_count || 0)}`,
].join("<br/>");
},
},
series: [
{
name: "用户分布图",
type: "map",
map: "ctms-china",
roam: false,
zoom: 1.08,
label: { show: false },
itemStyle: {
areaColor: "#edf3ff",
borderColor: "#ffffff",
borderWidth: 1,
},
emphasis: {
label: { show: true, color: "#0f172a", fontSize: 12, fontWeight: 700 },
itemStyle: { areaColor: "#ffb86b", shadowBlur: 12, shadowColor: "rgba(245, 158, 11, 0.28)" },
},
data: provinceData.value.map((item) => ({
name: item.name,
value: item.total_count,
unique_user_count: item.unique_user_count,
unique_ip_count: item.unique_ip_count,
denied_count: item.denied_count,
})),
},
],
}));
const topRegions = computed(() =>
[...items.value]
.sort((a, b) => b.total_count - a.total_count)
.slice(0, 6)
.map((item, index) => ({
key: `${item.country}-${item.province}-${item.city}-${item.isp}`,
rank: String(index + 1).padStart(2, "0"),
name: formatLocation(item),
total_count: formatNumber(item.total_count),
})),
);
const loadData = async () => {
loading.value = true;
try {
const res = await fetchIpLocations({ days: days.value, limit: 50 });
items.value = res.data.items;
summary.value = res.data.summary || null;
} catch {
items.value = [];
summary.value = null;
} finally {
loading.value = false;
}
};
onMounted(loadData);
defineExpose({ refresh: loadData });
</script>
<style scoped>
.ip-locations {
--ip-ink: #1a2332;
--ip-muted: #64748b;
--ip-border: #e2e8f0;
--ip-card: #ffffff;
--ip-blue: #3b82f6;
--ip-cyan: #06b6d4;
--ip-violet: #8b5cf6;
--ip-danger: #ef4444;
--ip-indigo: #6366f1;
--ip-radius: 16px;
--ip-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
display: flex;
flex-direction: column;
gap: 14px;
}
.ip-hero {
background: var(--ip-card);
border: 1px solid var(--ip-border);
border-radius: var(--ip-radius);
box-shadow: var(--ip-shadow);
padding: 14px 18px;
}
.ip-locations-toolbar {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.toolbar-left {
display: flex;
flex-direction: column;
gap: 2px;
}
.toolbar-title {
font-size: 15px;
font-weight: 600;
color: var(--ip-ink);
}
.toolbar-desc {
font-size: 12px;
color: var(--ip-muted);
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.metric-card {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 14px;
min-height: 64px;
padding: 10px 16px;
border-radius: var(--ip-radius);
background: #fff;
border: 1px solid var(--ip-border);
box-shadow: var(--ip-shadow);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.metric-card::after {
content: "";
position: absolute;
right: -46px;
bottom: -52px;
width: 120px;
height: 120px;
border-radius: 18px;
background: rgba(59, 130, 246, 0.06);
transform: rotate(18deg);
pointer-events: none;
}
.metric-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
.metric-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
border-radius: var(--ip-radius) var(--ip-radius) 0 0;
}
.metric-card.blue::before { background: linear-gradient(90deg, #3b82f6, #60a5fa); }
.metric-card.cyan::before { background: linear-gradient(90deg, #06b6d4, #22d3ee); }
.metric-card.indigo::before { background: linear-gradient(90deg, #6366f1, #818cf8); }
.metric-card.violet::before { background: linear-gradient(90deg, #8b5cf6, #a78bfa); }
.metric-card.danger::before { background: linear-gradient(90deg, #ef4444, #f87171); }
.metric-icon {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 12px;
flex-shrink: 0;
}
.metric-icon svg {
width: 22px;
height: 22px;
}
.metric-card.blue .metric-icon { background: #eff6ff; color: #3b82f6; }
.metric-card.cyan .metric-icon { background: #ecfeff; color: #06b6d4; }
.metric-card.indigo .metric-icon { background: #eef2ff; color: #6366f1; }
.metric-card.violet .metric-icon { background: #f5f3ff; color: #8b5cf6; }
.metric-card.danger .metric-icon { background: #fef2f2; color: #ef4444; }
.metric-body {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.metric-label {
color: var(--ip-muted);
font-size: 13px;
font-weight: 500;
}
.metric-value {
color: var(--ip-ink);
font-size: 24px;
font-weight: 700;
line-height: 1.1;
}
.distribution-card {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 14px;
background: var(--ip-card);
border: 1px solid var(--ip-border);
border-radius: var(--ip-radius);
box-shadow: var(--ip-shadow);
padding: 18px;
}
.map-panel,
.rank-panel {
min-width: 0;
border: 1px solid #f1f5f9;
border-radius: 14px;
background: linear-gradient(180deg, #fafcff 0%, #ffffff 100%);
padding: 16px;
}
.section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.section-head.compact {
margin-bottom: 16px;
}
.section-title-group h4 {
margin: 0;
color: var(--ip-ink);
font-size: 16px;
font-weight: 600;
}
.section-title-group p {
margin: 4px 0 0;
color: var(--ip-muted);
font-size: 12px;
}
.china-map {
width: 100%;
height: 430px;
}
.region-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.region-row {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
width: 100%;
padding: 12px;
border-radius: 10px;
background: #fff;
border: 1px solid #f1f5f9;
color: var(--ip-ink);
transition: all 0.15s ease;
}
.region-row:hover {
background: #f8fafc;
border-color: var(--ip-border);
}
.rank {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: #f1f5f9;
color: #94a3b8;
font-size: 12px;
font-weight: 700;
}
.rank.top {
background: linear-gradient(135deg, #3b82f6, #6366f1);
color: #fff;
}
.region-name {
overflow: hidden;
font-size: 13px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.region-value {
color: var(--ip-blue);
font-size: 15px;
font-weight: 700;
}
@media (max-width: 1100px) {
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.distribution-card {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.ip-locations-toolbar {
flex-direction: column;
align-items: flex-start;
}
.metric-grid {
grid-template-columns: 1fr;
}
.china-map {
height: 320px;
}
}
</style>
@@ -1,82 +1,51 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { mount } from "@vue/test-utils";
import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
import type { PermissionMetricsResponse, HealthResponse, AlertsResponse } from "@/types/api";
vi.mock("@/api/projectPermissions", () => ({
fetchPermissionMetrics: vi.fn().mockResolvedValue({
data: {
check_metrics: {
total_checks: 1000, allowed_checks: 950, denied_checks: 50,
total_time: 5.234, min_time: 0.001, max_time: 0.05, avg_time: 0.005,
allow_rate: 95.0, deny_rate: 5.0, error_rate: 0.1, errors: 1,
},
cache_metrics: {
total_accesses: 1000, cache_hits: 850, cache_misses: 150,
cache_invalidations: 5, hit_rate: 85.0, miss_rate: 15.0,
},
uptime_seconds: 3600,
},
}),
fetchPermissionHealth: vi.fn().mockResolvedValue({
data: {
status: "healthy", health_score: 95, issues: [],
metrics: {}, cache_stats: { cache_items: { total_count: 60 }, cache_metrics: {} },
},
}),
fetchPermissionAlerts: vi.fn().mockResolvedValue({ data: { total: 0, alerts: [] } }),
fetchTopDenied: vi.fn().mockResolvedValue({ data: { items: [] } }),
fetchStatsSummary: vi.fn().mockResolvedValue({
data: {
total_logs: 5000,
today: { total_checks: 200, allowed_checks: 190, denied_checks: 10, avg_elapsed_ms: 3.5, max_elapsed_ms: 45, allow_rate: 95, deny_rate: 5 },
last_hour: { total_checks: 30, allowed_checks: 28, denied_checks: 2, requests_per_minute: 0.5 },
},
}),
fetchPermissionTrends: vi.fn().mockResolvedValue({ data: { period: "24h", data_points: [] } }),
fetchAccessLogs: vi.fn().mockResolvedValue({ data: { total: 0, page: 1, page_size: 50, items: [] } }),
}));
describe("PermissionMonitoring.vue", () => {
const mockMetrics: PermissionMetricsResponse = {
check_metrics: {
total_checks: 1000,
allowed_checks: 950,
denied_checks: 50,
total_time: 5.234,
min_time: 0.001,
max_time: 0.05,
avg_time: 0.005,
allow_rate: 95.0,
deny_rate: 5.0,
error_rate: 0.1,
errors: 1,
},
cache_metrics: {
total_accesses: 1000,
cache_hits: 850,
cache_misses: 150,
cache_invalidations: 5,
hit_rate: 85.0,
miss_rate: 15.0,
},
uptime_seconds: 3600,
};
const mockHealth: HealthResponse = {
status: "healthy",
health_score: 95,
issues: [],
metrics: mockMetrics,
cache_stats: {
cache_items: {
project_permissions_count: 10,
member_role_count: 50,
total_count: 60,
},
cache_metrics: mockMetrics.cache_metrics,
},
};
const mockAlerts: AlertsResponse = {
total: 2,
alerts: [
{
timestamp: 1715692800.123,
level: "warning",
type: "slow_permission_check",
message: "权限检查耗时过长: 52.34ms",
data: { elapsed_time: 0.05234 },
},
{
timestamp: 1715692799.456,
level: "error",
type: "permission_check_error",
message: "权限检查出错: database connection timeout",
data: { error: "database connection timeout" },
},
],
};
it("renders monitoring dashboard", () => {
it("renders monitoring dashboard with tabs", () => {
const wrapper = mount(PermissionMonitoring, {
props: {
metrics: mockMetrics,
health: mockHealth,
alerts: mockAlerts,
},
global: {
stubs: {
ElTag: false,
ElProgress: false,
ElTable: false,
ElEmpty: false,
ElTabs: false,
ElTabPane: false,
PermissionTrendCharts: true,
PermissionAccessLogs: true,
PermissionIpLocations: true,
},
},
});
@@ -84,115 +53,19 @@ describe("PermissionMonitoring.vue", () => {
expect(wrapper.find(".permission-monitoring").exists()).toBe(true);
});
it("displays health status", () => {
it("exposes refresh method", () => {
const wrapper = mount(PermissionMonitoring, {
props: {
metrics: mockMetrics,
health: mockHealth,
alerts: mockAlerts,
},
global: {
stubs: {
ElTag: false,
ElProgress: false,
ElTable: false,
ElEmpty: false,
ElTabs: true,
ElTabPane: true,
PermissionTrendCharts: true,
PermissionAccessLogs: true,
PermissionIpLocations: true,
},
},
});
const healthCard = wrapper.find(".health-card");
expect(healthCard.exists()).toBe(true);
expect(healthCard.text()).toContain("系统健康状态");
});
it("displays metric cards", () => {
const wrapper = mount(PermissionMonitoring, {
props: {
metrics: mockMetrics,
health: mockHealth,
alerts: mockAlerts,
},
global: {
stubs: {
ElTag: false,
ElProgress: false,
ElTable: false,
ElEmpty: false,
},
},
});
const metricCards = wrapper.findAll(".metric-card");
expect(metricCards.length).toBeGreaterThan(0);
});
it("displays cache efficiency", () => {
const wrapper = mount(PermissionMonitoring, {
props: {
metrics: mockMetrics,
health: mockHealth,
alerts: mockAlerts,
},
global: {
stubs: {
ElTag: false,
ElProgress: false,
ElTable: false,
ElEmpty: false,
},
},
});
const cacheCard = wrapper.find(".cache-card");
expect(cacheCard.exists()).toBe(true);
expect(cacheCard.text()).toContain("缓存效率");
});
it("displays alerts list", () => {
const wrapper = mount(PermissionMonitoring, {
props: {
metrics: mockMetrics,
health: mockHealth,
alerts: mockAlerts,
},
global: {
stubs: {
ElTag: false,
ElProgress: false,
ElTable: false,
ElEmpty: false,
},
},
});
const alertsCard = wrapper.find(".alerts-card");
expect(alertsCard.exists()).toBe(true);
expect(alertsCard.text()).toContain("最近告警");
});
it("emits refresh event when refresh button clicked", async () => {
const wrapper = mount(PermissionMonitoring, {
props: {
metrics: mockMetrics,
health: mockHealth,
alerts: mockAlerts,
},
global: {
stubs: {
ElTag: false,
ElProgress: false,
ElTable: false,
ElEmpty: false,
ElButton: false,
},
},
});
const refreshButton = wrapper.find("button");
if (refreshButton.exists()) {
await refreshButton.trigger("click");
expect(wrapper.emitted("refresh")).toBeTruthy();
}
expect(typeof wrapper.vm.refresh).toBe("function");
});
});
+617 -259
View File
@@ -1,346 +1,704 @@
<template>
<div class="permission-monitoring">
<!-- 系统健康评分 -->
<div v-if="health" class="health-card">
<div class="health-header">
<h3>系统健康状态</h3>
<el-tag :type="getHealthType(health.status)">
{{ getHealthLabel(health.status) }}
</el-tag>
</div>
<div class="health-score">
<div class="score-value">{{ health.health_score }}</div>
<div class="score-label">健康分数</div>
</div>
<div v-if="health.issues.length > 0" class="health-issues">
<div class="issues-title">发现的问题</div>
<ul>
<li v-for="(issue, index) in health.issues" :key="index">{{ issue }}</li>
</ul>
</div>
</div>
<!-- 性能指标卡片 -->
<div v-if="metrics" class="metrics-cards">
<div class="metric-card">
<div class="metric-label">总检查次数</div>
<div class="metric-value">{{ metrics.check_metrics.total_checks }}</div>
</div>
<div class="metric-card">
<div class="metric-label">平均响应时间</div>
<div class="metric-value">{{ (metrics.check_metrics.avg_time * 1000).toFixed(2) }}ms</div>
</div>
<div class="metric-card">
<div class="metric-label">允许率</div>
<div class="metric-value">{{ metrics.check_metrics.allow_rate.toFixed(1) }}%</div>
</div>
<div class="metric-card">
<div class="metric-label">错误率</div>
<div class="metric-value" :style="{ color: metrics.check_metrics.error_rate > 1 ? '#f56c6c' : '#67c23a' }">
{{ metrics.check_metrics.error_rate.toFixed(2) }}%
</div>
</div>
</div>
<!-- 缓存效率 -->
<div v-if="metrics" class="cache-card">
<div class="cache-header">
<h3>缓存效率</h3>
</div>
<div class="cache-stats">
<div class="cache-stat">
<div class="stat-label">缓存命中率</div>
<el-progress
:percentage="metrics.cache_metrics.hit_rate"
:color="getProgressColor(metrics.cache_metrics.hit_rate)"
/>
<div class="stat-value">{{ metrics.cache_metrics.hit_rate.toFixed(1) }}%</div>
<el-tabs v-model="activeTab" @tab-change="onTabChange">
<el-tab-pane label="实时概览" name="overview">
<div v-if="statsSummary" class="stats-summary">
<div v-for="card in summaryCards" :key="card.label" class="summary-card" :class="card.tone">
<div class="summary-icon">
<component :is="card.icon" />
</div>
<div class="summary-body">
<span class="summary-label">{{ card.label }}</span>
<strong class="summary-value">{{ card.value }}</strong>
</div>
</div>
</div>
<div class="cache-stat">
<div class="stat-label">缓存项目数</div>
<div class="stat-value">{{ health?.cache_stats?.cache_items.total_count || 0 }}</div>
<div class="overview-grid">
<div v-if="health" class="health-card">
<div class="health-header">
<h3>系统健康状态</h3>
<el-tag :type="getHealthType(health.status)" effect="dark" round>
{{ getHealthLabel(health.status) }}
</el-tag>
</div>
<div class="health-score-ring">
<el-progress
type="dashboard"
:percentage="health.health_score"
:color="getScoreColor(health.health_score)"
:width="140"
:stroke-width="10"
>
<template #default>
<div class="score-inner">
<strong>{{ health.health_score }}</strong>
<span>健康分</span>
</div>
</template>
</el-progress>
</div>
<div v-if="health.issues.length > 0" class="health-issues">
<div v-for="(issue, index) in health.issues" :key="index" class="issue-item">
<span class="issue-dot" />
<span>{{ issue }}</span>
</div>
</div>
<div v-else class="health-ok">
<span class="ok-icon">&#10003;</span>
<span>系统运行正常未发现问题</span>
</div>
</div>
<div v-if="metrics" class="cache-card">
<h3>缓存效率</h3>
<div class="cache-stats">
<div class="cache-stat primary">
<div class="cache-stat-header">
<span class="stat-label">命中率</span>
<strong class="stat-value" :style="{ color: getProgressColor(metrics.cache_metrics.hit_rate) }">
{{ metrics.cache_metrics.hit_rate.toFixed(1) }}%
</strong>
</div>
<el-progress
:percentage="metrics.cache_metrics.hit_rate"
:color="getProgressColor(metrics.cache_metrics.hit_rate)"
:stroke-width="8"
:show-text="false"
/>
</div>
<div class="cache-stat-row">
<div class="cache-stat-mini">
<span class="stat-label">缓存项目</span>
<strong>{{ health?.cache_stats?.cache_items.total_count || 0 }}</strong>
</div>
<div class="cache-stat-mini">
<span class="stat-label">失效次数</span>
<strong>{{ metrics.cache_metrics.cache_invalidations }}</strong>
</div>
</div>
</div>
</div>
</div>
<div class="cache-stat">
<div class="stat-label">缓存失效次数</div>
<div class="stat-value">{{ metrics.cache_metrics.cache_invalidations }}</div>
<div v-if="topDenied && topDenied.length > 0" class="denied-card">
<div class="card-header">
<h3>被拒绝最多的权限</h3>
<el-tag effect="plain" size="small" type="info"> 7 </el-tag>
</div>
<div class="denied-list">
<div v-for="(item, index) in topDenied" :key="index" class="denied-row">
<span class="denied-rank" :class="{ top: index < 3 }">{{ index + 1 }}</span>
<div class="denied-info">
<strong>{{ item.endpoint_key }}</strong>
<small>{{ item.role }}</small>
</div>
<span class="denied-count">{{ item.denied_count }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 告警列表 -->
<div class="alerts-card">
<div class="alerts-header">
<h3>最近告警</h3>
<el-button type="primary" size="small" @click="$emit('refresh')">
<el-icon><RefreshRight /></el-icon>
刷新
</el-button>
</div>
<div class="alerts-card">
<div class="card-header">
<h3>最近告警</h3>
<el-tag v-if="alerts && alerts.alerts.length > 0" effect="dark" size="small" type="warning" round>
{{ alerts.alerts.length }}
</el-tag>
</div>
<div v-if="alerts && alerts.alerts.length > 0" class="alerts-list">
<div v-for="(alert, index) in alerts.alerts" :key="index" class="alert-row" :class="alert.level">
<div class="alert-level">
<el-tag :type="getAlertType(alert.level)" size="small" effect="dark" round>{{ alert.level }}</el-tag>
</div>
<div class="alert-body">
<strong>{{ alert.type }}</strong>
<span>{{ alert.message }}</span>
</div>
<span class="alert-time">{{ formatTime(alert.timestamp) }}</span>
</div>
</div>
<el-empty v-else description="暂无告警,系统运行正常" :image-size="60" />
</div>
</el-tab-pane>
<div v-if="alerts && alerts.alerts.length > 0" class="alerts-table-wrapper">
<el-table :data="alerts.alerts" stripe border>
<el-table-column prop="timestamp" label="时间" width="180">
<template #default="{ row }">
{{ formatTime(row.timestamp) }}
</template>
</el-table-column>
<el-tab-pane label="趋势分析" name="trends">
<PermissionTrendCharts ref="trendChartsRef" />
</el-tab-pane>
<el-table-column prop="level" label="级别" width="80">
<template #default="{ row }">
<el-tag :type="getAlertType(row.level)">{{ row.level }}</el-tag>
</template>
</el-table-column>
<el-tab-pane label="访问日志" name="logs">
<PermissionAccessLogs ref="accessLogsRef" />
</el-tab-pane>
<el-table-column prop="type" label="类型" width="150" />
<el-table-column prop="message" label="消息" show-overflow-tooltip />
</el-table>
</div>
<div v-else class="empty-state">
<el-empty description="暂无告警" />
</div>
</div>
<el-tab-pane label="IP属地" name="ip-locations">
<PermissionIpLocations ref="ipLocationsRef" />
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { RefreshRight } from "@element-plus/icons-vue";
import { ref, computed, h, onMounted } from "vue";
import type {
PermissionMetricsResponse,
CacheStatsResponse,
AlertsResponse,
HealthResponse,
TopDeniedItem,
StatsSummaryResponse,
} from "@/types/api";
import {
fetchPermissionMetrics,
fetchPermissionAlerts,
fetchPermissionHealth,
fetchTopDenied,
fetchStatsSummary,
} from "@/api/projectPermissions";
import PermissionTrendCharts from "./PermissionTrendCharts.vue";
import PermissionAccessLogs from "./PermissionAccessLogs.vue";
import PermissionIpLocations from "./PermissionIpLocations.vue";
interface Props {
metrics: PermissionMetricsResponse | null;
health: HealthResponse | null;
alerts: AlertsResponse | null;
}
const IconCheck = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("polyline", { points: "20 6 9 17 4 12" }),
]);
const IconActivity = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("polyline", { points: "22 12 18 12 15 21 9 3 6 12 2 12" }),
]);
const IconPercent = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("line", { x1: "19", y1: "5", x2: "5", y2: "19" }),
h("circle", { cx: "6.5", cy: "6.5", r: "2.5" }),
h("circle", { cx: "17.5", cy: "17.5", r: "2.5" }),
]);
const IconClock = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("circle", { cx: "12", cy: "12", r: "10" }),
h("polyline", { points: "12 6 12 12 16 14" }),
]);
const IconDatabase = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("ellipse", { cx: "12", cy: "5", rx: "9", ry: "3" }),
h("path", { d: "M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" }),
h("path", { d: "M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" }),
]);
interface Emits {
(e: "refresh"): void;
}
const activeTab = ref("overview");
const metrics = ref<PermissionMetricsResponse | null>(null);
const health = ref<HealthResponse | null>(null);
const alerts = ref<AlertsResponse | null>(null);
const topDenied = ref<TopDeniedItem[]>([]);
const statsSummary = ref<StatsSummaryResponse | null>(null);
defineProps<Props>();
defineEmits<Emits>();
const trendChartsRef = ref<InstanceType<typeof PermissionTrendCharts>>();
const accessLogsRef = ref<InstanceType<typeof PermissionAccessLogs>>();
const ipLocationsRef = ref<InstanceType<typeof PermissionIpLocations>>();
const formatTime = (timestamp: number): string => {
return new Date(timestamp * 1000).toLocaleString("zh-CN");
};
const getHealthType = (status: string): string => {
const typeMap: Record<string, string> = {
healthy: "success",
degraded: "warning",
unhealthy: "danger",
};
return typeMap[status] || "info";
const getHealthType = (status: string) => {
const map: Record<string, string> = { healthy: "success", degraded: "warning", unhealthy: "danger" };
return map[status] || "info";
};
const getHealthLabel = (status: string): string => {
const labelMap: Record<string, string> = {
healthy: "健康",
degraded: "降级",
unhealthy: "不健康",
};
return labelMap[status] || status;
const getHealthLabel = (status: string) => {
const map: Record<string, string> = { healthy: "健康", degraded: "降级", unhealthy: "不健康" };
return map[status] || status;
};
const getAlertType = (level: string): string => {
const typeMap: Record<string, string> = {
info: "info",
warning: "warning",
error: "danger",
};
return typeMap[level] || "info";
const getAlertType = (level: string) => {
const map: Record<string, string> = { info: "info", warning: "warning", error: "danger" };
return map[level] || "info";
};
const getProgressColor = (percentage: number): string => {
if (percentage >= 80) return "#67c23a";
if (percentage >= 50) return "#e6a23c";
return "#f56c6c";
if (percentage >= 80) return "#10b981";
if (percentage >= 50) return "#f59e0b";
return "#ef4444";
};
const getScoreColor = (score: number): string => {
if (score >= 80) return "#10b981";
if (score >= 60) return "#f59e0b";
return "#ef4444";
};
const summaryCards = computed(() => {
if (!statsSummary.value) return [];
return [
{
label: "今日总检查",
value: statsSummary.value.today.total_checks.toLocaleString(),
tone: "blue",
icon: IconCheck,
},
{
label: "每分钟请求",
value: statsSummary.value.last_hour.requests_per_minute,
tone: "cyan",
icon: IconActivity,
},
{
label: "今日允许率",
value: `${statsSummary.value.today.allow_rate}%`,
tone: statsSummary.value.today.allow_rate >= 80 ? "green" : "warning",
icon: IconPercent,
},
{
label: "平均响应时间",
value: `${statsSummary.value.today.avg_elapsed_ms}ms`,
tone: statsSummary.value.today.avg_elapsed_ms > 10 ? "danger" : "indigo",
icon: IconClock,
},
{
label: "历史总记录",
value: statsSummary.value.total_logs.toLocaleString(),
tone: "violet",
icon: IconDatabase,
},
];
});
const loadOverviewData = async () => {
const [metricsRes, healthRes, alertsRes, deniedRes, summaryRes] = await Promise.allSettled([
fetchPermissionMetrics(),
fetchPermissionHealth(),
fetchPermissionAlerts(undefined, 20),
fetchTopDenied({ days: 7, limit: 10 }),
fetchStatsSummary(),
]);
if (metricsRes.status === "fulfilled") metrics.value = metricsRes.value.data;
if (healthRes.status === "fulfilled") health.value = healthRes.value.data;
if (alertsRes.status === "fulfilled") alerts.value = alertsRes.value.data;
if (deniedRes.status === "fulfilled") topDenied.value = deniedRes.value.data.items;
if (summaryRes.status === "fulfilled") statsSummary.value = summaryRes.value.data;
};
const onTabChange = (tab: string) => {
if (tab === "overview") loadOverviewData();
};
const refresh = () => {
if (activeTab.value === "overview") loadOverviewData();
else if (activeTab.value === "trends") trendChartsRef.value?.refresh();
else if (activeTab.value === "logs") accessLogsRef.value?.refresh();
else if (activeTab.value === "ip-locations") ipLocationsRef.value?.refresh();
};
onMounted(loadOverviewData);
defineExpose({ refresh });
</script>
<style scoped lang="scss">
<style scoped>
.permission-monitoring {
padding: 20px 0;
display: flex;
flex-direction: column;
gap: 20px;
--mon-ink: #1a2332;
--mon-muted: #64748b;
--mon-border: #e2e8f0;
--mon-radius: 16px;
--mon-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
padding: 0;
}
.health-card {
background: #f5f7fa;
border: 1px solid #ebeef5;
border-radius: 4px;
.stats-summary {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 14px;
margin-bottom: 16px;
}
.summary-card {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 12px;
padding: 16px 18px;
border-radius: var(--mon-radius);
background: #fff;
border: 1px solid var(--mon-border);
box-shadow: var(--mon-shadow);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.summary-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
.summary-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
border-radius: var(--mon-radius) var(--mon-radius) 0 0;
}
.summary-card.blue::before { background: linear-gradient(90deg, #3b82f6, #60a5fa); }
.summary-card.cyan::before { background: linear-gradient(90deg, #06b6d4, #22d3ee); }
.summary-card.green::before { background: linear-gradient(90deg, #10b981, #34d399); }
.summary-card.warning::before { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
.summary-card.indigo::before { background: linear-gradient(90deg, #6366f1, #818cf8); }
.summary-card.danger::before { background: linear-gradient(90deg, #ef4444, #f87171); }
.summary-card.violet::before { background: linear-gradient(90deg, #8b5cf6, #a78bfa); }
.summary-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 10px;
flex-shrink: 0;
}
.summary-icon svg {
width: 20px;
height: 20px;
}
.summary-card.blue .summary-icon { background: #eff6ff; color: #3b82f6; }
.summary-card.cyan .summary-icon { background: #ecfeff; color: #06b6d4; }
.summary-card.green .summary-icon { background: #ecfdf5; color: #10b981; }
.summary-card.warning .summary-icon { background: #fffbeb; color: #f59e0b; }
.summary-card.indigo .summary-icon { background: #eef2ff; color: #6366f1; }
.summary-card.danger .summary-icon { background: #fef2f2; color: #ef4444; }
.summary-card.violet .summary-icon { background: #f5f3ff; color: #8b5cf6; }
.summary-body {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.summary-label {
color: var(--mon-muted);
font-size: 12px;
font-weight: 500;
}
.summary-value {
color: var(--mon-ink);
font-size: 22px;
font-weight: 700;
line-height: 1.1;
}
.overview-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-bottom: 16px;
}
.health-card,
.cache-card,
.denied-card,
.alerts-card {
background: #fff;
border: 1px solid var(--mon-border);
border-radius: var(--mon-radius);
padding: 20px;
box-shadow: var(--mon-shadow);
}
.denied-card,
.alerts-card {
margin-bottom: 16px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.card-header h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--mon-ink);
}
.health-card h3,
.cache-card h3 {
margin: 0 0 16px;
font-size: 15px;
font-weight: 600;
color: var(--mon-ink);
}
.health-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
margin-bottom: 16px;
}
.health-score {
text-align: center;
margin: 20px 0;
.health-header h3 {
margin: 0;
}
.score-value {
font-size: 48px;
font-weight: bold;
color: #409eff;
.health-score-ring {
display: flex;
justify-content: center;
padding: 8px 0 16px;
}
.score-label {
font-size: 14px;
color: #909399;
margin-top: 10px;
.score-inner {
display: flex;
flex-direction: column;
align-items: center;
}
.score-inner strong {
font-size: 32px;
font-weight: 700;
color: var(--mon-ink);
line-height: 1;
}
.score-inner span {
font-size: 12px;
color: var(--mon-muted);
margin-top: 4px;
}
.health-issues {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #dcdfe6;
padding-top: 12px;
border-top: 1px solid var(--mon-border);
display: flex;
flex-direction: column;
gap: 8px;
}
.issues-title {
font-size: 14px;
font-weight: 600;
color: #606266;
margin-bottom: 10px;
}
.health-issues ul {
margin: 0;
padding-left: 20px;
li {
color: #f56c6c;
font-size: 13px;
margin: 5px 0;
}
}
.metrics-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}
.metric-card {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 20px;
text-align: center;
}
.metric-label {
.issue-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #909399;
margin-bottom: 10px;
color: #dc2626;
}
.metric-value {
font-size: 24px;
font-weight: bold;
color: #303133;
.issue-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #ef4444;
flex-shrink: 0;
}
.cache-card {
background: #f5f7fa;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 20px;
.health-ok {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
border-radius: 10px;
background: #ecfdf5;
color: #059669;
font-size: 13px;
font-weight: 500;
}
.cache-header {
margin-bottom: 20px;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.ok-icon {
font-size: 16px;
font-weight: 700;
}
.cache-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.cache-stat {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 15px;
.cache-stat.primary {
padding: 14px;
background: #f8fafc;
border-radius: 12px;
}
.stat-label {
font-size: 13px;
color: #909399;
.cache-stat-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.stat-value {
font-size: 20px;
font-weight: bold;
color: #303133;
margin-top: 10px;
.cache-stat-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
:deep(.el-progress) {
margin: 10px 0;
}
.alerts-card {
background: #f5f7fa;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 20px;
}
.alerts-header {
.cache-stat-mini {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-direction: column;
gap: 6px;
padding: 12px;
background: #f8fafc;
border-radius: 10px;
}
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
.cache-stat-mini strong {
font-size: 20px;
font-weight: 700;
color: var(--mon-ink);
}
.stat-label {
font-size: 12px;
color: var(--mon-muted);
font-weight: 500;
}
.stat-value {
font-size: 18px;
font-weight: 700;
}
.denied-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.denied-row {
display: grid;
grid-template-columns: 32px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 10px 12px;
border-radius: 10px;
transition: background 0.15s ease;
}
.denied-row:hover {
background: #f8fafc;
}
.denied-rank {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 8px;
background: #f1f5f9;
color: #94a3b8;
font-size: 12px;
font-weight: 700;
}
.denied-rank.top {
background: linear-gradient(135deg, #ef4444, #f87171);
color: #fff;
}
.denied-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.denied-info strong {
font-size: 13px;
font-weight: 600;
color: var(--mon-ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.denied-info small {
font-size: 11px;
color: var(--mon-muted);
}
.denied-count {
font-size: 16px;
font-weight: 700;
color: #ef4444;
}
.alerts-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.alert-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid var(--mon-border);
transition: background 0.15s ease;
}
.alert-row:hover {
background: #f8fafc;
}
.alert-row.error {
border-color: #fecaca;
background: #fef2f2;
}
.alert-row.warning {
border-color: #fed7aa;
background: #fffbeb;
}
.alert-body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.alert-body strong {
font-size: 13px;
font-weight: 600;
color: var(--mon-ink);
}
.alert-body span {
font-size: 12px;
color: var(--mon-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.alert-time {
font-size: 11px;
color: #94a3b8;
white-space: nowrap;
}
@media (max-width: 1200px) {
.stats-summary {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.alerts-table-wrapper {
overflow-x: auto;
@media (max-width: 900px) {
.stats-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.overview-grid {
grid-template-columns: 1fr;
}
}
.empty-state {
padding: 40px 20px;
text-align: center;
@media (max-width: 600px) {
.stats-summary {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./PermissionTemplateSelector.vue"), "utf8");
describe("PermissionTemplateSelector", () => {
it("renders compact role permission cards without footer placeholder space", () => {
const source = readSource();
expect(source).toContain("角色权限概览");
expect(source).toContain("align-items: start");
expect(source).toContain("padding: 12px 14px");
expect(source).not.toContain("card-footer");
expect(source).not.toContain("点击编辑权限");
expect(source).not.toContain("inactive-label");
expect(source).not.toContain("edit-hint");
});
});
@@ -1,61 +1,62 @@
<template>
<div class="template-selector">
<div class="selector-header">
<h3>角色权限概览</h3>
<p class="selector-desc">展示当前项目各角色权限配置状态</p>
<div class="selector-title-group">
<h3>角色权限概览</h3>
<p class="selector-desc">展示当前项目各角色的权限配置状态</p>
</div>
<el-tag effect="plain" size="small" type="info" round>{{ templates.length }} 个角色</el-tag>
</div>
<div class="template-grid">
<el-tooltip
<div
v-for="template in templates"
:key="template.id"
:content="template.description"
placement="top"
:disabled="!template.description"
>
<div
class="template-card"
:class="{ inactive: template.category && !isRoleActive(template.category) }"
@click="template.category && emit('edit-role', template.category!)"
>
<div class="card-header">
<div class="card-top">
<span class="card-icon">{{ roleIcon(template.category) }}</span>
<div class="card-title-wrap">
<span class="card-name">{{ template.category ? (ROLE_LABELS[template.category] || template.name) : template.name }}</span>
<el-tag v-if="!template.is_system" size="small" type="success">自定义</el-tag>
<span v-if="template.description" class="card-desc">{{ template.description }}</span>
</div>
<el-button
v-if="template.category"
size="small"
type="primary"
link
class="edit-role-btn"
@click.stop="emit('edit-role', template.category!)"
>编辑权限</el-button>
<el-tag v-if="!template.is_system" size="small" effect="dark" round type="success" class="card-badge">自定义</el-tag>
</div>
<div class="card-stats">
<template v-if="props.currentPermissions && template.category && props.currentPermissions[template.category]">
<span class="stat">
<el-icon><Check /></el-icon>
{{ countCurrentEnabled(template.category) }} 项已启用
</span>
<span class="stat disabled">
<el-icon><Close /></el-icon>
{{ countCurrentDisabled(template.category) }} 项已禁
</span>
<div class="stat-bar">
<div class="stat-fill" :style="{ width: enabledPercent(template.category) + '%' }" />
</div>
<div class="stat-numbers">
<span class="stat enabled">
<span class="stat-dot green" />
{{ countCurrentEnabled(template.category) }}
</span>
<span class="stat disabled">
<span class="stat-dot red" />
{{ countCurrentDisabled(template.category) }} 禁用
</span>
</div>
</template>
<template v-else>
<span class="stat">
<el-icon><Check /></el-icon>
{{ countEnabled(template) }} 项已启用
</span>
<span class="stat disabled">
<el-icon><Close /></el-icon>
{{ countDisabled(template) }} 项已禁
</span>
<div class="stat-bar">
<div class="stat-fill" :style="{ width: templateEnabledPercent(template) + '%' }" />
</div>
<div class="stat-numbers">
<span class="stat enabled">
<span class="stat-dot green" />
{{ countEnabled(template) }}
</span>
<span class="stat disabled">
<span class="stat-dot red" />
{{ countDisabled(template) }} 禁用
</span>
</div>
</template>
</div>
</div>
</el-tooltip>
</div>
</div>
</template>
@@ -63,7 +64,6 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { ElMessage } from "element-plus";
import { Check, Close } from "@element-plus/icons-vue";
import {
fetchPermissionTemplates,
type PermissionTemplate,
@@ -110,6 +110,12 @@ const countDisabled = (t: PermissionTemplate) => {
return n;
};
const templateEnabledPercent = (t: PermissionTemplate) => {
const enabled = countEnabled(t);
const total = enabled + countDisabled(t);
return total > 0 ? Math.round((enabled / total) * 100) : 0;
};
const countCurrentEnabled = (role: string) => {
const perms = props.currentPermissions?.[role];
if (!perms) return 0;
@@ -122,6 +128,12 @@ const countCurrentDisabled = (role: string) => {
return Object.values(perms).filter((v) => !v).length;
};
const enabledPercent = (role: string) => {
const enabled = countCurrentEnabled(role);
const total = enabled + countCurrentDisabled(role);
return total > 0 ? Math.round((enabled / total) * 100) : 0;
};
const loadTemplates = async () => {
try {
const res = await fetchPermissionTemplates();
@@ -136,117 +148,155 @@ onMounted(loadTemplates);
<style scoped>
.template-selector {
padding: 16px 0;
padding: 8px 0 12px;
}
.selector-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16px;
}
.selector-header h3 {
.selector-title-group h3 {
margin: 0 0 4px;
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
color: #1a2332;
}
.selector-desc {
margin: 0;
font-size: 13px;
color: #888;
font-size: 12px;
color: #64748b;
}
.template-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
align-items: start;
gap: 12px;
margin-bottom: 16px;
margin-bottom: 12px;
}
.template-card {
padding: 14px;
border: 1.5px solid #e4e7ed;
border-radius: 8px;
transition: all 0.2s;
position: relative;
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
}
.template-card:hover {
border-color: #93c5fd;
box-shadow: 0 4px 14px rgba(59, 130, 246, 0.1);
transform: translateY(-2px);
}
.template-card.inactive {
border-style: dashed;
background: #fafafa;
opacity: 0.75;
border-color: #e2e8f0;
background: #fafbfc;
opacity: 0.7;
}
.template-card:hover {
border-color: #c0c4cc;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.template-card.inactive:hover {
border-color: #cbd5e1;
box-shadow: none;
transform: none;
}
.template-card:not(.inactive):hover {
border-color: #409eff;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15);
}
.card-header {
.card-top {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
align-items: flex-start;
gap: 10px;
margin-bottom: 12px;
}
.card-icon {
font-size: 24px;
line-height: 1;
flex-shrink: 0;
}
.card-title-wrap {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
gap: 2px;
}
.card-name {
font-size: 14px;
font-weight: 600;
color: #303133;
color: #1a2332;
}
.template-card.inactive .card-name {
color: #909399;
color: #94a3b8;
}
.edit-role-btn {
margin-left: auto;
opacity: 0;
transition: opacity 0.15s;
.card-desc {
font-size: 11px;
color: #94a3b8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.template-card:hover .edit-role-btn {
opacity: 1;
}
.card-icon {
font-size: 20px;
line-height: 1;
.card-badge {
flex-shrink: 0;
}
.card-stats {
display: flex;
gap: 10px;
font-size: 12px;
color: #67c23a;
flex-direction: column;
gap: 8px;
}
.template-card.inactive .card-stats {
color: #b0b0b0;
.stat-bar {
height: 4px;
border-radius: 2px;
background: #f1f5f9;
overflow: hidden;
}
.card-stats .stat {
.stat-fill {
height: 100%;
border-radius: 2px;
background: linear-gradient(90deg, #10b981, #34d399);
transition: width 0.3s ease;
}
.stat-numbers {
display: flex;
gap: 12px;
}
.stat {
display: flex;
align-items: center;
gap: 3px;
gap: 4px;
font-size: 12px;
color: #64748b;
}
.card-stats .stat.disabled {
color: #f56c6c;
.stat-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.template-card.inactive .card-stats .stat.disabled {
color: #c0c4cc;
}
.stat-dot.green { background: #10b981; }
.stat-dot.red { background: #ef4444; }
.template-card.inactive .stat-dot.green { background: #94a3b8; }
.template-card.inactive .stat-dot.red { background: #cbd5e1; }
.template-card.inactive .stat-fill { background: #cbd5e1; }
</style>
@@ -0,0 +1,324 @@
<template>
<div class="trend-charts">
<div class="trend-toolbar">
<div class="toolbar-left">
<span class="toolbar-title">数据趋势</span>
<span class="toolbar-desc">查看权限系统各项指标的变化趋势</span>
</div>
<el-radio-group v-model="period" size="small" @change="loadData">
<el-radio-button value="24h">24小时</el-radio-button>
<el-radio-button value="7d">7</el-radio-button>
<el-radio-button value="30d">30</el-radio-button>
</el-radio-group>
</div>
<div v-loading="loading" class="charts-grid">
<div class="chart-card">
<div class="chart-header">
<div class="chart-icon blue">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
</div>
<h4>检查量趋势</h4>
</div>
<v-chart :option="checksChartOption" autoresize class="chart" />
</div>
<div class="chart-card">
<div class="chart-header">
<div class="chart-icon cyan">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</div>
<h4>响应时间趋势</h4>
</div>
<v-chart :option="responseTimeOption" autoresize class="chart" />
</div>
<div class="chart-card">
<div class="chart-header">
<div class="chart-icon green">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
</div>
<h4>缓存命中率</h4>
</div>
<v-chart :option="cacheHitOption" autoresize class="chart" />
</div>
<div class="chart-card">
<div class="chart-header">
<div class="chart-icon danger">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
</div>
<h4>拒绝率趋势</h4>
</div>
<v-chart :option="denyRateOption" autoresize class="chart" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import VChart from "vue-echarts";
import { use } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { LineChart, BarChart } from "echarts/charts";
import {
TitleComponent,
TooltipComponent,
GridComponent,
LegendComponent,
} from "echarts/components";
import { fetchPermissionTrends } from "@/api/projectPermissions";
import type { TrendDataPoint } from "@/types/api";
use([CanvasRenderer, LineChart, BarChart, TitleComponent, TooltipComponent, GridComponent, LegendComponent]);
const period = ref<"24h" | "7d" | "30d">("24h");
const loading = ref(false);
const dataPoints = ref<TrendDataPoint[]>([]);
const formatTime = (iso: string) => {
const d = new Date(iso);
if (period.value === "24h") return `${d.getHours()}:00`;
return `${d.getMonth() + 1}/${d.getDate()}`;
};
const xLabels = computed(() => dataPoints.value.map((p: TrendDataPoint) => formatTime(p.bucket_time)));
const baseGridOption = {
grid: { left: 50, right: 20, top: 20, bottom: 25 },
tooltip: { trigger: "axis" as const },
};
const checksChartOption = computed(() => ({
grid: { left: 50, right: 20, top: 20, bottom: 50 },
tooltip: { trigger: "axis" as const },
legend: { data: ["允许", "拒绝"], bottom: 0, itemGap: 20 },
xAxis: { type: "category", data: xLabels.value, axisLine: { lineStyle: { color: "#e2e8f0" } }, axisLabel: { color: "#64748b" } },
yAxis: { type: "value", minInterval: 1, splitLine: { lineStyle: { color: "#f1f5f9" } }, axisLabel: { color: "#64748b" } },
series: [
{
name: "允许",
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 6,
data: dataPoints.value.map((p: TrendDataPoint) => p.allowed_checks),
itemStyle: { color: "#10b981" },
lineStyle: { width: 2.5 },
areaStyle: { color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "rgba(16,185,129,0.2)" }, { offset: 1, color: "rgba(16,185,129,0)" }] } },
},
{
name: "拒绝",
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 6,
data: dataPoints.value.map((p: TrendDataPoint) => p.denied_checks),
itemStyle: { color: "#ef4444" },
lineStyle: { width: 2.5 },
areaStyle: { color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "rgba(239,68,68,0.15)" }, { offset: 1, color: "rgba(239,68,68,0)" }] } },
},
],
}));
const responseTimeOption = computed(() => ({
grid: { left: 50, right: 20, top: 20, bottom: 50 },
tooltip: { trigger: "axis" as const },
legend: { data: ["平均响应时间", "最大响应时间"], bottom: 0, itemGap: 20 },
xAxis: { type: "category", data: xLabels.value, axisLine: { lineStyle: { color: "#e2e8f0" } }, axisLabel: { color: "#64748b" } },
yAxis: { type: "value", name: "ms", splitLine: { lineStyle: { color: "#f1f5f9" } }, axisLabel: { color: "#64748b" } },
series: [
{
name: "平均响应时间",
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 6,
data: dataPoints.value.map((p: TrendDataPoint) => p.avg_elapsed_ms),
itemStyle: { color: "#3b82f6" },
lineStyle: { width: 2.5 },
areaStyle: { color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "rgba(59,130,246,0.15)" }, { offset: 1, color: "rgba(59,130,246,0)" }] } },
},
{
name: "最大响应时间",
type: "line",
smooth: true,
symbol: "diamond",
symbolSize: 6,
data: dataPoints.value.map((p: TrendDataPoint) => p.max_elapsed_ms),
itemStyle: { color: "#f59e0b" },
lineStyle: { width: 2, type: "dashed" },
},
],
}));
const cacheHitOption = computed(() => ({
grid: { left: 50, right: 20, top: 20, bottom: 25 },
tooltip: { trigger: "axis" as const },
xAxis: { type: "category", data: xLabels.value, axisLine: { lineStyle: { color: "#e2e8f0" } }, axisLabel: { color: "#64748b" } },
yAxis: { type: "value", max: 100, name: "%", splitLine: { lineStyle: { color: "#f1f5f9" } }, axisLabel: { color: "#64748b" } },
series: [
{
name: "缓存命中率",
type: "bar",
barMaxWidth: 24,
itemStyle: {
borderRadius: [4, 4, 0, 0],
color: (params: any) => {
const val = params.value;
if (val >= 80) return { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "#34d399" }, { offset: 1, color: "#10b981" }] };
if (val >= 50) return { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "#fbbf24" }, { offset: 1, color: "#f59e0b" }] };
return { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "#f87171" }, { offset: 1, color: "#ef4444" }] };
},
},
data: dataPoints.value.map((p: TrendDataPoint) => p.cache_hit_rate),
},
],
}));
const denyRateOption = computed(() => ({
grid: { left: 50, right: 20, top: 20, bottom: 25 },
tooltip: { trigger: "axis" as const },
xAxis: { type: "category", data: xLabels.value, axisLine: { lineStyle: { color: "#e2e8f0" } }, axisLabel: { color: "#64748b" } },
yAxis: { type: "value", max: 100, name: "%", splitLine: { lineStyle: { color: "#f1f5f9" } }, axisLabel: { color: "#64748b" } },
series: [
{
name: "拒绝率",
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 6,
data: dataPoints.value.map((p: TrendDataPoint) =>
p.total_checks > 0 ? Math.round((p.denied_checks / p.total_checks) * 1000) / 10 : 0
),
itemStyle: { color: "#ef4444" },
lineStyle: { width: 2.5 },
areaStyle: { color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "rgba(239,68,68,0.2)" }, { offset: 1, color: "rgba(239,68,68,0)" }] } },
},
],
}));
const loadData = async () => {
loading.value = true;
try {
const res = await fetchPermissionTrends(period.value);
dataPoints.value = res.data.data_points;
} catch {
dataPoints.value = [];
} finally {
loading.value = false;
}
};
onMounted(loadData);
defineExpose({ refresh: loadData });
</script>
<style scoped>
.trend-charts {
--trend-ink: #1a2332;
--trend-muted: #64748b;
--trend-border: #e2e8f0;
--trend-radius: 16px;
--trend-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
}
.trend-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
margin-bottom: 16px;
background: #fff;
border: 1px solid var(--trend-border);
border-radius: var(--trend-radius);
box-shadow: var(--trend-shadow);
}
.toolbar-left {
display: flex;
flex-direction: column;
gap: 2px;
}
.toolbar-title {
font-size: 15px;
font-weight: 600;
color: var(--trend-ink);
}
.toolbar-desc {
font-size: 12px;
color: var(--trend-muted);
}
.charts-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
}
.chart-card {
background: #fff;
border: 1px solid var(--trend-border);
border-radius: var(--trend-radius);
padding: 18px;
box-shadow: var(--trend-shadow);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.chart-card:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.07);
}
.chart-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.chart-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
flex-shrink: 0;
}
.chart-icon svg {
width: 16px;
height: 16px;
}
.chart-icon.blue { background: #eff6ff; color: #3b82f6; }
.chart-icon.cyan { background: #ecfeff; color: #06b6d4; }
.chart-icon.green { background: #ecfdf5; color: #10b981; }
.chart-icon.danger { background: #fef2f2; color: #ef4444; }
.chart-card h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--trend-ink);
}
.chart {
height: 240px;
width: 100%;
}
@media (max-width: 900px) {
.charts-grid {
grid-template-columns: 1fr;
}
.trend-toolbar {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
}
</style>
@@ -0,0 +1,14 @@
// 地图数据来源:https://geojson.cn/api/china/1.6.3/china.json
// 组件直接导入本地缓存的 GeoJSON,避免运行时依赖外部网络。
export const normalizeProvinceName = (value?: string | null) => {
return String(value || "")
.trim()
.replace(/$/u, "")
.replace(/$/u, "")
.replace(/$/u, "")
.replace(/$/u, "")
.replace(/$/u, "")
.replace(/$/u, "")
.replace(/$/u, "");
};
+3 -3
View File
@@ -8,13 +8,13 @@ describe("admin project route permissions", () => {
it("allows project admin pages through matrix-authorized PMs instead of all project roles", () => {
const source = readRouter();
expect(source).toContain('adminProjectPermission: { module: "project_members", action: "read" }');
expect(source).toContain('adminProjectPermission: { module: "project_members", action: "write" }');
expect(source).toContain('adminProjectPermission: { module: "sites", action: "read" }');
expect(source).toContain('adminProjectPermission: { module: "audit_export", action: "read" }');
expect(source).toContain("meta: { title: TEXT.menu.projectManagement }");
expect(source).toContain("ADMIN_PROJECT_OPERATION_KEYS");
expect(source).toContain('if (role !== "PM") return false;');
expect(source).toContain("roles?.[role]?.[permission.module]?.[permission.action]");
expect(source).toContain("isApiPermissionAllowed");
expect(source).toContain("studyStore.currentPermissions?.[role]?.[operationKey]");
});
it("keeps login landing independent from PM management backend access", () => {
+11 -3
View File
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { isSystemAdmin } from "../utils/roles";
import { isApiPermissionAllowed } from "../utils/apiPermissionValue";
import { findFirstAccessibleProjectPath, getProjectRoutePermission, hasProjectPermission } from "../utils/projectRoutePermissions";
import { fetchStudyDetail } from "../api/studies";
import { fetchApiEndpointPermissions } from "../api/projectPermissions";
@@ -489,6 +490,12 @@ const router = createRouter({
routes,
});
const ADMIN_PROJECT_OPERATION_KEYS: Record<string, Record<"read" | "write", string>> = {
audit_export: { read: "audit_logs:read", write: "audit_logs:read" },
project_members: { read: "project_members:list", write: "project_members:update" },
sites: { read: "sites:read", write: "sites:update" },
};
const ensureAdminProjectAccess = async (to: any, isAdmin: boolean) => {
if (isAdmin) return true;
const studyStore = useStudyStore();
@@ -509,8 +516,9 @@ const ensureAdminProjectAccess = async (to: any, isAdmin: boolean) => {
const role = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || "";
if (role !== "PM") return false;
// 管理员项目权限检查:PM 角色默认有访问权限
return true;
const operationKey = ADMIN_PROJECT_OPERATION_KEYS[permission.module]?.[permission.action];
if (!operationKey) return false;
return isApiPermissionAllowed(studyStore.currentPermissions?.[role]?.[operationKey]);
};
router.beforeEach(async (to, _from, next) => {
@@ -578,7 +586,7 @@ router.beforeEach(async (to, _from, next) => {
await studyStore.loadCurrentStudyPermissions().catch(() => {});
}
const role = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || "";
const canReadAudit = !!studyStore.currentPermissions?.[role]?.["audit_logs:read"]?.allowed;
const canReadAudit = isApiPermissionAllowed(studyStore.currentPermissions?.[role]?.["audit_logs:read"]);
if (!(isAdmin || (role === "PM" && canReadAudit))) {
next({ path: "/project/overview" });
return;
+1 -1
View File
@@ -117,7 +117,7 @@ describe("study store startup rehydration", () => {
status: "ACTIVE",
role_in_study: "PM",
} as any);
study.currentPermissions = { roles: { PM: { project_members: { read: true, write: true } } } };
study.currentPermissions = { PM: { "project_members:update": { allowed: true } } };
study.setCurrentStudy({
id: "study-b",
+154 -3
View File
@@ -131,11 +131,11 @@ export interface Site {
}
// 接口级权限
export type ApiPermissionValue = boolean | { allowed: boolean };
export interface ApiEndpointPermissionsResponse {
[role: string]: {
[endpoint_key: string]: {
allowed: boolean;
};
[endpoint_key: string]: ApiPermissionValue;
};
}
@@ -204,3 +204,154 @@ export interface HealthResponse {
metrics: PermissionMetricsResponse;
cache_stats: CacheStatsResponse;
}
// 权限监控 - 访问日志
export interface AccessLogItem {
id: string;
study_id: string;
user_id: string;
user_name: string;
endpoint_key: string;
role: string;
allowed: boolean;
elapsed_ms: number;
ip_address: string | null;
ip_location: string;
ip_country: string;
ip_province: string;
ip_city: string;
ip_isp: string;
created_at: string;
}
export interface AccessLogsSummary {
total_count: number;
unique_user_count: number;
unique_ip_count: number;
denied_count: number;
avg_elapsed_ms: number;
}
export interface AccessLogUserStat {
user_id: string;
user_name: string;
role: string;
total_count: number;
denied_count: number;
unique_ip_count: number;
sample_ip_address: string | null;
last_seen_at: string | null;
primary_location: string;
}
export interface AccessLogsResponse {
total: number;
page: number;
page_size: number;
summary: AccessLogsSummary;
user_stats: AccessLogUserStat[];
items: AccessLogItem[];
}
export interface SecurityAccessLogItem {
id: string;
method: string;
path: string;
status_code: number;
elapsed_ms: number;
client_ip: string | null;
user_agent: string | null;
auth_status: "ANONYMOUS" | "INVALID_TOKEN" | "AUTHENTICATED" | string;
user_identifier: string | null;
account_label: string;
created_at: string;
}
export interface SecurityAccessLogsResponse {
total: number;
page: number;
page_size: number;
summary: {
total_count: number;
anonymous_count: number;
invalid_token_count: number;
error_count: number;
};
items: SecurityAccessLogItem[];
}
// 权限监控 - 趋势数据
export interface TrendDataPoint {
bucket_time: string;
total_checks: number;
allowed_checks: number;
denied_checks: number;
avg_elapsed_ms: number;
max_elapsed_ms: number;
cache_hits: number;
cache_misses: number;
cache_hit_rate: number;
error_count: number;
}
export interface TrendsResponse {
period: string;
data_points: TrendDataPoint[];
}
// 权限监控 - 被拒绝最多
export interface TopDeniedItem {
endpoint_key: string;
role: string;
denied_count: number;
}
export interface TopDeniedResponse {
days: number;
items: TopDeniedItem[];
}
export interface IpLocationStatItem {
country: string;
province: string;
city: string;
isp: string;
location: string;
total_count: number;
allowed_count: number;
denied_count: number;
unique_ip_count: number;
unique_user_count: number;
}
export interface IpLocationsResponse {
days: number;
summary: {
total_count: number;
allowed_count: number;
denied_count: number;
unique_ip_count: number;
unique_user_count: number;
};
items: IpLocationStatItem[];
}
// 权限监控 - 统计摘要
export interface StatsSummaryResponse {
total_logs: number;
today: {
total_checks: number;
allowed_checks: number;
denied_checks: number;
avg_elapsed_ms: number;
max_elapsed_ms: number;
allow_rate: number;
deny_rate: number;
};
last_hour: {
total_checks: number;
allowed_checks: number;
denied_checks: number;
requests_per_minute: number;
};
}
+6
View File
@@ -0,0 +1,6 @@
import type { ApiPermissionValue } from "../types/api";
export const isApiPermissionAllowed = (value: ApiPermissionValue | null | undefined): boolean => {
if (typeof value === "boolean") return value;
return !!value?.allowed;
};
+5 -2
View File
@@ -12,8 +12,11 @@ describe("permission project role model", () => {
expect(source).toContain("getProjectRole");
expect(source).toContain("study.currentStudyRole");
expect(source).toContain("study.currentPermissions");
expect(source).toContain('"faq.edit": { module: "faq", action: "write" }');
expect(source).not.toContain('"faq.edit": { module: "etmf"');
expect(source).toContain('"faq.edit": "faq:update"');
expect(source).toContain("isApiPermissionAllowed");
expect(source).toContain("projectPermissions.value?.[projectRole.value]?.[operationKey]");
expect(source).not.toContain("projectPermissions.value?.roles");
expect(source).not.toContain('"faq.edit": "etmf');
expect(source).not.toContain('"ADMIN", "PM"');
});
});
+25 -25
View File
@@ -3,6 +3,7 @@ import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { TEXT } from "../locales";
import { getProjectRole, isSystemAdmin } from "./roles";
import { isApiPermissionAllowed } from "./apiPermissionValue";
const PERMISSIONS: Record<string, string[]> = {
"subject.create": ["ADMIN"],
@@ -45,32 +46,31 @@ export const usePermission = () => {
const can = (action: string): boolean => {
if (systemAdmin.value) return true;
if (!projectRole.value) return false;
const permissionMap: Record<string, { module: string; action: "read" | "write" }> = {
"subject.create": { module: "subjects", action: "write" },
"subject.enroll": { module: "subjects", action: "write" },
"subject.complete": { module: "subjects", action: "write" },
"subject.drop": { module: "subjects", action: "write" },
"ae.create": { module: "risk_issues", action: "write" },
"ae.close": { module: "risk_issues", action: "write" },
"project.overview.read": { module: "project_overview", action: "read" },
"faq.edit": { module: "faq", action: "write" },
"faq.create": { module: "faq", action: "write" },
"faq.reply": { module: "faq", action: "write" },
"knowledge.notes.write": { module: "shared_library", action: "write" },
"shared.library.read": { module: "shared_library", action: "read" },
"shared.library.write": { module: "shared_library", action: "write" },
"project.members.manage": { module: "project_members", action: "write" },
"site.manage": { module: "sites", action: "write" },
"site.cra.bind": { module: "sites", action: "write" },
"fees.contract.write": { module: "fees", action: "write" },
"fees.attachment.delete": { module: "fees", action: "write" },
"file.attachment.delete": { module: "file_versions", action: "write" },
"audit.export.read": { module: "audit_export", action: "read" },
const permissionMap: Record<string, string> = {
"subject.create": "subjects:create",
"subject.enroll": "subjects:update",
"subject.complete": "subjects:update",
"subject.drop": "subjects:update",
"ae.create": "risk_issues:create",
"ae.close": "risk_issues:update",
"project.overview.read": "project_overview:read",
"faq.edit": "faq:update",
"faq.create": "faq:create",
"faq.reply": "faq_reply:create",
"knowledge.notes.write": "knowledge_notes:create",
"shared.library.read": "knowledge_notes:read",
"shared.library.write": "knowledge_notes:create",
"project.members.manage": "project_members:update",
"site.manage": "sites:update",
"site.cra.bind": "sites:update",
"fees.contract.write": "fees_contracts:create",
"fees.attachment.delete": "fees_attachments:delete",
"file.attachment.delete": "attachments:delete",
"audit.export.read": "audit_logs:read",
};
const mapped = permissionMap[action];
if (!mapped) return false;
const allowed = projectPermissions.value?.roles?.[projectRole.value]?.[mapped.module]?.[mapped.action];
return !!allowed;
const operationKey = permissionMap[action];
if (!operationKey) return false;
return isApiPermissionAllowed(projectPermissions.value?.[projectRole.value]?.[operationKey]);
};
const reason = (action: string): string => {
@@ -2,18 +2,22 @@ import { describe, expect, it } from "vitest";
import { findFirstAccessibleProjectPath, getProjectRoutePermission, hasProjectPermission } from "./projectRoutePermissions";
describe("project route permissions", () => {
it("maps project routes to read permission modules", () => {
expect(getProjectRoutePermission("/project/overview")).toEqual({ module: "project_overview", action: "read" });
expect(getProjectRoutePermission("/knowledge/medical-consult/abc")).toEqual({ module: "faq", action: "read" });
expect(getProjectRoutePermission("/knowledge/notes/new")).toEqual({ module: "shared_library", action: "read" });
expect(getProjectRoutePermission("/startup/kickoff/new")).toEqual({ module: "startup_auth", action: "read" });
it("maps project routes to backend operation keys", () => {
expect(getProjectRoutePermission("/project/overview")).toEqual({ operationKey: "project_overview:read" });
expect(getProjectRoutePermission("/project/milestones")).toEqual({ operationKey: "project_milestones:read" });
expect(getProjectRoutePermission("/finance/contracts")).toEqual({ operationKey: "finance_contracts:read" });
expect(getProjectRoutePermission("/drug/shipments")).toEqual({ operationKey: "drug_shipments:read" });
expect(getProjectRoutePermission("/startup/feasibility/new")).toEqual({ operationKey: "ethics:read" });
expect(getProjectRoutePermission("/startup/kickoff/new")).toEqual({ operationKey: "startup_auth:read" });
expect(getProjectRoutePermission("/knowledge/medical-consult/abc")).toEqual({ operationKey: "faq:read" });
expect(getProjectRoutePermission("/knowledge/notes/new")).toEqual({ operationKey: "knowledge_notes:read" });
});
it("rejects project routes when the role lacks module read permission", () => {
it("rejects project routes when the role lacks operation permission", () => {
const matrix = {
CRA: {
project_overview: { read: false, write: false },
shared_library: { read: true, write: true },
"project_overview:read": { allowed: false },
"knowledge_notes:read": { allowed: true },
},
} as any;
@@ -1,4 +1,5 @@
import type { ApiEndpointPermissionsResponse } from "../types/api";
import { isApiPermissionAllowed } from "./apiPermissionValue";
export type ProjectRoutePermission = {
operationKey: string;
@@ -7,10 +8,12 @@ export type ProjectRoutePermission = {
const routePermissions: Array<{ prefixes: string[]; permission: ProjectRoutePermission }> = [
{ prefixes: ["/project/overview"], permission: { operationKey: "project_overview:read" } },
{ prefixes: ["/project/milestones"], permission: { operationKey: "project_milestones:read" } },
{ prefixes: ["/fees/contracts", "/finance/contracts"], permission: { operationKey: "fees_contracts:read" } },
{ prefixes: ["/drug/shipments", "/materials/equipment"], permission: { operationKey: "material_equipments:read" } },
{ prefixes: ["/fees/contracts"], permission: { operationKey: "fees_contracts:read" } },
{ prefixes: ["/finance/contracts"], permission: { operationKey: "finance_contracts:read" } },
{ prefixes: ["/drug/shipments"], permission: { operationKey: "drug_shipments:read" } },
{ prefixes: ["/materials/equipment"], permission: { operationKey: "material_equipments:read" } },
{ prefixes: ["/file-versions", "/trial/", "/documents/"], permission: { operationKey: "documents:read" } },
{ prefixes: ["/startup/feasibility-ethics", "/startup/feasibility", "/startup/ethics"], permission: { operationKey: "startup_ethics:read" } },
{ prefixes: ["/startup/feasibility-ethics", "/startup/feasibility", "/startup/ethics"], permission: { operationKey: "ethics:read" } },
{ prefixes: ["/startup/meeting-auth", "/startup/kickoff", "/startup/training"], permission: { operationKey: "startup_auth:read" } },
{ prefixes: ["/subjects"], permission: { operationKey: "subjects:read" } },
{ prefixes: ["/risk-issues"], permission: { operationKey: "risk_issues:read" } },
@@ -57,7 +60,7 @@ export const hasProjectPermission = (
) => {
if (!permission || isAdmin) return true;
if (!role || !permissions) return false;
return !!permissions[role]?.[permission.operationKey]?.allowed;
return isApiPermissionAllowed(permissions[role]?.[permission.operationKey]);
};
export const findFirstAccessibleProjectPath = (
+26 -21
View File
@@ -1,8 +1,28 @@
import { describe, it, expect, beforeEach } from "vitest";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { mount } from "@vue/test-utils";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import ApiPermissions from "@/views/admin/ApiPermissions.vue";
import { createPinia, setActivePinia } from "pinia";
vi.mock("vue-router", async () => {
const actual = await vi.importActual<typeof import("vue-router")>("vue-router");
return {
...actual,
useRoute: () => ({ params: { id: "study-1" } }),
};
});
vi.mock("@/api/projectPermissions", () => ({
fetchApiEndpointPermissions: vi.fn().mockResolvedValue({ data: {} }),
updateApiEndpointPermissions: vi.fn().mockResolvedValue({ data: {} }),
fetchPermissionMetrics: vi.fn().mockResolvedValue({ data: {} }),
fetchCacheStats: vi.fn().mockResolvedValue({ data: {} }),
fetchPermissionAlerts: vi.fn().mockResolvedValue({ data: { total: 0, alerts: [] } }),
fetchPermissionHealth: vi.fn().mockResolvedValue({ data: {} }),
resetPermissionMetrics: vi.fn().mockResolvedValue({ data: undefined }),
}));
describe("ApiPermissions.vue", () => {
beforeEach(() => {
setActivePinia(createPinia());
@@ -23,30 +43,15 @@ describe("ApiPermissions.vue", () => {
});
it("renders tabs", () => {
const wrapper = mount(ApiPermissions, {
global: {
stubs: {
ApiEndpointPermissions: true,
PermissionMonitoring: true,
},
},
});
const source = readFileSync(resolve(__dirname, "./ApiPermissions.vue"), "utf8");
const tabs = wrapper.findAll(".el-tabs__nav-item");
expect(tabs.length).toBeGreaterThanOrEqual(2);
expect(source).toContain('label="角色权限"');
expect(source).toContain('label="权限监控"');
});
it("has save button disabled when not dirty", () => {
const wrapper = mount(ApiPermissions, {
global: {
stubs: {
ApiEndpointPermissions: true,
PermissionMonitoring: true,
},
},
});
const source = readFileSync(resolve(__dirname, "./ApiPermissions.vue"), "utf8");
const saveButton = wrapper.findAll("button").find((btn) => btn.text().includes("保存"));
expect(saveButton?.attributes("disabled")).toBeDefined();
expect(source).toContain(':disabled="!dirty"');
});
});
+14 -3
View File
@@ -156,10 +156,10 @@ const onApiMatrixUpdate = (newMatrix: ApiEndpointPermissionsResponse) => {
const currentPermissionsForTemplate = computed(() => {
if (!apiMatrix.value) return undefined;
const result: Record<string, Record<string, boolean>> = {};
for (const [role, endpoints] of Object.entries(apiMatrix.value as Record<string, Record<string, { allowed: boolean }>>)) {
for (const [role, endpoints] of Object.entries(apiMatrix.value)) {
result[role] = {};
for (const [key, val] of Object.entries(endpoints)) {
result[role][key] = val.allowed;
result[role][key] = typeof val === "boolean" ? val : val.allowed;
}
}
return result;
@@ -171,13 +171,24 @@ const onTemplateApplied = async (permissions: Record<string, Record<string, { al
ElMessage.success("权限已更新");
};
const flattenMatrix = (matrix: ApiEndpointPermissionsResponse): Record<string, Record<string, boolean>> => {
const result: Record<string, Record<string, boolean>> = {};
for (const [role, endpoints] of Object.entries(matrix)) {
result[role] = {};
for (const [key, val] of Object.entries(endpoints)) {
result[role][key] = typeof val === "boolean" ? val : val.allowed;
}
}
return result;
};
const save = async () => {
if (!studyId.value || !dirty.value) return;
saving.value = true;
try {
if (apiMatrix.value) {
const res = await updateApiEndpointPermissions(studyId.value, apiMatrix.value);
const res = await updateApiEndpointPermissions(studyId.value, flattenMatrix(apiMatrix.value));
apiMatrix.value = res.data;
}
+2 -2
View File
@@ -13,8 +13,8 @@ describe("audit logs access", () => {
expect(source).toContain("getProjectRole");
expect(source).toContain("study.currentStudyRole");
expect(source).toContain('projectRole.value === "PM"');
expect(source).toContain("roles?.[projectRole.value]?.audit_export?.read");
expect(source).not.toContain("roles?.PM?.audit_export?.read");
expect(source).toContain("isApiPermissionAllowed");
expect(source).toContain('permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]');
expect(source).not.toContain('const role = auth.user?.role');
expect(source).not.toContain('role !== "ADMIN"');
});
+3 -2
View File
@@ -177,6 +177,7 @@ import { roleDict, getDictLabel } from "../../dictionaries";
import { exportAuditCsv } from "../../audit/export/auditExportService";
import { displayDateTime } from "../../utils/display";
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { TEXT } from "../../locales";
const study = useStudyStore();
@@ -216,11 +217,11 @@ const isAdmin = computed(() => isSystemAdmin(auth.user));
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
const canProjectExport = computed(() => {
if (isAdmin.value) return true;
return projectRole.value === "PM" && !!permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]?.allowed;
return projectRole.value === "PM" && isApiPermissionAllowed(permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]);
});
const canAccessAuditLogs = computed(() => {
if (isAdmin.value) return true;
return projectRole.value === "PM" && !!permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]?.allowed;
return projectRole.value === "PM" && isApiPermissionAllowed(permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]);
});
const ensureAccess = () => {
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./PermissionManagement.vue"), "utf8");
describe("permission management custom roles", () => {
it("uses role management wording in the drawer", () => {
const source = readSource();
expect(source).toContain('title="角色管理"');
expect(source).toContain('label="角色列表"');
expect(source).toContain('placeholder="角色类型"');
expect(source).toContain("新增角色");
expect(source).toContain('label="角色名称"');
expect(source).toContain("roleDisplayName(row)");
expect(source).not.toContain('title="权限模板管理"');
expect(source).not.toContain('label="模板列表"');
expect(source).not.toContain("新增模板");
});
it("keeps custom active roles configurable for permissions and members", () => {
const source = readSource();
expect(source).toContain("customRoleInput");
expect(source).toContain("addCustomRole");
expect(source).toContain("roleOptions");
expect(source).toContain("ROLE_LABELS[role] || role");
expect(source).toContain("ROLE_LABELS[row.category] || row.name");
expect(source).toContain("await loadPermissionData();");
expect(source).toContain('activeRolesInStudy.value[0] || ""');
expect(source).toContain('role === "ADMIN"');
});
it("does not render the duplicated monitoring header", () => {
const source = readSource();
expect(source).toContain("<PermissionMonitoring />");
expect(source).not.toContain("实时监控权限使用情况、趋势分析与异常告警");
expect(source).not.toContain("重置指标");
expect(source).not.toContain("refreshMonitoring");
expect(source).not.toContain("doResetMetrics");
});
it("does not render the duplicated system permissions header", () => {
const source = readSource();
expect(source).toContain("systemPermissionsByModule");
expect(source).not.toContain("<h2 class=\"perm-title\">系统级权限</h2>");
expect(source).not.toContain("只读展示,所有系统管理操作仅限 ADMIN 角色执行");
});
it("removes the vertical gap between permission headers and body content", () => {
const source = readSource();
expect(source).toContain("padding: 0 0 20px;");
expect(source).not.toContain("padding: 20px 0 20px;");
});
});
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -82,6 +82,7 @@ import ProjectForm from "./ProjectForm.vue";
import { useStudyStore } from "../../store/study";
import { useAuthStore } from "../../store/auth";
import { isSystemAdmin } from "../../utils/roles";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { TEXT } from "../../locales";
const projects = ref<Study[]>([]);
@@ -127,7 +128,7 @@ const openCreate = () => {
};
const MODULE_TO_OPERATION: Record<string, Record<string, string>> = {
project_members: { read: "project_members:read", write: "project_members:update" },
project_members: { read: "project_members:list", write: "project_members:update" },
sites: { read: "sites:read", write: "sites:update" },
audit_export: { read: "audit_logs:read", write: "audit_logs:read" },
};
@@ -137,7 +138,7 @@ const canProject = (row: Study, module: string, action: "read" | "write") => {
const role = row.role_in_study || "";
const operationKey = MODULE_TO_OPERATION[module]?.[action];
if (!operationKey) return false;
return !!permissionsByProject.value[row.id]?.[role]?.[operationKey]?.allowed;
return isApiPermissionAllowed(permissionsByProject.value[row.id]?.[role]?.[operationKey]);
};
const goMembers = (row: Study) => {
@@ -66,7 +66,6 @@ describe("route view overlays are mounted only when visible", () => {
'<el-drawer v-if="projectMilestoneEditorVisible"',
'<el-drawer v-if="siteMilestoneEditorVisible"',
'<el-drawer v-if="siteEnrollmentEditorVisible"',
'<el-drawer v-if="monitoringStrategyEditorVisible"',
'const getRollbackAxisLeftStyle = (axisX?: number | null) => ({',
':style="getRollbackAxisLeftStyle(lane.x)"',
':style="getRollbackAxisLeftStyle(row.axisX)"',
+1 -1
View File
@@ -164,7 +164,7 @@ const roleLabel = computed(() => displayEnum(TEXT.enums.userRole, projectRole.va
const emptyText = TEXT.modules.workbench.quickEmpty;
const isCraWorkbench = computed(() => projectRole.value === "CRA");
const canAccessProjectPath = (path: string) =>
hasProjectPermission(study.currentPermissions?.roles, projectRole.value, getProjectRoutePermission(path), isSystemAdmin(auth.user));
hasProjectPermission(study.currentPermissions, projectRole.value, getProjectRoutePermission(path), isSystemAdmin(auth.user));
const quickActionCandidates = [
{ label: TEXT.modules.workbench.feasibilityEthics, path: "/startup/feasibility-ethics", icon: Timer },
{ label: TEXT.modules.workbench.aeList, path: "/subjects", icon: Warning },
+5 -1
View File
@@ -12,7 +12,11 @@
"esModuleInterop": true,
"skipLibCheck": true,
"lib": ["ESNext", "DOM"],
"types": ["node", "vite/client"]
"types": ["node", "vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
+15 -4
View File
@@ -412,12 +412,23 @@ check_http_endpoint() {
local path="$1"
local url="${BASE_URL%/}$path"
log "检查接口: $url"
if ! curl -fsS "$url" >/dev/null; then
if [[ "$path" == "/api/v1/auth/login-key" ]]; then
printf '[CTMS] 登录公钥接口失败。请检查 .env 中 LOGIN_RSA_PRIVATE_KEY 是否是完整 PEM 单行转义文本。\n' >&2
local attempt=1
local max_attempts=10
local delay_seconds=2
while [[ "$attempt" -le "$max_attempts" ]]; do
if curl -fsS "$url" >/dev/null; then
return 0
fi
fail "接口检查失败: $url"
if [[ "$attempt" -lt "$max_attempts" ]]; then
log "接口未就绪,${delay_seconds}s 后重试 (${attempt}/${max_attempts})"
sleep "$delay_seconds"
fi
attempt=$((attempt + 1))
done
if [[ "$path" == "/api/v1/auth/login-key" ]]; then
printf '[CTMS] 登录公钥接口失败。请检查 .env 中 LOGIN_RSA_PRIVATE_KEY 是否是完整 PEM 单行转义文本。\n' >&2
fi
fail "接口检查失败: $url"
}
show_progress_note() {