修复(权限管理):统一 PM 系统导航与权限校验
This commit is contained in:
@@ -602,6 +602,7 @@ async def get_access_logs(
|
|||||||
end_time: Optional[datetime] = Query(None),
|
end_time: Optional[datetime] = Query(None),
|
||||||
client_ip: Optional[str] = Query(None),
|
client_ip: Optional[str] = Query(None),
|
||||||
client_type: Optional[str] = Query(None),
|
client_type: Optional[str] = Query(None),
|
||||||
|
event_type: str = Query("all", pattern="^(all|permission)$"),
|
||||||
keyword: Optional[str] = Query(None),
|
keyword: Optional[str] = Query(None),
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(50, ge=1, le=200),
|
page_size: int = Query(50, ge=1, le=200),
|
||||||
@@ -660,7 +661,14 @@ async def get_access_logs(
|
|||||||
keyword_conditions.append(PermissionAccessLog.ip_address.in_(location_ips))
|
keyword_conditions.append(PermissionAccessLog.ip_address.in_(location_ips))
|
||||||
conditions.append(or_(*keyword_conditions))
|
conditions.append(or_(*keyword_conditions))
|
||||||
|
|
||||||
include_security_logs = scope.is_admin and study_id is None and role is None and endpoint_key is None and allowed is not True
|
include_security_logs = (
|
||||||
|
event_type != "permission"
|
||||||
|
and scope.is_admin
|
||||||
|
and study_id is None
|
||||||
|
and role is None
|
||||||
|
and endpoint_key is None
|
||||||
|
and allowed is not True
|
||||||
|
)
|
||||||
event_conditions = list(conditions)
|
event_conditions = list(conditions)
|
||||||
if include_security_logs:
|
if include_security_logs:
|
||||||
matching_security_event = select(SecurityAccessLog.id).where(
|
matching_security_event = select(SecurityAccessLog.id).where(
|
||||||
@@ -1118,81 +1126,352 @@ async def get_trends(
|
|||||||
_=Depends(get_current_user),
|
_=Depends(get_current_user),
|
||||||
period: str = Query("24h", pattern="^(24h|7d|30d)$"),
|
period: str = Query("24h", pattern="^(24h|7d|30d)$"),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""获取趋势数据(从快照表或实时聚合)"""
|
"""获取权限检查趋势、周期摘要和归因数据。"""
|
||||||
scope = await resolve_monitoring_scope(db, _)
|
scope = await resolve_monitoring_scope(db, _)
|
||||||
if not scope.is_admin and not scope.study_ids:
|
if not scope.is_admin and not scope.study_ids:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
period_map = {"24h": timedelta(hours=24), "7d": timedelta(days=7), "30d": timedelta(days=30)}
|
bucket_delta, bucket_count = _trend_bucket_config(period)
|
||||||
start_time = now - period_map[period]
|
current_bucket = _trend_bucket_time(now, period)
|
||||||
|
start_time = current_bucket - bucket_delta * (bucket_count - 1)
|
||||||
|
current_duration = now - start_time
|
||||||
|
previous_end = start_time
|
||||||
|
previous_start = previous_end - current_duration
|
||||||
|
|
||||||
# 先尝试从快照表获取
|
log_buckets = await _load_trend_log_buckets(db, scope, start_time, now, period)
|
||||||
snapshot_query = (
|
cache_buckets, current_cache, previous_cache = await _load_trend_cache_buckets(
|
||||||
select(PermissionMetricSnapshot)
|
db,
|
||||||
.where(PermissionMetricSnapshot.bucket_time >= start_time)
|
scope,
|
||||||
.order_by(PermissionMetricSnapshot.bucket_time)
|
previous_start,
|
||||||
|
previous_end,
|
||||||
|
start_time,
|
||||||
|
now,
|
||||||
|
period,
|
||||||
)
|
)
|
||||||
result = await db.execute(snapshot_query)
|
summary = await _load_trend_summary(db, scope, start_time, now)
|
||||||
snapshots = result.scalars().all()
|
previous_summary = await _load_trend_summary(db, scope, previous_start, previous_end)
|
||||||
|
summary.update(current_cache)
|
||||||
|
previous_summary.update(previous_cache)
|
||||||
|
previous_summary.pop("observed_end_at", None)
|
||||||
|
|
||||||
if snapshots and scope.is_admin:
|
data_points = []
|
||||||
return {
|
for index in range(bucket_count):
|
||||||
"period": period,
|
bucket_time = start_time + bucket_delta * index
|
||||||
"data_points": [
|
values = log_buckets.get(bucket_time, _empty_trend_bucket())
|
||||||
{
|
cache_values = cache_buckets.get(bucket_time, {"cache_hits": 0, "cache_misses": 0})
|
||||||
"bucket_time": s.bucket_time.isoformat(),
|
cache_total = int(cache_values["cache_hits"]) + int(cache_values["cache_misses"])
|
||||||
"total_checks": s.total_checks,
|
total = int(values["total"])
|
||||||
"allowed_checks": s.allowed_checks,
|
data_points.append(
|
||||||
"denied_checks": s.denied_checks,
|
{
|
||||||
"avg_elapsed_ms": round(s.avg_elapsed_ms, 2),
|
"bucket_time": bucket_time.isoformat(),
|
||||||
"max_elapsed_ms": round(s.max_elapsed_ms, 2),
|
"sample_state": "partial" if bucket_time == current_bucket else "complete",
|
||||||
"cache_hits": s.cache_hits,
|
"total_checks": total,
|
||||||
"cache_misses": s.cache_misses,
|
"allowed_checks": int(values["allowed"]),
|
||||||
"cache_hit_rate": round(
|
"denied_checks": int(values["denied"]),
|
||||||
s.cache_hits / (s.cache_hits + s.cache_misses) * 100, 1
|
"avg_elapsed_ms": round(float(values["elapsed_sum"]) / total, 2) if total else 0,
|
||||||
) if (s.cache_hits + s.cache_misses) > 0 else 0,
|
"max_elapsed_ms": round(float(values["max_ms"]), 2),
|
||||||
"error_count": s.error_count,
|
"cache_hits": int(cache_values["cache_hits"]),
|
||||||
}
|
"cache_misses": int(cache_values["cache_misses"]),
|
||||||
for s in snapshots
|
"cache_hit_rate": round(int(cache_values["cache_hits"]) / cache_total * 100, 1) if cache_total else 0,
|
||||||
],
|
"cache_sample_available": cache_total > 0,
|
||||||
}
|
"error_count": 0,
|
||||||
|
}
|
||||||
# 如果没有快照数据,从原始日志实时聚合(适用于刚部署时)。
|
)
|
||||||
# 这里使用 Python 分桶,避免 SQLite 测试库不支持 PostgreSQL date_trunc。
|
|
||||||
trend_query = select(
|
|
||||||
PermissionAccessLog.created_at,
|
|
||||||
PermissionAccessLog.allowed,
|
|
||||||
PermissionAccessLog.elapsed_ms,
|
|
||||||
).where(PermissionAccessLog.created_at >= start_time)
|
|
||||||
result = await db.execute(
|
|
||||||
_apply_monitoring_scope_to_log_query(trend_query, scope)
|
|
||||||
)
|
|
||||||
buckets: dict[datetime, dict[str, float | int]] = defaultdict(
|
|
||||||
lambda: {"total": 0, "allowed": 0, "denied": 0, "elapsed_sum": 0.0, "max_ms": 0.0}
|
|
||||||
)
|
|
||||||
for created_at, allowed, elapsed_ms in result.all():
|
|
||||||
bucket = _trend_bucket_time(created_at, period)
|
|
||||||
buckets[bucket]["total"] += 1
|
|
||||||
buckets[bucket]["allowed" if allowed else "denied"] += 1
|
|
||||||
buckets[bucket]["elapsed_sum"] += float(elapsed_ms or 0)
|
|
||||||
buckets[bucket]["max_ms"] = max(float(buckets[bucket]["max_ms"]), float(elapsed_ms or 0))
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"period": period,
|
"period": period,
|
||||||
"data_points": [
|
"generated_at": now.isoformat(),
|
||||||
|
"range": {
|
||||||
|
"start_at": start_time.isoformat(),
|
||||||
|
"end_at": now.isoformat(),
|
||||||
|
"previous_start_at": previous_start.isoformat(),
|
||||||
|
"previous_end_at": previous_end.isoformat(),
|
||||||
|
"bucket_seconds": int(bucket_delta.total_seconds()),
|
||||||
|
"bucket_count": bucket_count,
|
||||||
|
"timezone": "UTC",
|
||||||
|
"observed_end_at": summary.pop("observed_end_at"),
|
||||||
|
},
|
||||||
|
"summary": summary,
|
||||||
|
"previous_summary": previous_summary,
|
||||||
|
"attribution": await _load_trend_attribution(db, scope, start_time, now),
|
||||||
|
"data_points": data_points,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _trend_bucket_config(period: str) -> tuple[timedelta, int]:
|
||||||
|
return {
|
||||||
|
"24h": (timedelta(hours=1), 24),
|
||||||
|
"7d": (timedelta(hours=6), 28),
|
||||||
|
"30d": (timedelta(days=1), 30),
|
||||||
|
}[period]
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_trend_bucket() -> dict[str, float | int]:
|
||||||
|
return {"total": 0, "allowed": 0, "denied": 0, "elapsed_sum": 0.0, "max_ms": 0.0}
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_trend_bucket(target: dict[str, float | int], *, total: int, allowed: int, denied: int, elapsed_sum: float, max_ms: float) -> None:
|
||||||
|
target["total"] = int(target["total"]) + total
|
||||||
|
target["allowed"] = int(target["allowed"]) + allowed
|
||||||
|
target["denied"] = int(target["denied"]) + denied
|
||||||
|
target["elapsed_sum"] = float(target["elapsed_sum"]) + elapsed_sum
|
||||||
|
target["max_ms"] = max(float(target["max_ms"]), max_ms)
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_trend_log_buckets(
|
||||||
|
db: AsyncSession,
|
||||||
|
scope: MonitoringScope,
|
||||||
|
start_time: datetime,
|
||||||
|
end_time: datetime,
|
||||||
|
period: str,
|
||||||
|
) -> dict[datetime, dict[str, float | int]]:
|
||||||
|
"""PostgreSQL 先按小时聚合,SQLite 测试库使用 Python 分桶。"""
|
||||||
|
buckets: dict[datetime, dict[str, float | int]] = defaultdict(_empty_trend_bucket)
|
||||||
|
dialect_name = db.get_bind().dialect.name
|
||||||
|
if dialect_name == "postgresql":
|
||||||
|
hour_bucket = func.date_trunc("hour", PermissionAccessLog.created_at).label("hour_bucket")
|
||||||
|
query = select(
|
||||||
|
hour_bucket,
|
||||||
|
func.count().label("total"),
|
||||||
|
func.count().filter(PermissionAccessLog.allowed.is_(True)).label("allowed"),
|
||||||
|
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied"),
|
||||||
|
func.coalesce(func.sum(PermissionAccessLog.elapsed_ms), 0).label("elapsed_sum"),
|
||||||
|
func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_ms"),
|
||||||
|
).where(
|
||||||
|
PermissionAccessLog.created_at >= start_time,
|
||||||
|
PermissionAccessLog.created_at <= end_time,
|
||||||
|
).group_by(hour_bucket)
|
||||||
|
result = await db.execute(_apply_monitoring_scope_to_log_query(query, scope))
|
||||||
|
for row in result.all():
|
||||||
|
bucket_time = _trend_bucket_time(row.hour_bucket, period)
|
||||||
|
_merge_trend_bucket(
|
||||||
|
buckets[bucket_time],
|
||||||
|
total=int(row.total or 0),
|
||||||
|
allowed=int(row.allowed or 0),
|
||||||
|
denied=int(row.denied or 0),
|
||||||
|
elapsed_sum=float(row.elapsed_sum or 0),
|
||||||
|
max_ms=float(row.max_ms or 0),
|
||||||
|
)
|
||||||
|
return buckets
|
||||||
|
|
||||||
|
query = select(
|
||||||
|
PermissionAccessLog.created_at,
|
||||||
|
PermissionAccessLog.allowed,
|
||||||
|
PermissionAccessLog.elapsed_ms,
|
||||||
|
).where(
|
||||||
|
PermissionAccessLog.created_at >= start_time,
|
||||||
|
PermissionAccessLog.created_at <= end_time,
|
||||||
|
)
|
||||||
|
result = await db.execute(_apply_monitoring_scope_to_log_query(query, scope))
|
||||||
|
for created_at, allowed, elapsed_ms in result.all():
|
||||||
|
bucket_time = _trend_bucket_time(created_at, period)
|
||||||
|
_merge_trend_bucket(
|
||||||
|
buckets[bucket_time],
|
||||||
|
total=1,
|
||||||
|
allowed=1 if allowed else 0,
|
||||||
|
denied=0 if allowed else 1,
|
||||||
|
elapsed_sum=float(elapsed_ms or 0),
|
||||||
|
max_ms=float(elapsed_ms or 0),
|
||||||
|
)
|
||||||
|
return buckets
|
||||||
|
|
||||||
|
|
||||||
|
def _percentile(values: list[float], percentile: float) -> float:
|
||||||
|
if not values:
|
||||||
|
return 0.0
|
||||||
|
ordered = sorted(values)
|
||||||
|
position = (len(ordered) - 1) * percentile
|
||||||
|
lower = int(position)
|
||||||
|
upper = min(lower + 1, len(ordered) - 1)
|
||||||
|
fraction = position - lower
|
||||||
|
return ordered[lower] + (ordered[upper] - ordered[lower]) * fraction
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_trend_summary(
|
||||||
|
db: AsyncSession,
|
||||||
|
scope: MonitoringScope,
|
||||||
|
start_time: datetime,
|
||||||
|
end_time: datetime,
|
||||||
|
) -> dict:
|
||||||
|
query = select(
|
||||||
|
func.count().label("total"),
|
||||||
|
func.count().filter(PermissionAccessLog.allowed.is_(True)).label("allowed"),
|
||||||
|
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied"),
|
||||||
|
func.count().filter(PermissionAccessLog.elapsed_ms > 50).label("slow"),
|
||||||
|
func.count(func.distinct(PermissionAccessLog.study_id)).label("studies"),
|
||||||
|
func.count(func.distinct(PermissionAccessLog.user_id)).label("users"),
|
||||||
|
func.count(func.distinct(PermissionAccessLog.endpoint_key)).label("endpoints"),
|
||||||
|
func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"),
|
||||||
|
func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_ms"),
|
||||||
|
func.max(PermissionAccessLog.created_at).label("observed_end_at"),
|
||||||
|
).where(
|
||||||
|
PermissionAccessLog.created_at >= start_time,
|
||||||
|
PermissionAccessLog.created_at <= end_time,
|
||||||
|
)
|
||||||
|
row = (await db.execute(_apply_monitoring_scope_to_log_query(query, scope))).one()
|
||||||
|
if db.get_bind().dialect.name == "postgresql":
|
||||||
|
percentile_query = select(
|
||||||
|
func.percentile_cont(0.95).within_group(PermissionAccessLog.elapsed_ms)
|
||||||
|
).where(
|
||||||
|
PermissionAccessLog.created_at >= start_time,
|
||||||
|
PermissionAccessLog.created_at <= end_time,
|
||||||
|
)
|
||||||
|
percentile_result = await db.execute(
|
||||||
|
_apply_monitoring_scope_to_log_query(percentile_query, scope)
|
||||||
|
)
|
||||||
|
p95_ms = float(percentile_result.scalar() or 0)
|
||||||
|
else:
|
||||||
|
elapsed_query = select(PermissionAccessLog.elapsed_ms).where(
|
||||||
|
PermissionAccessLog.created_at >= start_time,
|
||||||
|
PermissionAccessLog.created_at <= end_time,
|
||||||
|
)
|
||||||
|
elapsed_result = await db.execute(
|
||||||
|
_apply_monitoring_scope_to_log_query(elapsed_query, scope)
|
||||||
|
)
|
||||||
|
p95_ms = _percentile(
|
||||||
|
[float(value) for value in elapsed_result.scalars().all()],
|
||||||
|
0.95,
|
||||||
|
)
|
||||||
|
total = int(row.total or 0)
|
||||||
|
denied = int(row.denied or 0)
|
||||||
|
return {
|
||||||
|
"total_checks": total,
|
||||||
|
"allowed_checks": int(row.allowed or 0),
|
||||||
|
"denied_checks": denied,
|
||||||
|
"deny_rate": round(denied / total * 100, 2) if total else 0,
|
||||||
|
"avg_elapsed_ms": round(float(row.avg_ms or 0), 2),
|
||||||
|
"p95_elapsed_ms": round(p95_ms, 2),
|
||||||
|
"max_elapsed_ms": round(float(row.max_ms or 0), 2),
|
||||||
|
"slow_check_count": int(row.slow or 0),
|
||||||
|
"active_study_count": int(row.studies or 0),
|
||||||
|
"active_user_count": int(row.users or 0),
|
||||||
|
"active_endpoint_count": int(row.endpoints or 0),
|
||||||
|
"cache_hits": 0,
|
||||||
|
"cache_misses": 0,
|
||||||
|
"cache_hit_rate": 0,
|
||||||
|
"cache_sample_available": False,
|
||||||
|
"observed_end_at": row.observed_end_at.isoformat() if row.observed_end_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_trend_cache_buckets(
|
||||||
|
db: AsyncSession,
|
||||||
|
scope: MonitoringScope,
|
||||||
|
previous_start: datetime,
|
||||||
|
previous_end: datetime,
|
||||||
|
current_start: datetime,
|
||||||
|
current_end: datetime,
|
||||||
|
period: str,
|
||||||
|
) -> tuple[dict[datetime, dict[str, int]], dict, dict]:
|
||||||
|
empty_summary = {"cache_hits": 0, "cache_misses": 0, "cache_hit_rate": 0, "cache_sample_available": False}
|
||||||
|
if not scope.is_admin:
|
||||||
|
return {}, dict(empty_summary), dict(empty_summary)
|
||||||
|
result = await db.execute(
|
||||||
|
select(PermissionMetricSnapshot).where(
|
||||||
|
PermissionMetricSnapshot.bucket_time >= previous_start,
|
||||||
|
PermissionMetricSnapshot.bucket_time <= current_end,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
buckets: dict[datetime, dict[str, int]] = defaultdict(lambda: {"cache_hits": 0, "cache_misses": 0})
|
||||||
|
current_counts = {"cache_hits": 0, "cache_misses": 0}
|
||||||
|
previous_counts = {"cache_hits": 0, "cache_misses": 0}
|
||||||
|
for snapshot in result.scalars().all():
|
||||||
|
snapshot_time = snapshot.bucket_time
|
||||||
|
if snapshot_time.tzinfo is None:
|
||||||
|
snapshot_time = snapshot_time.replace(tzinfo=timezone.utc)
|
||||||
|
if snapshot_time >= current_start:
|
||||||
|
current_counts["cache_hits"] += int(snapshot.cache_hits or 0)
|
||||||
|
current_counts["cache_misses"] += int(snapshot.cache_misses or 0)
|
||||||
|
bucket = buckets[_trend_bucket_time(snapshot_time, period)]
|
||||||
|
bucket["cache_hits"] += int(snapshot.cache_hits or 0)
|
||||||
|
bucket["cache_misses"] += int(snapshot.cache_misses or 0)
|
||||||
|
elif snapshot_time < previous_end:
|
||||||
|
previous_counts["cache_hits"] += int(snapshot.cache_hits or 0)
|
||||||
|
previous_counts["cache_misses"] += int(snapshot.cache_misses or 0)
|
||||||
|
|
||||||
|
def serialize(counts: dict[str, int]) -> dict:
|
||||||
|
total = counts["cache_hits"] + counts["cache_misses"]
|
||||||
|
return {
|
||||||
|
**counts,
|
||||||
|
"cache_hit_rate": round(counts["cache_hits"] / total * 100, 2) if total else 0,
|
||||||
|
"cache_sample_available": total > 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
return dict(buckets), serialize(current_counts), serialize(previous_counts)
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_trend_attribution(
|
||||||
|
db: AsyncSession,
|
||||||
|
scope: MonitoringScope,
|
||||||
|
start_time: datetime,
|
||||||
|
end_time: datetime,
|
||||||
|
) -> dict:
|
||||||
|
base_conditions = (
|
||||||
|
PermissionAccessLog.created_at >= start_time,
|
||||||
|
PermissionAccessLog.created_at <= end_time,
|
||||||
|
)
|
||||||
|
denied_query = select(
|
||||||
|
PermissionAccessLog.endpoint_key,
|
||||||
|
PermissionAccessLog.role,
|
||||||
|
func.count().label("denied_count"),
|
||||||
|
).where(*base_conditions, PermissionAccessLog.allowed.is_(False)).group_by(
|
||||||
|
PermissionAccessLog.endpoint_key,
|
||||||
|
PermissionAccessLog.role,
|
||||||
|
).order_by(desc("denied_count")).limit(5)
|
||||||
|
denied_rows = (await db.execute(_apply_monitoring_scope_to_log_query(denied_query, scope))).all()
|
||||||
|
|
||||||
|
slow_query = select(
|
||||||
|
PermissionAccessLog.endpoint_key,
|
||||||
|
func.count().label("sample_count"),
|
||||||
|
func.count().filter(PermissionAccessLog.elapsed_ms > 50).label("slow_count"),
|
||||||
|
func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"),
|
||||||
|
func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_ms"),
|
||||||
|
).where(*base_conditions).group_by(PermissionAccessLog.endpoint_key).order_by(
|
||||||
|
desc("slow_count"), desc("avg_ms"), desc("sample_count")
|
||||||
|
).limit(5)
|
||||||
|
slow_rows = (await db.execute(_apply_monitoring_scope_to_log_query(slow_query, scope))).all()
|
||||||
|
|
||||||
|
client_type = func.coalesce(PermissionAccessLog.client_type, "unknown")
|
||||||
|
client_version = func.coalesce(PermissionAccessLog.client_version, "-")
|
||||||
|
client_platform = func.coalesce(PermissionAccessLog.client_platform, "-")
|
||||||
|
client_query = select(
|
||||||
|
client_type.label("client_type"),
|
||||||
|
client_version.label("client_version"),
|
||||||
|
client_platform.label("client_platform"),
|
||||||
|
func.count().label("total_count"),
|
||||||
|
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied_count"),
|
||||||
|
func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"),
|
||||||
|
).where(*base_conditions).group_by(client_type, client_version, client_platform).order_by(
|
||||||
|
desc("total_count")
|
||||||
|
).limit(5)
|
||||||
|
client_rows = (await db.execute(_apply_monitoring_scope_to_log_query(client_query, scope))).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"top_denied": [
|
||||||
|
{"endpoint_key": row.endpoint_key, "role": row.role, "denied_count": int(row.denied_count)}
|
||||||
|
for row in denied_rows
|
||||||
|
],
|
||||||
|
"top_slow": [
|
||||||
{
|
{
|
||||||
"bucket_time": bucket.isoformat(),
|
"endpoint_key": row.endpoint_key,
|
||||||
"total_checks": values["total"],
|
"sample_count": int(row.sample_count),
|
||||||
"allowed_checks": values["allowed"],
|
"slow_count": int(row.slow_count),
|
||||||
"denied_checks": values["denied"],
|
"avg_elapsed_ms": round(float(row.avg_ms), 2),
|
||||||
"avg_elapsed_ms": round(float(values["elapsed_sum"]) / int(values["total"]), 2),
|
"max_elapsed_ms": round(float(row.max_ms), 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())
|
for row in slow_rows
|
||||||
|
],
|
||||||
|
"client_breakdown": [
|
||||||
|
{
|
||||||
|
"client_type": row.client_type,
|
||||||
|
"client_version": row.client_version,
|
||||||
|
"client_platform": row.client_platform,
|
||||||
|
"total_count": int(row.total_count),
|
||||||
|
"denied_count": int(row.denied_count),
|
||||||
|
"deny_rate": round(int(row.denied_count) / int(row.total_count) * 100, 2) if row.total_count else 0,
|
||||||
|
"avg_elapsed_ms": round(float(row.avg_ms), 2),
|
||||||
|
}
|
||||||
|
for row in client_rows
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from app.schemas.common import PaginatedResponse
|
|||||||
from app.crud import user as user_crud
|
from app.crud import user as user_crud
|
||||||
from app.crud import member as member_crud
|
from app.crud import member as member_crud
|
||||||
from app.utils.pagination import paginate
|
from app.utils.pagination import paginate
|
||||||
from app.schemas.user import UserCreate, UserLoginActivityRead, UserRead, UserStatus, UserUpdate
|
from app.schemas.user import LoginStatus, UserCreate, UserLoginActivityRead, UserRead, UserStatus, UserUpdate
|
||||||
from app.services.user_login_sessions import get_login_summaries, list_login_activities
|
from app.services.user_login_sessions import get_login_summaries, list_login_activities
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -21,11 +21,16 @@ async def list_users(
|
|||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
keyword: str | None = Query(default=None),
|
keyword: str | None = Query(default=None),
|
||||||
user_status: UserStatus | None = Query(default=None, alias="status"),
|
user_status: UserStatus | None = Query(default=None, alias="status"),
|
||||||
|
login_status: LoginStatus | None = Query(default=None),
|
||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
current_user=Depends(require_roles(["ADMIN"])),
|
current_user=Depends(require_roles(["ADMIN"])),
|
||||||
) -> PaginatedResponse[UserRead]:
|
) -> PaginatedResponse[UserRead]:
|
||||||
users = await user_crud.list_users(db, skip=skip, limit=limit, keyword=keyword, status=user_status)
|
users = await user_crud.list_users(
|
||||||
total_users = await user_crud.count_users(db, keyword=keyword, status=user_status)
|
db, skip=skip, limit=limit, keyword=keyword, status=user_status, login_status=login_status
|
||||||
|
)
|
||||||
|
total_users = await user_crud.count_users(
|
||||||
|
db, keyword=keyword, status=user_status, login_status=login_status
|
||||||
|
)
|
||||||
summaries = await get_login_summaries(db, [user.id for user in users])
|
summaries = await get_login_summaries(db, [user.id for user in users])
|
||||||
items = []
|
items = []
|
||||||
for user in users:
|
for user in users:
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Sequence
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Literal, Sequence
|
||||||
|
|
||||||
from sqlalchemy import delete, func, or_, select, update
|
from sqlalchemy import delete, func, or_, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -11,12 +12,14 @@ from app.core.config import (
|
|||||||
PROTECTED_ADMIN_DEFAULT_PASSWORD,
|
PROTECTED_ADMIN_DEFAULT_PASSWORD,
|
||||||
PROTECTED_ADMIN_EMAIL,
|
PROTECTED_ADMIN_EMAIL,
|
||||||
PROTECTED_ADMIN_FULL_NAME,
|
PROTECTED_ADMIN_FULL_NAME,
|
||||||
|
settings,
|
||||||
)
|
)
|
||||||
from app.core.security import hash_password
|
from app.core.security import hash_password
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
from app.models.permission_access_log import PermissionAccessLog
|
from app.models.permission_access_log import PermissionAccessLog
|
||||||
from app.models.study_member import StudyMember
|
from app.models.study_member import StudyMember
|
||||||
from app.models.user import User, UserStatus
|
from app.models.user import User, UserStatus
|
||||||
|
from app.models.user_login_session import UserLoginSession
|
||||||
from app.schemas.user import UserCreate, UserRegisterRequest, UserUpdate
|
from app.schemas.user import UserCreate, UserRegisterRequest, UserUpdate
|
||||||
|
|
||||||
|
|
||||||
@@ -81,7 +84,13 @@ async def update_user(db: AsyncSession, user: User, user_in: UserUpdate) -> User
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def _apply_user_filters(query, *, keyword: str | None = None, status: UserStatus | None = None):
|
def _apply_user_filters(
|
||||||
|
query,
|
||||||
|
*,
|
||||||
|
keyword: str | None = None,
|
||||||
|
status: UserStatus | None = None,
|
||||||
|
login_status: Literal["ONLINE", "OFFLINE"] | None = None,
|
||||||
|
):
|
||||||
if keyword:
|
if keyword:
|
||||||
pattern = f"%{keyword.strip()}%"
|
pattern = f"%{keyword.strip()}%"
|
||||||
query = query.where(
|
query = query.where(
|
||||||
@@ -93,6 +102,18 @@ def _apply_user_filters(query, *, keyword: str | None = None, status: UserStatus
|
|||||||
)
|
)
|
||||||
if status is not None:
|
if status is not None:
|
||||||
query = query.where(User.status == status)
|
query = query.where(User.status == status)
|
||||||
|
if login_status is not None:
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(seconds=settings.USER_SESSION_ONLINE_SECONDS)
|
||||||
|
has_online_session = (
|
||||||
|
select(UserLoginSession.id)
|
||||||
|
.where(
|
||||||
|
UserLoginSession.user_id == User.id,
|
||||||
|
UserLoginSession.ended_at.is_(None),
|
||||||
|
UserLoginSession.last_seen_at >= cutoff,
|
||||||
|
)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
query = query.where(has_online_session if login_status == "ONLINE" else ~has_online_session)
|
||||||
return query
|
return query
|
||||||
|
|
||||||
|
|
||||||
@@ -103,8 +124,9 @@ async def list_users(
|
|||||||
*,
|
*,
|
||||||
keyword: str | None = None,
|
keyword: str | None = None,
|
||||||
status: UserStatus | None = None,
|
status: UserStatus | None = None,
|
||||||
|
login_status: Literal["ONLINE", "OFFLINE"] | None = None,
|
||||||
) -> Sequence[User]:
|
) -> Sequence[User]:
|
||||||
query = _apply_user_filters(select(User), keyword=keyword, status=status)
|
query = _apply_user_filters(select(User), keyword=keyword, status=status, login_status=login_status)
|
||||||
result = await db.execute(query.order_by(User.created_at.desc()).offset(skip).limit(limit))
|
result = await db.execute(query.order_by(User.created_at.desc()).offset(skip).limit(limit))
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
@@ -114,8 +136,14 @@ async def count_users(
|
|||||||
*,
|
*,
|
||||||
keyword: str | None = None,
|
keyword: str | None = None,
|
||||||
status: UserStatus | None = None,
|
status: UserStatus | None = None,
|
||||||
|
login_status: Literal["ONLINE", "OFFLINE"] | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
query = _apply_user_filters(select(func.count()).select_from(User), keyword=keyword, status=status)
|
query = _apply_user_filters(
|
||||||
|
select(func.count()).select_from(User),
|
||||||
|
keyword=keyword,
|
||||||
|
status=status,
|
||||||
|
login_status=login_status,
|
||||||
|
)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
return int(result.scalar_one() or 0)
|
return int(result.scalar_one() or 0)
|
||||||
|
|
||||||
|
|||||||
@@ -565,6 +565,16 @@ def test_project_permission_config_is_system_level_permission():
|
|||||||
assert "PM" in SYSTEM_PERMISSIONS["system:permissions:project_config"]["roles"]
|
assert "PM" in SYSTEM_PERMISSIONS["system:permissions:project_config"]["roles"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pm_system_management_navigation_matches_backend_permission_contract():
|
||||||
|
"""PM 系统管理导航中的审计日志和权限管理应与后端角色权限保持一致。"""
|
||||||
|
assert "PM" in API_ENDPOINT_PERMISSIONS["audit_logs:read"]["default_roles"]
|
||||||
|
assert "PM" in SYSTEM_PERMISSIONS["system:permissions:read"]["roles"]
|
||||||
|
assert "PM" in SYSTEM_PERMISSIONS["system:permissions:project_config"]["roles"]
|
||||||
|
|
||||||
|
audit_route = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "audit_logs.py"
|
||||||
|
assert 'require_api_permission("audit_logs:read")' in audit_route.read_text()
|
||||||
|
|
||||||
|
|
||||||
def test_project_api_permission_routes_use_system_project_config_permission():
|
def test_project_api_permission_routes_use_system_project_config_permission():
|
||||||
"""项目权限矩阵 API 应明确依赖 system:permissions:project_config。"""
|
"""项目权限矩阵 API 应明确依赖 system:permissions:project_config。"""
|
||||||
route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "api_permissions.py"
|
route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "api_permissions.py"
|
||||||
|
|||||||
@@ -69,7 +69,19 @@ async def test_system_permission_monitoring_definitions_are_admin_only():
|
|||||||
assert all("PM" not in item["roles"] for item in monitoring_items)
|
assert all("PM" not in item["roles"] for item in monitoring_items)
|
||||||
|
|
||||||
|
|
||||||
async def _seed_permission_log(db_session, study_id: uuid.UUID, user_id: uuid.UUID, *, allowed: bool, elapsed_ms: float) -> None:
|
async def _seed_permission_log(
|
||||||
|
db_session,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
*,
|
||||||
|
allowed: bool,
|
||||||
|
elapsed_ms: float,
|
||||||
|
endpoint_key: str = "admin.permissions.read",
|
||||||
|
role: str = "PM",
|
||||||
|
client_type: str | None = None,
|
||||||
|
client_version: str | None = None,
|
||||||
|
client_platform: str | None = None,
|
||||||
|
) -> None:
|
||||||
study_exists = (
|
study_exists = (
|
||||||
await db_session.execute(text("SELECT id FROM studies WHERE id = :id"), {"id": str(study_id)})
|
await db_session.execute(text("SELECT id FROM studies WHERE id = :id"), {"id": str(study_id)})
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
@@ -119,21 +131,24 @@ async def _seed_permission_log(db_session, study_id: uuid.UUID, user_id: uuid.UU
|
|||||||
"""
|
"""
|
||||||
INSERT INTO permission_access_logs
|
INSERT INTO permission_access_logs
|
||||||
(id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address,
|
(id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address,
|
||||||
request_snapshot, created_at)
|
client_type, client_version, client_platform, request_snapshot, created_at)
|
||||||
VALUES
|
VALUES
|
||||||
(:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address,
|
(:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address,
|
||||||
:request_snapshot, CURRENT_TIMESTAMP)
|
:client_type, :client_version, :client_platform, :request_snapshot, CURRENT_TIMESTAMP)
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
"study_id": str(study_id),
|
"study_id": str(study_id),
|
||||||
"user_id": str(user_id),
|
"user_id": str(user_id),
|
||||||
"endpoint_key": "admin.permissions.read",
|
"endpoint_key": endpoint_key,
|
||||||
"role": "PM",
|
"role": role,
|
||||||
"allowed": allowed,
|
"allowed": allowed,
|
||||||
"elapsed_ms": elapsed_ms,
|
"elapsed_ms": elapsed_ms,
|
||||||
"ip_address": "127.0.0.1",
|
"ip_address": "127.0.0.1",
|
||||||
|
"client_type": client_type,
|
||||||
|
"client_version": client_version,
|
||||||
|
"client_platform": client_platform,
|
||||||
"request_snapshot": None,
|
"request_snapshot": None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -159,6 +174,53 @@ async def test_get_permission_metrics(db_session):
|
|||||||
assert data["check_metrics"]["denied_checks"] == 1
|
assert data["check_metrics"]["denied_checks"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_trends_returns_dense_summary_comparison_and_attribution(db_session):
|
||||||
|
baseline = await permission_monitoring.get_trends(db=db_session, _=AdminUserStub(), period="24h")
|
||||||
|
study_id = uuid.uuid4()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
await _seed_permission_log(
|
||||||
|
db_session,
|
||||||
|
study_id,
|
||||||
|
user_id,
|
||||||
|
allowed=True,
|
||||||
|
elapsed_ms=5,
|
||||||
|
endpoint_key="subjects.read",
|
||||||
|
client_type="web",
|
||||||
|
client_version="0.1.0",
|
||||||
|
client_platform="macos",
|
||||||
|
)
|
||||||
|
await _seed_permission_log(
|
||||||
|
db_session,
|
||||||
|
study_id,
|
||||||
|
user_id,
|
||||||
|
allowed=False,
|
||||||
|
elapsed_ms=80,
|
||||||
|
endpoint_key="subjects.export",
|
||||||
|
client_type="web",
|
||||||
|
client_version="0.1.0",
|
||||||
|
client_platform="macos",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = await permission_monitoring.get_trends(db=db_session, _=AdminUserStub(), period="24h")
|
||||||
|
|
||||||
|
assert data["period"] == "24h"
|
||||||
|
assert data["range"]["bucket_seconds"] == 3600
|
||||||
|
assert len(data["data_points"]) == 24
|
||||||
|
assert data["data_points"][-1]["sample_state"] == "partial"
|
||||||
|
assert data["summary"]["total_checks"] == baseline["summary"]["total_checks"] + 2
|
||||||
|
assert data["summary"]["denied_checks"] == baseline["summary"]["denied_checks"] + 1
|
||||||
|
assert data["summary"]["slow_check_count"] == baseline["summary"]["slow_check_count"] + 1
|
||||||
|
assert data["summary"]["active_study_count"] == baseline["summary"]["active_study_count"] + 1
|
||||||
|
assert data["summary"]["active_user_count"] == baseline["summary"]["active_user_count"] + 1
|
||||||
|
assert data["summary"]["active_endpoint_count"] >= 2
|
||||||
|
assert data["summary"]["max_elapsed_ms"] >= data["summary"]["p95_elapsed_ms"]
|
||||||
|
assert data["previous_summary"]["total_checks"] == 0
|
||||||
|
assert any(item["endpoint_key"] == "subjects.export" for item in data["attribution"]["top_denied"])
|
||||||
|
assert data["attribution"]["top_slow"][0]["endpoint_key"] == "subjects.export"
|
||||||
|
assert any(item["client_type"] == "web" for item in data["attribution"]["client_breakdown"])
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_cache_statistics(db_session):
|
async def test_get_cache_statistics(db_session):
|
||||||
"""测试获取缓存统计"""
|
"""测试获取缓存统计"""
|
||||||
@@ -1206,6 +1268,26 @@ async def test_access_logs_include_security_events_for_admin(db_session, monkeyp
|
|||||||
assert items_by_type["permission"]["request_snapshot"]["path"] == "/api/v1/projects/overview"
|
assert items_by_type["permission"]["request_snapshot"]["path"] == "/api/v1/projects/overview"
|
||||||
assert items_by_type["security"]["request_snapshot"]["headers"]["authorization"] == "[redacted]"
|
assert items_by_type["security"]["request_snapshot"]["headers"]["authorization"] == "[redacted]"
|
||||||
|
|
||||||
|
permission_only = await permission_monitoring.get_access_logs(
|
||||||
|
db=db_session,
|
||||||
|
_=AdminUserStub(),
|
||||||
|
study_id=None,
|
||||||
|
user_id=None,
|
||||||
|
endpoint_key=None,
|
||||||
|
role=None,
|
||||||
|
allowed=None,
|
||||||
|
start_time=None,
|
||||||
|
end_time=None,
|
||||||
|
client_ip=None,
|
||||||
|
client_type=None,
|
||||||
|
event_type="permission",
|
||||||
|
keyword=None,
|
||||||
|
page=1,
|
||||||
|
page_size=50,
|
||||||
|
)
|
||||||
|
assert permission_only["total"] == 1
|
||||||
|
assert permission_only["items"][0]["event_type"] == "permission"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_access_logs_ip_ranking_aggregates_by_ip_total(db_session):
|
async def test_access_logs_ip_ranking_aggregates_by_ip_total(db_session):
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import uuid
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.crud import user as user_crud
|
||||||
from app.models.user import User, UserStatus
|
from app.models.user import User, UserStatus
|
||||||
from app.models.user_login_session import UserLoginSession
|
from app.models.user_login_session import UserLoginSession
|
||||||
from app.services.user_login_sessions import get_login_summaries, session_id_from_payload
|
from app.services.user_login_sessions import get_login_summaries, session_id_from_payload
|
||||||
@@ -59,3 +60,44 @@ async def test_login_summary_marks_recent_unended_sessions_online(db_session, mo
|
|||||||
assert summaries[user.id].status == "ONLINE"
|
assert summaries[user.id].status == "ONLINE"
|
||||||
assert summaries[user.id].active_session_count == 1
|
assert summaries[user.id].active_session_count == 1
|
||||||
assert summaries[user.id].client_type == "desktop"
|
assert summaries[user.id].client_type == "desktop"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_user_list_filters_online_and_offline_accounts(db_session, monkeypatch):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
monkeypatch.setattr(settings, "USER_SESSION_ONLINE_SECONDS", 300)
|
||||||
|
online_user = User(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
email="online-filter@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
full_name="Online Filter",
|
||||||
|
clinical_department="IT",
|
||||||
|
status=UserStatus.ACTIVE,
|
||||||
|
)
|
||||||
|
offline_user = User(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
email="offline-filter@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
full_name="Offline Filter",
|
||||||
|
clinical_department="IT",
|
||||||
|
status=UserStatus.ACTIVE,
|
||||||
|
)
|
||||||
|
db_session.add_all([online_user, offline_user])
|
||||||
|
db_session.add(
|
||||||
|
UserLoginSession(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=online_user.id,
|
||||||
|
client_type="web",
|
||||||
|
login_at=now - timedelta(minutes=1),
|
||||||
|
last_seen_at=now - timedelta(seconds=10),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
online = await user_crud.list_users(db_session, login_status="ONLINE")
|
||||||
|
offline = await user_crud.list_users(db_session, login_status="OFFLINE")
|
||||||
|
|
||||||
|
assert online_user.id in {user.id for user in online}
|
||||||
|
assert offline_user.id not in {user.id for user in online}
|
||||||
|
assert offline_user.id in {user.id for user in offline}
|
||||||
|
assert await user_crud.count_users(db_session, login_status="ONLINE") == len(online)
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export const fetchAccessLogs = (params: {
|
|||||||
end_time?: string;
|
end_time?: string;
|
||||||
client_ip?: string;
|
client_ip?: string;
|
||||||
client_type?: string;
|
client_type?: string;
|
||||||
|
event_type?: "all" | "permission";
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
page_size?: number;
|
page_size?: number;
|
||||||
|
|||||||
@@ -74,11 +74,15 @@ describe("ApiEndpointPermissions.vue", () => {
|
|||||||
it("keeps matrix read-only and renders authorization states", async () => {
|
it("keeps matrix read-only and renders authorization states", async () => {
|
||||||
const source = readFileSync(resolve(__dirname, "./ApiEndpointPermissions.vue"), "utf8");
|
const source = readFileSync(resolve(__dirname, "./ApiEndpointPermissions.vue"), "utf8");
|
||||||
|
|
||||||
expect(source).not.toContain("只读查看各角色权限覆盖");
|
expect(source).toContain("有效权限矩阵");
|
||||||
|
expect(source).toContain(">只读</el-tag>");
|
||||||
expect(source).toContain("permission-state--allowed");
|
expect(source).toContain("permission-state--allowed");
|
||||||
expect(source).toContain("permission-state--disabled");
|
expect(source).toContain("permission-state--disabled");
|
||||||
expect(source).toContain("CircleCloseFilled");
|
expect(source).not.toContain("CircleCloseFilled");
|
||||||
expect(source).not.toContain('? "✓" : "—"');
|
expect(source).toContain("<template v-else>—</template>");
|
||||||
|
expect(source).toContain('filterAuthorization.value === "different"');
|
||||||
|
expect(source).toContain('max-height="calc(100dvh - 330px)"');
|
||||||
|
expect(source).toContain('label="权限类型" width="120" fixed="left"');
|
||||||
expect(source).not.toContain("defineEmits");
|
expect(source).not.toContain("defineEmits");
|
||||||
expect(source).not.toContain("el-checkbox");
|
expect(source).not.toContain("el-checkbox");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
<div class="api-permissions">
|
<div class="api-permissions">
|
||||||
<div class="api-permissions-toolbar">
|
<div class="api-permissions-toolbar">
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
<span class="toolbar-title">项目级权限矩阵</span>
|
<div class="toolbar-heading">
|
||||||
|
<span class="toolbar-title">有效权限矩阵</span>
|
||||||
|
<el-tag size="small" effect="plain" type="info">只读</el-tag>
|
||||||
|
</div>
|
||||||
|
<span class="toolbar-result">显示 {{ filteredOperations.length }} / {{ displayOperations.length }} 项权限</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-filters">
|
<div class="toolbar-filters">
|
||||||
<el-input
|
<el-input
|
||||||
@@ -29,6 +33,13 @@
|
|||||||
<el-option label="删除" value="delete" />
|
<el-option label="删除" value="delete" />
|
||||||
<el-option label="导出" value="export" />
|
<el-option label="导出" value="export" />
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<el-select v-model="filterAuthorization" placeholder="授权状态" style="width: 140px">
|
||||||
|
<el-option label="全部授权状态" value="" />
|
||||||
|
<el-option label="存在角色差异" value="different" />
|
||||||
|
<el-option label="至少一项授权" value="granted" />
|
||||||
|
<el-option label="全部未授权" value="ungranted" />
|
||||||
|
</el-select>
|
||||||
|
<el-button :disabled="!hasActiveFilters" @click="resetFilters">重置</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -37,10 +48,12 @@
|
|||||||
:data="filteredOperations"
|
:data="filteredOperations"
|
||||||
border
|
border
|
||||||
stripe
|
stripe
|
||||||
|
max-height="calc(100dvh - 330px)"
|
||||||
|
empty-text="未找到匹配的权限"
|
||||||
:span-method="spanMethod"
|
:span-method="spanMethod"
|
||||||
class="perm-matrix-table"
|
class="perm-matrix-table"
|
||||||
>
|
>
|
||||||
<el-table-column prop="permission_category" label="权限类型" width="120">
|
<el-table-column prop="permission_category" label="权限类型" width="120" fixed="left">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="hierarchy-cell hierarchy-cell--category" :data-category="getPermissionCategory(row)">
|
<div class="hierarchy-cell hierarchy-cell--category" :data-category="getPermissionCategory(row)">
|
||||||
<span class="hierarchy-bar" />
|
<span class="hierarchy-bar" />
|
||||||
@@ -49,7 +62,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column prop="module" label="模块" width="160">
|
<el-table-column prop="module" label="模块" width="160" fixed="left">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="hierarchy-cell hierarchy-cell--module">
|
<div class="hierarchy-cell hierarchy-cell--module">
|
||||||
<span class="module-dot" />
|
<span class="module-dot" />
|
||||||
@@ -94,7 +107,7 @@
|
|||||||
:title="isOperationAllowed(row.operation_key, role) ? '已授权' : '未授权'"
|
:title="isOperationAllowed(row.operation_key, role) ? '已授权' : '未授权'"
|
||||||
>
|
>
|
||||||
<template v-if="isOperationAllowed(row.operation_key, role)">✓</template>
|
<template v-if="isOperationAllowed(row.operation_key, role)">✓</template>
|
||||||
<CircleCloseFilled v-else />
|
<template v-else>—</template>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -105,7 +118,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { CircleCloseFilled, Search } from "@element-plus/icons-vue";
|
import { Search } from "@element-plus/icons-vue";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import type { ApiEndpointPermissionsResponse } from "@/types/api";
|
import type { ApiEndpointPermissionsResponse } from "@/types/api";
|
||||||
import { fetchApiOperations } from "@/api/projectPermissions";
|
import { fetchApiOperations } from "@/api/projectPermissions";
|
||||||
@@ -144,8 +157,20 @@ const { roleLabel, compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTem
|
|||||||
const searchText = ref("");
|
const searchText = ref("");
|
||||||
const filterModule = ref("");
|
const filterModule = ref("");
|
||||||
const filterAction = ref("");
|
const filterAction = ref("");
|
||||||
|
const filterAuthorization = ref("");
|
||||||
const operations = ref<Operation[]>([]);
|
const operations = ref<Operation[]>([]);
|
||||||
|
|
||||||
|
const hasActiveFilters = computed(() => Boolean(
|
||||||
|
searchText.value || filterModule.value || filterAction.value || filterAuthorization.value
|
||||||
|
));
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
searchText.value = "";
|
||||||
|
filterModule.value = "";
|
||||||
|
filterAction.value = "";
|
||||||
|
filterAuthorization.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
const moduleLabel = projectPermissionModuleLabel;
|
const moduleLabel = projectPermissionModuleLabel;
|
||||||
const getPermissionCategory = (row: Operation): "common" | "business" => projectPermissionCategory(row.module);
|
const getPermissionCategory = (row: Operation): "common" | "business" => projectPermissionCategory(row.module);
|
||||||
const getPermissionCategoryLabel = (row: Operation): string => projectPermissionCategoryLabel(row.module);
|
const getPermissionCategoryLabel = (row: Operation): string => projectPermissionCategoryLabel(row.module);
|
||||||
@@ -216,8 +241,14 @@ const filteredOperations = computed(() => {
|
|||||||
|
|
||||||
const matchModule = !filterModule.value || operation.module === filterModule.value;
|
const matchModule = !filterModule.value || operation.module === filterModule.value;
|
||||||
const matchAction = !filterAction.value || getOperationVerb(operation) === filterAction.value;
|
const matchAction = !filterAction.value || getOperationVerb(operation) === filterAction.value;
|
||||||
|
const roleStates = roles.value.map((role) => isOperationAllowed(operation.operation_key, role));
|
||||||
|
const matchAuthorization =
|
||||||
|
!filterAuthorization.value ||
|
||||||
|
(filterAuthorization.value === "different" && roleStates.some(Boolean) && roleStates.some((allowed) => !allowed)) ||
|
||||||
|
(filterAuthorization.value === "granted" && roleStates.some(Boolean)) ||
|
||||||
|
(filterAuthorization.value === "ungranted" && roleStates.every((allowed) => !allowed));
|
||||||
|
|
||||||
return matchSearch && matchModule && matchAction;
|
return matchSearch && matchModule && matchAction && matchAuthorization;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 按权限类型、模块分组排序
|
// 按权限类型、模块分组排序
|
||||||
@@ -394,19 +425,19 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.api-permissions {
|
.api-permissions {
|
||||||
padding: 20px 0;
|
padding: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.api-permissions-toolbar {
|
.api-permissions-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 10px;
|
||||||
margin-bottom: 18px;
|
margin-bottom: 8px;
|
||||||
padding: 14px 18px;
|
padding: 8px 10px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 14px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,23 +445,35 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-title {
|
.toolbar-title {
|
||||||
font-size: 15px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1a2332;
|
color: #1a2332;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar-result {
|
||||||
|
color: #7a8a9a;
|
||||||
|
font-size: 10.5px;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar-filters {
|
.toolbar-filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.api-permissions-table-wrapper {
|
.api-permissions-table-wrapper {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
||||||
}
|
}
|
||||||
@@ -442,7 +485,7 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
|
|||||||
.hierarchy-cell {
|
.hierarchy-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 6px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,7 +501,7 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
|
|||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
width: 3px;
|
width: 3px;
|
||||||
height: 36px;
|
height: 24px;
|
||||||
border-radius: 0 3px 3px 0;
|
border-radius: 0 3px 3px 0;
|
||||||
background: linear-gradient(180deg, #94a3b8 0%, #cbd5e1 100%);
|
background: linear-gradient(180deg, #94a3b8 0%, #cbd5e1 100%);
|
||||||
}
|
}
|
||||||
@@ -472,7 +515,7 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
|
|||||||
}
|
}
|
||||||
|
|
||||||
.category-label {
|
.category-label {
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
@@ -481,15 +524,15 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
|
|||||||
/* 模块:实心圆点 + 中等加粗字 */
|
/* 模块:实心圆点 + 中等加粗字 */
|
||||||
.module-dot {
|
.module-dot {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 7px;
|
width: 6px;
|
||||||
height: 7px;
|
height: 6px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #3b82f6;
|
background: #3b82f6;
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12);
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-label {
|
.module-label {
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1e293b;
|
color: #1e293b;
|
||||||
}
|
}
|
||||||
@@ -498,11 +541,11 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
|
|||||||
.section-chip {
|
.section-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 3px 10px;
|
padding: 2px 7px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #f1f5f9;
|
background: #f1f5f9;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
font-size: 12px;
|
font-size: 10.5px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -511,17 +554,17 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
|
|||||||
.operation-cell {
|
.operation-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-tag {
|
.action-tag {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
min-width: 40px;
|
min-width: 36px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.operation-name {
|
.operation-name {
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,10 +572,10 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 24px;
|
width: 18px;
|
||||||
height: 24px;
|
height: 18px;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,11 +587,46 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
|
|||||||
}
|
}
|
||||||
|
|
||||||
.permission-state--disabled {
|
.permission-state--disabled {
|
||||||
color: #94a3b8;
|
color: #c3ccd5;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.permission-state--disabled :deep(svg) {
|
:deep(.api-permissions .el-input__wrapper),
|
||||||
width: 16px;
|
:deep(.api-permissions .el-select__wrapper) {
|
||||||
height: 16px;
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.api-permissions .el-button) {
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.perm-matrix-table .el-table__header-wrapper th.el-table__cell) {
|
||||||
|
padding: 6px 0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.perm-matrix-table .el-table__body-wrapper td.el-table__cell) {
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.api-permissions-toolbar {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-filters {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.toolbar-filters > * {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -495,6 +495,7 @@ import {
|
|||||||
} from "../runtime";
|
} from "../runtime";
|
||||||
import { dispatchDesktopRefreshCurrentView } from "../composables/useDesktopRefresh";
|
import { dispatchDesktopRefreshCurrentView } from "../composables/useDesktopRefresh";
|
||||||
import { useDesktopShortcuts } from "../composables/useDesktopShortcuts";
|
import { useDesktopShortcuts } from "../composables/useDesktopShortcuts";
|
||||||
|
import { isApiPermissionAllowed } from "../utils/apiPermissionValue";
|
||||||
import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions";
|
import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions";
|
||||||
import type { DesktopCommand } from "../types/desktopCommands";
|
import type { DesktopCommand } from "../types/desktopCommands";
|
||||||
import DesktopCommandPalette from "./DesktopCommandPalette.vue";
|
import DesktopCommandPalette from "./DesktopCommandPalette.vue";
|
||||||
@@ -532,6 +533,12 @@ const DESKTOP_ADMIN_SECTION_LABEL = "系统管理";
|
|||||||
const isAdmin = computed(() => !!auth.user?.is_admin);
|
const isAdmin = computed(() => !!auth.user?.is_admin);
|
||||||
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
|
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
|
||||||
const canAccessAdminPermissions = computed(() => isAdmin.value || projectRole.value === "PM");
|
const canAccessAdminPermissions = computed(() => isAdmin.value || projectRole.value === "PM");
|
||||||
|
const canAccessAuditLogs = computed(() =>
|
||||||
|
isAdmin.value || (
|
||||||
|
projectRole.value === "PM" &&
|
||||||
|
isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["audit_logs:read"])
|
||||||
|
),
|
||||||
|
);
|
||||||
const canAccessProjectPath = (path: string) =>
|
const canAccessProjectPath = (path: string) =>
|
||||||
hasProjectPermission(study.currentPermissions, projectRole.value, getProjectRoutePermission(path), isAdmin.value);
|
hasProjectPermission(study.currentPermissions, projectRole.value, getProjectRoutePermission(path), isAdmin.value);
|
||||||
const hasAnyProjectModuleAccess = computed(() => projectRouteLandingPaths.some((path) => canAccessProjectPath(path)));
|
const hasAnyProjectModuleAccess = computed(() => projectRouteLandingPaths.some((path) => canAccessProjectPath(path)));
|
||||||
@@ -618,17 +625,18 @@ const toggleNavigationGroup = (item: LayoutNavigationItem) => {
|
|||||||
collapsedNavigationGroups.value = nextCollapsed;
|
collapsedNavigationGroups.value = nextCollapsed;
|
||||||
};
|
};
|
||||||
const adminNavigationItems = computed(() =>
|
const adminNavigationItems = computed(() =>
|
||||||
study.currentStudy
|
!isAdminContext.value
|
||||||
? []
|
? []
|
||||||
: buildAdminNavigationItems({
|
: buildAdminNavigationItems({
|
||||||
hasUser: Boolean(auth.user),
|
hasUser: Boolean(auth.user),
|
||||||
isAdmin: isAdmin.value,
|
isAdmin: isAdmin.value,
|
||||||
canAccessAdminPermissions: canAccessAdminPermissions.value,
|
canAccessAdminPermissions: canAccessAdminPermissions.value,
|
||||||
|
canAccessAuditLogs: canAccessAuditLogs.value,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const projectNavigationItems = computed(() =>
|
const projectNavigationItems = computed(() =>
|
||||||
buildProjectNavigationItems({
|
buildProjectNavigationItems({
|
||||||
hasCurrentStudy: Boolean(study.currentStudy),
|
hasCurrentStudy: Boolean(study.currentStudy) && !isAdminContext.value,
|
||||||
hasAnyProjectModuleAccess: hasAnyProjectModuleAccess.value,
|
hasAnyProjectModuleAccess: hasAnyProjectModuleAccess.value,
|
||||||
canAccessProjectPath,
|
canAccessProjectPath,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ describe("desktop layout shell", () => {
|
|||||||
expect(source).toContain("grid-template-rows: 52px auto minmax(0, 1fr);");
|
expect(source).toContain("grid-template-rows: 52px auto minmax(0, 1fr);");
|
||||||
expect(source).toContain("const adminDesktopNavigationItems");
|
expect(source).toContain("const adminDesktopNavigationItems");
|
||||||
expect(source).toContain("const projectDesktopNavigationItems");
|
expect(source).toContain("const projectDesktopNavigationItems");
|
||||||
|
expect(source).toContain("hasCurrentStudy: Boolean(study.currentStudy) && !isAdminContext.value");
|
||||||
expect(source).toContain('id: "desktop:refresh"');
|
expect(source).toContain('id: "desktop:refresh"');
|
||||||
expect(source).toContain("shortcut: formatDesktopShortcut(desktopShortcutPreferences.value.refresh)");
|
expect(source).toContain("shortcut: formatDesktopShortcut(desktopShortcutPreferences.value.refresh)");
|
||||||
expect(source).toContain('command === "ctms.desktop.back"');
|
expect(source).toContain('command === "ctms.desktop.back"');
|
||||||
|
|||||||
@@ -95,6 +95,17 @@ describe("PermissionAccessLogs", () => {
|
|||||||
expect(source).not.toContain("grid-template-columns: minmax(320px, 1fr) minmax(0, 1.35fr);");
|
expect(source).not.toContain("grid-template-columns: minmax(320px, 1fr) minmax(0, 1.35fr);");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the access log table edge-to-edge without removing filter or pagination spacing", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toMatch(/\.access-log-card \{[\s\S]*?padding: 0;[\s\S]*?overflow: hidden;/);
|
||||||
|
expect(source).toMatch(/\.audit-filters \{[\s\S]*?padding: 10px 12px;[\s\S]*?margin-bottom: 0;/);
|
||||||
|
expect(source).toMatch(/\.table-shell \{[\s\S]*?border: 0;[\s\S]*?border-radius: 0;/);
|
||||||
|
expect(source).toMatch(/\.logs-pagination \{[\s\S]*?padding: 8px 12px;[\s\S]*?border-top:/);
|
||||||
|
expect(source).toContain(".access-table :deep(.el-scrollbar__wrap)");
|
||||||
|
expect(source).toContain(".access-table :deep(.el-scrollbar__bar.is-horizontal)");
|
||||||
|
});
|
||||||
|
|
||||||
it("removes the duplicated audit hero copy", () => {
|
it("removes the duplicated audit hero copy", () => {
|
||||||
const source = readSource();
|
const source = readSource();
|
||||||
|
|
||||||
@@ -143,7 +154,11 @@ describe("PermissionAccessLogs", () => {
|
|||||||
expect(source).toContain("document.removeEventListener(\"visibilitychange\", onVisibilityChange)");
|
expect(source).toContain("document.removeEventListener(\"visibilitychange\", onVisibilityChange)");
|
||||||
expect(source).toContain("stopTimers()");
|
expect(source).toContain("stopTimers()");
|
||||||
expect(source).toContain("const REALTIME_POLL_INTERVAL_MS = 30_000;");
|
expect(source).toContain("const REALTIME_POLL_INTERVAL_MS = 30_000;");
|
||||||
expect(source).toContain('const timeRangePreset = ref<TimeRangePreset>("24h")');
|
expect(source).toContain('const timeRangePreset = ref<AccessTimeRangePreset>("24h")');
|
||||||
|
expect(source).toContain("权限趋势下钻{{ drilldownRangeLabel");
|
||||||
|
expect(source).toContain("const applyTrendFilter = async");
|
||||||
|
expect(source).toContain('params.event_type = "permission"');
|
||||||
|
expect(source).toContain("defineExpose({ refresh, applyTrendFilter })");
|
||||||
expect(source).toContain("accessLogDialogVisible");
|
expect(source).toContain("accessLogDialogVisible");
|
||||||
expect(source).toContain("accessTerminalWindowRef");
|
expect(source).toContain("accessTerminalWindowRef");
|
||||||
expect(source).toContain("accessDialogLines.join");
|
expect(source).toContain("accessDialogLines.join");
|
||||||
|
|||||||
@@ -30,6 +30,17 @@
|
|||||||
<section class="access-log-card">
|
<section class="access-log-card">
|
||||||
<div class="audit-filters">
|
<div class="audit-filters">
|
||||||
<div class="filter-inputs">
|
<div class="filter-inputs">
|
||||||
|
<el-tag
|
||||||
|
v-if="trendDrilldownActive"
|
||||||
|
closable
|
||||||
|
effect="plain"
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
class="trend-drilldown-tag"
|
||||||
|
@close="clearTrendDrilldownRange"
|
||||||
|
>
|
||||||
|
权限趋势下钻{{ drilldownRangeLabel ? ` · ${drilldownRangeLabel}` : "" }}
|
||||||
|
</el-tag>
|
||||||
<el-select v-model="filters.role" placeholder="角色" clearable size="small" class="filter-role" @change="onFilterChange">
|
<el-select v-model="filters.role" placeholder="角色" clearable size="small" class="filter-role" @change="onFilterChange">
|
||||||
<el-option v-for="role in roleFilterOptions" :key="role.value" :label="role.label" :value="role.value" />
|
<el-option v-for="role in roleFilterOptions" :key="role.value" :label="role.label" :value="role.value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
@@ -324,6 +335,17 @@ const TIME_RANGE_PRESETS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type TimeRangePreset = (typeof TIME_RANGE_PRESETS)[number]["value"];
|
type TimeRangePreset = (typeof TIME_RANGE_PRESETS)[number]["value"];
|
||||||
|
type AccessTimeRangePreset = TimeRangePreset | "custom";
|
||||||
|
|
||||||
|
interface TrendDrilldownPayload {
|
||||||
|
preset?: Exclude<TimeRangePreset, "all">;
|
||||||
|
startAt?: string;
|
||||||
|
endAt?: string;
|
||||||
|
allowed?: boolean;
|
||||||
|
role?: string;
|
||||||
|
clientType?: string;
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface IpRankingRow {
|
interface IpRankingRow {
|
||||||
ip: string;
|
ip: string;
|
||||||
@@ -385,8 +407,10 @@ const summary = ref<AccessLogsSummary>(emptySummary);
|
|||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const pageSize = ref(50);
|
const pageSize = ref(50);
|
||||||
const timeRangePreset = ref<TimeRangePreset>("24h");
|
const timeRangePreset = ref<AccessTimeRangePreset>("24h");
|
||||||
const ipRankingPeriod = ref<TimeRangePreset>("24h");
|
const ipRankingPeriod = ref<TimeRangePreset>("24h");
|
||||||
|
const trendDrilldownRange = ref<[Date, Date] | null>(null);
|
||||||
|
const trendDrilldownActive = ref(false);
|
||||||
const accessLogDialogVisible = ref(false);
|
const accessLogDialogVisible = ref(false);
|
||||||
const ipRankingDialogVisible = ref(false);
|
const ipRankingDialogVisible = ref(false);
|
||||||
const accessTerminalWindowRef = ref<HTMLElement | null>(null);
|
const accessTerminalWindowRef = ref<HTMLElement | null>(null);
|
||||||
@@ -636,7 +660,8 @@ const formatLastUpdated = (value: Date) => {
|
|||||||
return `${value.getFullYear()}/${value.getMonth() + 1}/${value.getDate()} ${String(value.getHours()).padStart(2, "0")}:${String(value.getMinutes()).padStart(2, "0")}:${String(value.getSeconds()).padStart(2, "0")}`;
|
return `${value.getFullYear()}/${value.getMonth() + 1}/${value.getDate()} ${String(value.getHours()).padStart(2, "0")}:${String(value.getMinutes()).padStart(2, "0")}:${String(value.getSeconds()).padStart(2, "0")}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildTimeRangeFromPreset = (preset: TimeRangePreset): [Date, Date] | null => {
|
const buildTimeRangeFromPreset = (preset: AccessTimeRangePreset): [Date, Date] | null => {
|
||||||
|
if (preset === "custom") return null;
|
||||||
const option = TIME_RANGE_PRESETS.find((item) => item.value === preset) || TIME_RANGE_PRESETS[1];
|
const option = TIME_RANGE_PRESETS.find((item) => item.value === preset) || TIME_RANGE_PRESETS[1];
|
||||||
if (!option.hours) return null;
|
if (!option.hours) return null;
|
||||||
const end = new Date();
|
const end = new Date();
|
||||||
@@ -691,6 +716,7 @@ const onFilterChange = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onTimePresetChange = () => {
|
const onTimePresetChange = () => {
|
||||||
|
trendDrilldownRange.value = null;
|
||||||
onFilterChange();
|
onFilterChange();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -704,6 +730,8 @@ const onKeywordInput = () => {
|
|||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
timeRangePreset.value = "24h";
|
timeRangePreset.value = "24h";
|
||||||
|
trendDrilldownRange.value = null;
|
||||||
|
trendDrilldownActive.value = false;
|
||||||
filters.role = undefined;
|
filters.role = undefined;
|
||||||
filters.allowed = undefined;
|
filters.allowed = undefined;
|
||||||
filters.clientType = undefined;
|
filters.clientType = undefined;
|
||||||
@@ -724,12 +752,13 @@ const buildQueryParams = (overrides: Record<string, any> = {}) => {
|
|||||||
if (filters.role) params.role = filters.role;
|
if (filters.role) params.role = filters.role;
|
||||||
if (filters.allowed !== undefined) params.allowed = filters.allowed;
|
if (filters.allowed !== undefined) params.allowed = filters.allowed;
|
||||||
if (filters.clientType) params.client_type = filters.clientType;
|
if (filters.clientType) params.client_type = filters.clientType;
|
||||||
|
if (trendDrilldownActive.value) params.event_type = "permission";
|
||||||
const keywordToken = filters.keyword.trim();
|
const keywordToken = filters.keyword.trim();
|
||||||
if (keywordToken) {
|
if (keywordToken) {
|
||||||
params.keyword = keywordToken;
|
params.keyword = keywordToken;
|
||||||
if (/^[0-9a-f:.]+$/i.test(keywordToken)) params.client_ip = keywordToken;
|
if (/^[0-9a-f:.]+$/i.test(keywordToken)) params.client_ip = keywordToken;
|
||||||
}
|
}
|
||||||
const presetRange = timeRangePreset.value ? buildTimeRangeFromPreset(timeRangePreset.value) : null;
|
const presetRange = trendDrilldownRange.value || buildTimeRangeFromPreset(timeRangePreset.value);
|
||||||
if (presetRange) {
|
if (presetRange) {
|
||||||
params.start_time = presetRange[0].toISOString();
|
params.start_time = presetRange[0].toISOString();
|
||||||
params.end_time = presetRange[1].toISOString();
|
params.end_time = presetRange[1].toISOString();
|
||||||
@@ -796,6 +825,37 @@ const refresh = () => {
|
|||||||
loadData();
|
loadData();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const drilldownRangeLabel = computed(() => {
|
||||||
|
if (!trendDrilldownRange.value) return "";
|
||||||
|
const [start, end] = trendDrilldownRange.value;
|
||||||
|
const compact = (value: Date) => `${value.getMonth() + 1}/${value.getDate()} ${String(value.getHours()).padStart(2, "0")}:${String(value.getMinutes()).padStart(2, "0")}`;
|
||||||
|
return `${compact(start)}–${compact(end)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearTrendDrilldownRange = () => {
|
||||||
|
trendDrilldownRange.value = null;
|
||||||
|
trendDrilldownActive.value = false;
|
||||||
|
timeRangePreset.value = "24h";
|
||||||
|
onFilterChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyTrendFilter = async (payload: TrendDrilldownPayload) => {
|
||||||
|
page.value = 1;
|
||||||
|
trendDrilldownActive.value = true;
|
||||||
|
filters.allowed = payload.allowed;
|
||||||
|
filters.role = payload.role;
|
||||||
|
filters.clientType = payload.clientType;
|
||||||
|
filters.keyword = payload.keyword || "";
|
||||||
|
if (payload.startAt && payload.endAt) {
|
||||||
|
trendDrilldownRange.value = [new Date(payload.startAt), new Date(payload.endAt)];
|
||||||
|
timeRangePreset.value = "custom";
|
||||||
|
} else {
|
||||||
|
trendDrilldownRange.value = null;
|
||||||
|
timeRangePreset.value = payload.preset || "24h";
|
||||||
|
}
|
||||||
|
await loadData();
|
||||||
|
};
|
||||||
|
|
||||||
const openLogDetail = (row: AccessLogItem) => {
|
const openLogDetail = (row: AccessLogItem) => {
|
||||||
selectedLog.value = row;
|
selectedLog.value = row;
|
||||||
detailDrawerVisible.value = true;
|
detailDrawerVisible.value = true;
|
||||||
@@ -1023,7 +1083,7 @@ onBeforeUnmount(() => {
|
|||||||
stopTimers();
|
stopTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
defineExpose({ refresh });
|
defineExpose({ refresh, applyTrendFilter });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -1089,12 +1149,12 @@ defineExpose({ refresh });
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding: 0 0 8px;
|
padding: 10px 12px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
border-bottom: 1px solid var(--audit-border);
|
border-bottom: 1px solid var(--audit-border);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-inputs {
|
.filter-inputs {
|
||||||
@@ -1249,19 +1309,14 @@ defineExpose({ refresh });
|
|||||||
|
|
||||||
.access-log-card {
|
.access-log-card {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 10px;
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
border: 1px solid var(--audit-border);
|
border: 1px solid var(--audit-border);
|
||||||
border-radius: var(--card-radius);
|
border-radius: var(--card-radius);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
box-shadow: var(--card-shadow);
|
box-shadow: var(--card-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.access-log-card {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.section-head {
|
.section-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -1302,14 +1357,14 @@ defineExpose({ refresh });
|
|||||||
.log-surface {
|
.log-surface {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-shell {
|
.table-shell {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid #edf2f7;
|
border: 0;
|
||||||
border-radius: 10px;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.access-table {
|
.access-table {
|
||||||
@@ -1331,6 +1386,14 @@ defineExpose({ refresh });
|
|||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.access-table :deep(.el-scrollbar__wrap) {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-table :deep(.el-scrollbar__bar.is-horizontal) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.time-cell,
|
.time-cell,
|
||||||
.ip-cell,
|
.ip-cell,
|
||||||
.location-cell,
|
.location-cell,
|
||||||
@@ -1608,7 +1671,8 @@ defineExpose({ refresh });
|
|||||||
.logs-pagination {
|
.logs-pagination {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
padding: 8px 10px;
|
padding: 8px 12px;
|
||||||
|
border-top: 1px solid var(--audit-border);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,13 @@ describe("PermissionMonitoring.vue", () => {
|
|||||||
expect(source).toContain(".permission-monitoring.is-source-tab .soybean-monitoring-tabs :deep(.el-tabs__content)");
|
expect(source).toContain(".permission-monitoring.is-source-tab .soybean-monitoring-tabs :deep(.el-tabs__content)");
|
||||||
expect(source).toContain("overflow: hidden");
|
expect(source).toContain("overflow: hidden");
|
||||||
expect(source).toContain(".soybean-monitoring-tabs :deep(.el-tabs__content)");
|
expect(source).toContain(".soybean-monitoring-tabs :deep(.el-tabs__content)");
|
||||||
expect(source).toContain("overflow: auto");
|
expect(source).toContain("flex: 1 1 0;");
|
||||||
|
expect(source).toContain("height: auto;");
|
||||||
|
expect(source).toContain("overflow-y: auto;");
|
||||||
|
expect(source).toContain('height: auto;\n min-height: 100%;');
|
||||||
|
expect(source).toContain('.permission-monitoring.is-source-tab .soybean-monitoring-tabs :deep(.el-tab-pane[aria-hidden="false"])');
|
||||||
|
expect(source).toContain("flex: 0 0 52px;");
|
||||||
|
expect(source).toContain("overscroll-behavior: contain;");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resizes the source-analysis chart when its tab becomes active", () => {
|
it("resizes the source-analysis chart when its tab becomes active", () => {
|
||||||
@@ -141,10 +147,12 @@ describe("PermissionMonitoring.vue", () => {
|
|||||||
|
|
||||||
expect(source).toContain('label="运行概览"');
|
expect(source).toContain('label="运行概览"');
|
||||||
expect(source).toContain('label="安全中心"');
|
expect(source).toContain('label="安全中心"');
|
||||||
expect(source).toContain('label="性能趋势"');
|
expect(source).toContain('label="运行趋势"');
|
||||||
expect(source).toContain('label="访问日志"');
|
expect(source).toContain('label="访问日志"');
|
||||||
expect(source).toContain('label="来源分析"');
|
expect(source).toContain('label="来源分析"');
|
||||||
expect(source).toContain('label="性能趋势" name="trends" lazy');
|
expect(source).toContain('label="运行趋势" name="trends" lazy');
|
||||||
|
expect(source).toContain('@drilldown="openTrendDrilldown"');
|
||||||
|
expect(source).toContain('accessLogsRef.value?.applyTrendFilter(payload)');
|
||||||
expect(source).toContain('label="访问日志" name="logs" lazy');
|
expect(source).toContain('label="访问日志" name="logs" lazy');
|
||||||
expect(source).toContain('label="来源分析" name="ip-locations" lazy');
|
expect(source).toContain('label="来源分析" name="ip-locations" lazy');
|
||||||
expect(source).toContain('<SecurityCenter v-if="props.isAdmin"');
|
expect(source).toContain('<SecurityCenter v-if="props.isAdmin"');
|
||||||
@@ -159,7 +167,12 @@ describe("PermissionMonitoring.vue", () => {
|
|||||||
expect(source).toContain('const formatResponseTime');
|
expect(source).toContain('const formatResponseTime');
|
||||||
expect(source).toContain('return "<0.1ms"');
|
expect(source).toContain('return "<0.1ms"');
|
||||||
expect(source).toContain("业务样本不足");
|
expect(source).toContain("业务样本不足");
|
||||||
expect(source).toContain("组件在线不代表指标已完成评估");
|
expect(source).not.toContain("组件在线不代表指标已完成评估");
|
||||||
|
expect(source).not.toContain("评分由性能、拒绝率、缓存、告警和组件状态共同计算");
|
||||||
|
expect(source).not.toContain("最近一次检查未发现需要处理的问题");
|
||||||
|
expect(source).toContain('class="health-summary-label">综合状态');
|
||||||
|
expect(source).toContain('class="health-signal-item"');
|
||||||
|
expect(source).toContain('item.deduction ? `-${item.deduction}` : "正常"');
|
||||||
expect(source).toContain('class="overview-pane"');
|
expect(source).toContain('class="overview-pane"');
|
||||||
expect(source).toContain('class="overview-hero-card"');
|
expect(source).toContain('class="overview-hero-card"');
|
||||||
expect(source).toContain('class="operations-card"');
|
expect(source).toContain('class="operations-card"');
|
||||||
|
|||||||
@@ -79,16 +79,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-progress>
|
</el-progress>
|
||||||
<div class="health-summary-copy">
|
<div class="health-summary-copy" :class="`is-${healthDisplayStatus}`">
|
||||||
<strong>{{ health.sample_sufficient ? "当前运行正常" : "等待业务样本" }}</strong>
|
<span class="health-summary-label">综合状态</span>
|
||||||
<span v-if="health.sample_sufficient">评分由性能、拒绝率、缓存、告警和组件状态共同计算</span>
|
<strong>{{ health.sample_sufficient ? getHealthSummaryLabel(health.status) : "待评估" }}</strong>
|
||||||
<span v-else>组件在线不代表指标已完成评估</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="health.sample_sufficient" class="health-signal-list">
|
<div v-if="health.sample_sufficient" class="health-signal-list">
|
||||||
<span v-for="item in healthScoreItems" :key="item.label" :class="{ 'is-warning': item.deduction }">
|
<div v-for="item in healthScoreItems" :key="item.label" class="health-signal-item" :class="{ 'is-warning': item.deduction }">
|
||||||
{{ item.label }}{{ item.deduction ? ` -${item.deduction}` : " 正常" }}
|
<span>{{ item.label }}</span>
|
||||||
</span>
|
<strong>{{ item.deduction ? `-${item.deduction}` : "正常" }}</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="sample-warning">
|
<div v-else class="sample-warning">
|
||||||
近 1 小时仅 {{ health.last_hour.total_checks }} 次权限检查,业务样本不足
|
近 1 小时仅 {{ health.last_hour.total_checks }} 次权限检查,业务样本不足
|
||||||
@@ -195,7 +195,6 @@
|
|||||||
<span class="empty-alert-icon">✓</span>
|
<span class="empty-alert-icon">✓</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>暂无活动告警</strong>
|
<strong>暂无活动告警</strong>
|
||||||
<small>最近一次检查未发现需要处理的问题</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-empty v-else description="告警数据尚未加载" :image-size="44" />
|
<el-empty v-else description="告警数据尚未加载" :image-size="44" />
|
||||||
@@ -222,14 +221,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane label="性能趋势" name="trends" lazy>
|
<el-tab-pane label="运行趋势" name="trends" lazy>
|
||||||
<template #label>
|
<template #label>
|
||||||
<span class="soybean-tab-label">
|
<span class="soybean-tab-label">
|
||||||
<component :is="IconActivity" />
|
<component :is="IconActivity" />
|
||||||
<span>性能趋势</span>
|
<span>运行趋势</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<PermissionTrendCharts ref="trendChartsRef" />
|
<PermissionTrendCharts ref="trendChartsRef" @drilldown="openTrendDrilldown" />
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane v-if="props.isAdmin" label="安全中心" name="security" lazy>
|
<el-tab-pane v-if="props.isAdmin" label="安全中心" name="security" lazy>
|
||||||
@@ -364,6 +363,22 @@ type IpLocationsExpose = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ipLocationsRef = ref<IpLocationsExpose>();
|
const ipLocationsRef = ref<IpLocationsExpose>();
|
||||||
|
|
||||||
|
interface TrendDrilldownPayload {
|
||||||
|
preset?: "24h" | "7d" | "30d";
|
||||||
|
startAt?: string;
|
||||||
|
endAt?: string;
|
||||||
|
allowed?: boolean;
|
||||||
|
role?: string;
|
||||||
|
clientType?: string;
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openTrendDrilldown = async (payload: TrendDrilldownPayload) => {
|
||||||
|
activeTab.value = "logs";
|
||||||
|
await nextTick();
|
||||||
|
await accessLogsRef.value?.applyTrendFilter(payload);
|
||||||
|
};
|
||||||
const securityCenterRef = ref<InstanceType<typeof SecurityCenter>>();
|
const securityCenterRef = ref<InstanceType<typeof SecurityCenter>>();
|
||||||
|
|
||||||
const formatTime = (timestamp: number): string => {
|
const formatTime = (timestamp: number): string => {
|
||||||
@@ -384,6 +399,11 @@ const getHealthLabel = (status: string) => {
|
|||||||
return map[status] || status;
|
return map[status] || status;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getHealthSummaryLabel = (status: string) => {
|
||||||
|
const map: Record<string, string> = { healthy: "正常", degraded: "需关注", unhealthy: "异常" };
|
||||||
|
return map[status] || "待评估";
|
||||||
|
};
|
||||||
|
|
||||||
const getComponentStatusLabel = (status: string) => {
|
const getComponentStatusLabel = (status: string) => {
|
||||||
const map: Record<string, string> = { healthy: "正常", degraded: "降级", unhealthy: "异常", unknown: "未知" };
|
const map: Record<string, string> = { healthy: "正常", degraded: "降级", unhealthy: "异常", unknown: "未知" };
|
||||||
return map[status] || status;
|
return map[status] || status;
|
||||||
@@ -593,6 +613,9 @@ defineExpose({ refresh });
|
|||||||
}
|
}
|
||||||
|
|
||||||
.soybean-monitoring-tabs :deep(.el-tabs__header) {
|
.soybean-monitoring-tabs :deep(.el-tabs__header) {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
flex: 0 0 52px;
|
||||||
height: 52px;
|
height: 52px;
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
padding: 0 24px;
|
padding: 0 24px;
|
||||||
@@ -656,10 +679,14 @@ defineExpose({ refresh });
|
|||||||
}
|
}
|
||||||
|
|
||||||
.soybean-monitoring-tabs :deep(.el-tabs__content) {
|
.soybean-monitoring-tabs :deep(.el-tabs__content) {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 0;
|
||||||
|
height: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: auto;
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
.permission-monitoring.is-source-tab .soybean-monitoring-tabs :deep(.el-tabs__content) {
|
.permission-monitoring.is-source-tab .soybean-monitoring-tabs :deep(.el-tabs__content) {
|
||||||
@@ -671,8 +698,14 @@ defineExpose({ refresh });
|
|||||||
}
|
}
|
||||||
|
|
||||||
.soybean-monitoring-tabs :deep(.el-tab-pane[aria-hidden="false"]) {
|
.soybean-monitoring-tabs :deep(.el-tab-pane[aria-hidden="false"]) {
|
||||||
|
height: auto;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-monitoring.is-source-tab .soybean-monitoring-tabs :deep(.el-tab-pane[aria-hidden="false"]) {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ip-locations-pane {
|
.ip-locations-pane {
|
||||||
@@ -1314,26 +1347,58 @@ defineExpose({ refresh });
|
|||||||
.health-summary {
|
.health-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
min-height: 96px;
|
min-height: 96px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.health-summary-copy {
|
.health-summary-copy {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
justify-content: center;
|
||||||
min-width: 0;
|
gap: 2px;
|
||||||
|
min-width: 92px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(145deg, #f0fdf4, #ecfdf5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.health-summary-copy strong {
|
.health-summary-copy strong {
|
||||||
color: var(--mon-ink);
|
color: #047857;
|
||||||
font-size: 13px;
|
font-size: 17px;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.health-summary-copy span {
|
.health-summary-copy .health-summary-label {
|
||||||
color: var(--mon-muted);
|
color: var(--mon-muted);
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
line-height: 1.45;
|
}
|
||||||
|
|
||||||
|
.health-summary-copy.is-degraded {
|
||||||
|
border-color: #fde68a;
|
||||||
|
background: linear-gradient(145deg, #fffbeb, #fef3c7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-summary-copy.is-degraded strong {
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-summary-copy.is-unhealthy {
|
||||||
|
border-color: #fecaca;
|
||||||
|
background: linear-gradient(145deg, #fef2f2, #fee2e2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-summary-copy.is-unhealthy strong {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-summary-copy.is-unknown {
|
||||||
|
border-color: #dbe3ef;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-summary-copy.is-unknown strong {
|
||||||
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.score-inner strong {
|
.score-inner strong {
|
||||||
@@ -1348,16 +1413,36 @@ defineExpose({ refresh });
|
|||||||
.health-signal-list {
|
.health-signal-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px 6px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.health-signal-list span {
|
.health-signal-item {
|
||||||
color: var(--mon-muted);
|
display: inline-flex;
|
||||||
font-size: 11px;
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border: 1px solid #d1fae5;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f0fdf4;
|
||||||
|
font-size: 10px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.health-signal-list .is-warning {
|
.health-signal-item span {
|
||||||
|
color: var(--mon-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-signal-item strong {
|
||||||
|
color: #047857;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-signal-item.is-warning {
|
||||||
|
border-color: #fde68a;
|
||||||
|
background: #fffbeb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-signal-item.is-warning strong {
|
||||||
color: #b45309;
|
color: #b45309;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1729,6 +1814,7 @@ defineExpose({ refresh });
|
|||||||
}
|
}
|
||||||
|
|
||||||
.soybean-monitoring-tabs :deep(.el-tabs__header) {
|
.soybean-monitoring-tabs :deep(.el-tabs__header) {
|
||||||
|
flex-basis: 48px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const readSource = () => readFileSync(resolve(__dirname, "./PermissionTemplateSelector.vue"), "utf8");
|
||||||
|
|
||||||
|
describe("PermissionTemplateSelector.vue", () => {
|
||||||
|
it("renders accessible role actions with explicit project status", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("<button");
|
||||||
|
expect(source).toContain(":aria-label=");
|
||||||
|
expect(source).toContain("使用中");
|
||||||
|
expect(source).toContain("未启用");
|
||||||
|
expect(source).toContain("配置权限");
|
||||||
|
expect(source).toContain("grid-template-columns: repeat(5, minmax(0, 1fr));");
|
||||||
|
expect(source).toContain("min-height: 122px;");
|
||||||
|
expect(source).toContain("margin-top: auto;");
|
||||||
|
expect(source).not.toContain('PM: "👑"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,17 +2,20 @@
|
|||||||
<div class="template-selector">
|
<div class="template-selector">
|
||||||
<div class="selector-header">
|
<div class="selector-header">
|
||||||
<div class="selector-title-group">
|
<div class="selector-title-group">
|
||||||
<h3>角色权限概览</h3>
|
<h3>角色策略概览</h3>
|
||||||
</div>
|
</div>
|
||||||
<el-tag effect="plain" size="small" type="info" round>{{ templates.length }} 个角色</el-tag>
|
<el-tag effect="plain" size="small" type="info" round>{{ templates.length }} 个角色</el-tag>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="template-grid">
|
<div class="template-grid">
|
||||||
<div
|
<button
|
||||||
v-for="template in sortedTemplates"
|
v-for="template in sortedTemplates"
|
||||||
:key="template.id"
|
:key="template.id"
|
||||||
|
type="button"
|
||||||
class="template-card"
|
class="template-card"
|
||||||
:class="{ inactive: template.category && !isRoleActive(template.category) }"
|
:class="{ inactive: template.category && !isRoleActive(template.category) }"
|
||||||
|
:disabled="!template.category"
|
||||||
|
:aria-label="template.category ? `配置 ${template.name} 权限` : template.name"
|
||||||
@click="template.category && emit('edit-role', template.category!)"
|
@click="template.category && emit('edit-role', template.category!)"
|
||||||
>
|
>
|
||||||
<div class="card-top">
|
<div class="card-top">
|
||||||
@@ -21,7 +24,9 @@
|
|||||||
<span class="card-name">{{ template.name }}</span>
|
<span class="card-name">{{ template.name }}</span>
|
||||||
<span v-if="template.description" class="card-desc">{{ template.description }}</span>
|
<span v-if="template.description" class="card-desc">{{ template.description }}</span>
|
||||||
</div>
|
</div>
|
||||||
<el-tag v-if="!template.is_system" size="small" effect="dark" round type="success" class="card-badge">自定义</el-tag>
|
<span :class="['card-status', isRoleActive(template.category || '') ? 'card-status--active' : 'card-status--inactive']">
|
||||||
|
{{ isRoleActive(template.category || '') ? '使用中' : '未启用' }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-stats">
|
<div class="card-stats">
|
||||||
<template v-if="props.currentPermissions && template.category && props.currentPermissions[template.category]">
|
<template v-if="props.currentPermissions && template.category && props.currentPermissions[template.category]">
|
||||||
@@ -31,11 +36,11 @@
|
|||||||
<div class="stat-numbers">
|
<div class="stat-numbers">
|
||||||
<span class="stat enabled">
|
<span class="stat enabled">
|
||||||
<span class="stat-dot green" />
|
<span class="stat-dot green" />
|
||||||
{{ countCurrentEnabled(template.category) }} 启用
|
{{ countCurrentEnabled(template.category) }} 已授权
|
||||||
</span>
|
</span>
|
||||||
<span class="stat disabled">
|
<span class="stat disabled">
|
||||||
<span class="stat-dot red" />
|
<span class="stat-dot red" />
|
||||||
{{ countCurrentDisabled(template.category) }} 禁用
|
{{ countCurrentDisabled(template.category) }} 未授权
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -46,16 +51,17 @@
|
|||||||
<div class="stat-numbers">
|
<div class="stat-numbers">
|
||||||
<span class="stat enabled">
|
<span class="stat enabled">
|
||||||
<span class="stat-dot green" />
|
<span class="stat-dot green" />
|
||||||
{{ countEnabled(template) }} 启用
|
{{ countEnabled(template) }} 已授权
|
||||||
</span>
|
</span>
|
||||||
<span class="stat disabled">
|
<span class="stat disabled">
|
||||||
<span class="stat-dot red" />
|
<span class="stat-dot red" />
|
||||||
{{ countDisabled(template) }} 禁用
|
{{ countDisabled(template) }} 未授权
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="card-action">配置权限 <span aria-hidden="true">→</span></div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -94,10 +100,10 @@ const sortedTemplates = computed(() =>
|
|||||||
|
|
||||||
const roleIcon = (category: string | null) => {
|
const roleIcon = (category: string | null) => {
|
||||||
const icons: Record<string, string> = {
|
const icons: Record<string, string> = {
|
||||||
PM: "👑", CRA: "📋", PV: "🔍",
|
PM: "PM", CRA: "CRA", PV: "PV",
|
||||||
QA: "🏥", CTA: "📦",
|
QA: "QA", CTA: "CTA",
|
||||||
};
|
};
|
||||||
return icons[category ?? ""] ?? "📄";
|
return icons[category ?? ""] ?? "—";
|
||||||
};
|
};
|
||||||
|
|
||||||
const isRoleActive = (role: string) => props.activeRoles?.includes(role) ?? false;
|
const isRoleActive = (role: string) => props.activeRoles?.includes(role) ?? false;
|
||||||
@@ -155,38 +161,45 @@ watch(() => props.refreshKey, loadTemplates);
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.template-selector {
|
.template-selector {
|
||||||
padding: 8px 0 12px;
|
padding: 4px 0 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selector-header {
|
.selector-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selector-title-group h3 {
|
.selector-title-group h3 {
|
||||||
margin: 0 0 4px;
|
margin: 0;
|
||||||
font-size: 15px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1a2332;
|
color: #1a2332;
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-grid {
|
.template-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
align-items: start;
|
align-items: start;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-card {
|
.template-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 12px 14px;
|
width: 100%;
|
||||||
|
min-height: 122px;
|
||||||
|
padding: 8px 10px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: left;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
||||||
}
|
}
|
||||||
@@ -197,11 +210,19 @@ watch(() => props.refreshKey, loadTemplates);
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.template-card:focus-visible {
|
||||||
|
outline: 3px solid rgba(59, 130, 246, 0.25);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.template-card.inactive {
|
.template-card.inactive {
|
||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
border-color: #e2e8f0;
|
border-color: #e2e8f0;
|
||||||
background: #fafbfc;
|
background: #fafbfc;
|
||||||
opacity: 0.7;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-card.inactive:hover {
|
.template-card.inactive:hover {
|
||||||
@@ -213,12 +234,21 @@ watch(() => props.refreshKey, loadTemplates);
|
|||||||
.card-top {
|
.card-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 10px;
|
gap: 7px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-icon {
|
.card-icon {
|
||||||
font-size: 24px;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #eef5fa;
|
||||||
|
color: #315f7a;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 800;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -232,7 +262,7 @@ watch(() => props.refreshKey, loadTemplates);
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-name {
|
.card-name {
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1a2332;
|
color: #1a2332;
|
||||||
}
|
}
|
||||||
@@ -242,13 +272,49 @@ watch(() => props.refreshKey, loadTemplates);
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-desc {
|
.card-desc {
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 9.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-status--active {
|
||||||
|
border-color: #b9ddcc;
|
||||||
|
background: #edf8f2;
|
||||||
|
color: #267054;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-status--inactive {
|
||||||
|
border-color: #dbe3ea;
|
||||||
|
background: #f5f7f9;
|
||||||
|
color: #7a8997;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 6px;
|
||||||
|
border-top: 1px solid #edf1f5;
|
||||||
|
color: #416b83;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.card-badge {
|
.card-badge {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -256,11 +322,11 @@ watch(() => props.refreshKey, loadTemplates);
|
|||||||
.card-stats {
|
.card-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-bar {
|
.stat-bar {
|
||||||
height: 4px;
|
height: 3px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: #f1f5f9;
|
background: #f1f5f9;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -275,20 +341,20 @@ watch(() => props.refreshKey, loadTemplates);
|
|||||||
|
|
||||||
.stat-numbers {
|
.stat-numbers {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat {
|
.stat {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 12px;
|
font-size: 10.5px;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-dot {
|
.stat-dot {
|
||||||
width: 6px;
|
width: 5px;
|
||||||
height: 6px;
|
height: 5px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -300,4 +366,22 @@ watch(() => props.refreshKey, loadTemplates);
|
|||||||
.template-card.inactive .stat-dot.red { background: #cbd5e1; }
|
.template-card.inactive .stat-dot.red { background: #cbd5e1; }
|
||||||
.template-card.inactive .stat-fill { background: #cbd5e1; }
|
.template-card.inactive .stat-fill { background: #cbd5e1; }
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.template-grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.template-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.template-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,39 +5,56 @@ import { resolve } from "node:path";
|
|||||||
const readSource = () => readFileSync(resolve(__dirname, "./PermissionTrendCharts.vue"), "utf8");
|
const readSource = () => readFileSync(resolve(__dirname, "./PermissionTrendCharts.vue"), "utf8");
|
||||||
|
|
||||||
describe("PermissionTrendCharts", () => {
|
describe("PermissionTrendCharts", () => {
|
||||||
it("renders explicit empty states instead of zero-value charts when samples are unavailable", () => {
|
it("uses accurate permission trend language and removes the misleading all-period option", () => {
|
||||||
const source = readSource();
|
const source = readSource();
|
||||||
|
|
||||||
expect(source).toContain("暂无有效响应时间样本");
|
expect(source).toContain("权限检查趋势");
|
||||||
expect(source).toContain("暂无缓存访问样本");
|
expect(source).toContain("权限检查量");
|
||||||
expect(source).toContain("暂无权限检查样本");
|
expect(source).toContain("权限检查耗时");
|
||||||
expect(source).toContain("const hasResponseSamples = computed");
|
expect(source).toContain("权限拒绝率");
|
||||||
expect(source).toContain("const hasCacheSamples = computed");
|
expect(source).toContain("权限缓存命中率");
|
||||||
|
expect(source).not.toContain("系统性能趋势");
|
||||||
|
expect(source).not.toContain('{ value: "all", label: "全部" }');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sorts points chronologically and includes the date in 24-hour labels", () => {
|
it("renders dense period summaries with previous-period and sample context", () => {
|
||||||
const source = readSource();
|
const source = readSource();
|
||||||
|
|
||||||
expect(source).toContain('String(d.getHours()).padStart(2, "0")');
|
expect(source).toContain('class="metric-strip"');
|
||||||
expect(source).toContain("dataPoints.value = [...res.data.data_points].sort(");
|
expect(source).toContain("P95 检查耗时");
|
||||||
expect(source).toContain("new Date(left.bucket_time).getTime() - new Date(right.bucket_time).getTime()");
|
expect(source).toContain("活跃范围");
|
||||||
|
expect(source).toContain("较上期");
|
||||||
|
expect(source).toContain("cache_sample_available");
|
||||||
|
expect(source).toContain("不等于命中率为 0");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps period selection, update time and refresh together in the tab toolbar", () => {
|
it("uses honest chart semantics and visible health thresholds", () => {
|
||||||
const source = readSource();
|
const source = readSource();
|
||||||
|
|
||||||
expect(source).toContain('class="toolbar-actions"');
|
expect(source).toContain('stack: "checks"');
|
||||||
expect(source).toContain("最近成功更新:{{ formatLastUpdated(lastUpdatedAt) }}");
|
expect(source).toContain("smooth: false");
|
||||||
expect(source).toContain('@click="loadData">刷新');
|
expect(source).toContain('formatter: "慢检查 50ms"');
|
||||||
|
expect(source).toContain('formatter: "健康线 80%"');
|
||||||
|
expect(source).toContain("connectNulls: false");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses compact chart surfaces while preserving a two-column desktop layout", () => {
|
it("provides denial, latency and client attribution with access-log drilldown", () => {
|
||||||
const source = readSource();
|
const source = readSource();
|
||||||
|
|
||||||
expect(source).toContain("padding: 0 12px 12px");
|
expect(source).toContain("拒绝最多");
|
||||||
expect(source).toContain("grid-template-columns: repeat(2, 1fr)");
|
expect(source).toContain("耗时最高");
|
||||||
expect(source).toContain("min-height: 276px");
|
expect(source).toContain("客户端表现");
|
||||||
expect(source).toContain("height: 212px");
|
expect(source).toContain('@click="onChecksChartClick"');
|
||||||
expect(source).toContain("gap: 10px");
|
expect(source).toContain('emit("drilldown"');
|
||||||
|
expect(source).toContain("openAttributionLogs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the information-dense layout responsive", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("grid-template-columns: repeat(5, minmax(0, 1fr))");
|
||||||
|
expect(source).toContain("grid-template-columns: minmax(0, 2.1fr)");
|
||||||
|
expect(source).toContain("grid-template-columns: repeat(3, minmax(0, 1fr))");
|
||||||
|
expect(source).toContain("@media (max-width: 900px)");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,16 @@ describe("SecurityCenter", () => {
|
|||||||
expect(source).not.toContain('<section class="audit-filters">\n <el-select');
|
expect(source).not.toContain('<section class="audit-filters">\n <el-select');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the security table edge-to-edge while preserving filter and pagination spacing", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toMatch(/\.security-table-wrap \{[\s\S]*?padding: 0;[\s\S]*?overflow: hidden;/);
|
||||||
|
expect(source).toMatch(/\.table-head \{[\s\S]*?margin-bottom: 0;[\s\S]*?padding: 10px 12px;/);
|
||||||
|
expect(source).toMatch(/\.pagination-wrap \{[\s\S]*?padding: 8px 12px;[\s\S]*?border-top:/);
|
||||||
|
expect(source).toContain(".security-table :deep(.el-scrollbar__wrap)");
|
||||||
|
expect(source).toContain(".security-table :deep(.el-scrollbar__bar.is-horizontal)");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps risk levels on the page and opens abnormal sources in a compact ranking dialog", () => {
|
it("keeps risk levels on the page and opens abnormal sources in a compact ranking dialog", () => {
|
||||||
const source = readSource();
|
const source = readSource();
|
||||||
|
|
||||||
|
|||||||
@@ -938,7 +938,8 @@ defineExpose({ refresh: loadSecurityEvents });
|
|||||||
|
|
||||||
.security-table-wrap {
|
.security-table-wrap {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 10px;
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
border: 1px solid var(--audit-border);
|
border: 1px solid var(--audit-border);
|
||||||
border-radius: var(--card-radius);
|
border-radius: var(--card-radius);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
@@ -964,6 +965,14 @@ defineExpose({ refresh: loadSecurityEvents });
|
|||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.security-table :deep(.el-scrollbar__wrap) {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-table :deep(.el-scrollbar__bar.is-horizontal) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.time-cell,
|
.time-cell,
|
||||||
.ip-cell,
|
.ip-cell,
|
||||||
.location-cell,
|
.location-cell,
|
||||||
@@ -1222,7 +1231,9 @@ defineExpose({ refresh: loadSecurityEvents });
|
|||||||
.table-head {
|
.table-head {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--audit-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-head .section-title-group {
|
.table-head .section-title-group {
|
||||||
@@ -1490,15 +1501,12 @@ defineExpose({ refresh: loadSecurityEvents });
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.security-table-wrap {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-wrap {
|
.pagination-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: 10px;
|
margin-top: 0;
|
||||||
padding: 8px 16px 0;
|
padding: 8px 12px;
|
||||||
|
border-top: 1px solid var(--audit-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-pill {
|
.category-pill {
|
||||||
@@ -1806,8 +1814,7 @@ defineExpose({ refresh: loadSecurityEvents });
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.security-analysis-card,
|
.security-analysis-card {
|
||||||
.security-table-wrap {
|
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
<el-icon><Suitcase /></el-icon>
|
<el-icon><Suitcase /></el-icon>
|
||||||
<span>{{ TEXT.menu.projectManagement }}</span>
|
<span>{{ TEXT.menu.projectManagement }}</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-menu-item v-if="isAdmin" index="/admin/audit-logs">
|
<el-menu-item v-if="canAccessAuditLogs" index="/admin/audit-logs">
|
||||||
<el-icon><Document /></el-icon>
|
<el-icon><Document /></el-icon>
|
||||||
<span>{{ TEXT.menu.auditLogs }}</span>
|
<span>{{ TEXT.menu.auditLogs }}</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
<span>{{ TEXT.menu.permissionManagement }}</span>
|
<span>{{ TEXT.menu.permissionManagement }}</span>
|
||||||
</template>
|
</template>
|
||||||
<el-menu-item index="/admin/permissions/system">系统级权限</el-menu-item>
|
<el-menu-item index="/admin/permissions/system">系统级权限</el-menu-item>
|
||||||
<el-menu-item index="/admin/permissions/project">项目权限配置</el-menu-item>
|
<el-menu-item index="/admin/permissions/project">项目访问控制</el-menu-item>
|
||||||
</el-sub-menu>
|
</el-sub-menu>
|
||||||
</el-menu-item-group>
|
</el-menu-item-group>
|
||||||
|
|
||||||
@@ -414,6 +414,7 @@ import {
|
|||||||
} from "@element-plus/icons-vue";
|
} from "@element-plus/icons-vue";
|
||||||
import { ElMessageBox } from "element-plus";
|
import { ElMessageBox } from "element-plus";
|
||||||
import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions";
|
import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions";
|
||||||
|
import { isApiPermissionAllowed } from "../utils/apiPermissionValue";
|
||||||
import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager";
|
import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager";
|
||||||
import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager";
|
import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager";
|
||||||
import {
|
import {
|
||||||
@@ -446,6 +447,12 @@ const isAdmin = computed(() => !!auth.user?.is_admin);
|
|||||||
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
|
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
|
||||||
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
|
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
|
||||||
const canAccessAdminPermissions = computed(() => isAdmin.value || projectRole.value === "PM");
|
const canAccessAdminPermissions = computed(() => isAdmin.value || projectRole.value === "PM");
|
||||||
|
const canAccessAuditLogs = computed(() =>
|
||||||
|
isAdmin.value || (
|
||||||
|
projectRole.value === "PM" &&
|
||||||
|
isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["audit_logs:read"])
|
||||||
|
),
|
||||||
|
);
|
||||||
const canAccessProjectPath = (path: string) =>
|
const canAccessProjectPath = (path: string) =>
|
||||||
hasProjectPermission(study.currentPermissions, 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 canAccessAnyProjectPath = (paths: string[]) => paths.some((path) => canAccessProjectPath(path));
|
||||||
@@ -844,7 +851,7 @@ const breadcrumbs = computed(() => {
|
|||||||
const items: any[] = [];
|
const items: any[] = [];
|
||||||
|
|
||||||
if (route.path.startsWith("/admin")) {
|
if (route.path.startsWith("/admin")) {
|
||||||
// 顶级管理后台菜单(按权限过滤),供末级面包屑横跳
|
// 顶级系统管理菜单(按权限过滤),供末级面包屑横跳
|
||||||
const topItems: { label: string; path: string }[] = [];
|
const topItems: { label: string; path: string }[] = [];
|
||||||
if (isAdmin.value) topItems.push({ label: TEXT.menu.accountManagement, path: "/admin/users" });
|
if (isAdmin.value) topItems.push({ label: TEXT.menu.accountManagement, path: "/admin/users" });
|
||||||
topItems.push({ label: TEXT.menu.projectManagement, path: "/admin/projects" });
|
topItems.push({ label: TEXT.menu.projectManagement, path: "/admin/projects" });
|
||||||
@@ -856,10 +863,10 @@ const breadcrumbs = computed(() => {
|
|||||||
// 权限管理子页,供「权限管理」父级与末级横跳
|
// 权限管理子页,供「权限管理」父级与末级横跳
|
||||||
const permItems = [
|
const permItems = [
|
||||||
{ label: "系统级权限", path: "/admin/permissions/system" },
|
{ label: "系统级权限", path: "/admin/permissions/system" },
|
||||||
{ label: "项目权限配置", path: "/admin/permissions/project" },
|
{ label: "项目访问控制", path: "/admin/permissions/project" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// 首项「管理后台」为分组标签,不可点击
|
// 首项「系统管理」为分组标签,不可点击
|
||||||
items.push({ label: TEXT.menu.admin });
|
items.push({ label: TEXT.menu.admin });
|
||||||
|
|
||||||
// 第二级:当前所属的顶级模块,带下拉横跳到其它顶级模块
|
// 第二级:当前所属的顶级模块,带下拉横跳到其它顶级模块
|
||||||
@@ -890,7 +897,7 @@ const breadcrumbs = computed(() => {
|
|||||||
}
|
}
|
||||||
// 项目详情:项目名由 viewContext 作为末项提供,继续走通用逻辑
|
// 项目详情:项目名由 viewContext 作为末项提供,继续走通用逻辑
|
||||||
} else {
|
} else {
|
||||||
// 顶级页面:模块名带同级下拉,可在管理后台各模块间横跳
|
// 顶级页面:模块名带同级下拉,可在系统管理各模块间横跳
|
||||||
items.push({
|
items.push({
|
||||||
label: curTop?.label || (route.meta?.title as string),
|
label: curTop?.label || (route.meta?.title as string),
|
||||||
path: route.path,
|
path: route.path,
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildAdminNavigationItems } from "./navigation";
|
||||||
|
|
||||||
|
const labels = (items: ReturnType<typeof buildAdminNavigationItems>) => items.map((item) => item.label);
|
||||||
|
|
||||||
|
describe("system management navigation", () => {
|
||||||
|
it("exposes project management, audit logs, and permission management to PMs", () => {
|
||||||
|
const items = buildAdminNavigationItems({
|
||||||
|
hasUser: true,
|
||||||
|
isAdmin: false,
|
||||||
|
canAccessAdminPermissions: true,
|
||||||
|
canAccessAuditLogs: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(labels(items)).toEqual(["项目管理", "审计日志", "权限管理"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expose PM system modules to a non-PM account", () => {
|
||||||
|
const items = buildAdminNavigationItems({
|
||||||
|
hasUser: true,
|
||||||
|
isAdmin: false,
|
||||||
|
canAccessAdminPermissions: false,
|
||||||
|
canAccessAuditLogs: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(labels(items)).toEqual(["项目管理"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -58,6 +58,7 @@ export const buildAdminNavigationItems = (options: {
|
|||||||
hasUser: boolean;
|
hasUser: boolean;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
canAccessAdminPermissions: boolean;
|
canAccessAdminPermissions: boolean;
|
||||||
|
canAccessAuditLogs: boolean;
|
||||||
}): LayoutNavigationItem[] => {
|
}): LayoutNavigationItem[] => {
|
||||||
if (!options.hasUser) return [];
|
if (!options.hasUser) return [];
|
||||||
|
|
||||||
@@ -78,15 +79,17 @@ export const buildAdminNavigationItems = (options: {
|
|||||||
icon: "project",
|
icon: "project",
|
||||||
keywords: ["project"],
|
keywords: ["project"],
|
||||||
});
|
});
|
||||||
|
if (options.isAdmin || options.canAccessAuditLogs) {
|
||||||
|
items.push({
|
||||||
|
label: TEXT.menu.auditLogs,
|
||||||
|
path: "/admin/audit-logs",
|
||||||
|
group: TEXT.menu.admin,
|
||||||
|
icon: "audit",
|
||||||
|
keywords: ["audit"],
|
||||||
|
});
|
||||||
|
}
|
||||||
if (options.isAdmin) {
|
if (options.isAdmin) {
|
||||||
items.push(
|
items.push(
|
||||||
{
|
|
||||||
label: TEXT.menu.auditLogs,
|
|
||||||
path: "/admin/audit-logs",
|
|
||||||
group: TEXT.menu.admin,
|
|
||||||
icon: "audit",
|
|
||||||
keywords: ["audit"],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: TEXT.menu.systemMonitoring,
|
label: TEXT.menu.systemMonitoring,
|
||||||
path: "/admin/system-monitoring",
|
path: "/admin/system-monitoring",
|
||||||
@@ -128,7 +131,7 @@ export const buildAdminNavigationItems = (options: {
|
|||||||
keywords: ["permission", "system"],
|
keywords: ["permission", "system"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "项目权限配置",
|
label: "项目访问控制",
|
||||||
path: "/admin/permissions/project",
|
path: "/admin/permissions/project",
|
||||||
group: TEXT.menu.permissionManagement,
|
group: TEXT.menu.permissionManagement,
|
||||||
icon: "key",
|
icon: "key",
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ export const TEXT = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
admin: "管理后台",
|
admin: "系统管理",
|
||||||
accountManagement: "账号管理",
|
accountManagement: "账号管理",
|
||||||
projectManagement: "项目管理",
|
projectManagement: "项目管理",
|
||||||
auditLogs: "审计日志",
|
auditLogs: "审计日志",
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ describe("admin project route permissions", () => {
|
|||||||
expect(source).toContain("const workbenchEntryPath = resolveWorkbenchEntryPath(isDesktopRuntime)");
|
expect(source).toContain("const workbenchEntryPath = resolveWorkbenchEntryPath(isDesktopRuntime)");
|
||||||
expect(source).toContain("next({ path: workbenchEntryPath });");
|
expect(source).toContain("next({ path: workbenchEntryPath });");
|
||||||
expect(source).toContain('if (isDesktopRuntime && token && !isAdmin && to.path.startsWith("/admin"))');
|
expect(source).toContain('if (isDesktopRuntime && token && !isAdmin && to.path.startsWith("/admin"))');
|
||||||
|
expect(source).toContain("const canAccessProjectManagement = studyStore.currentStudyRole === \"PM\"");
|
||||||
|
expect(source).toContain("if (!canAccessProjectManagement)");
|
||||||
expect(source).toContain('next({ path: DESKTOP_PROJECT_ENTRY_PATH });');
|
expect(source).toContain('next({ path: DESKTOP_PROJECT_ENTRY_PATH });');
|
||||||
expect(source).toContain("if (token && isWorkbenchEntryPath(to.path) && studyStore.currentStudy)");
|
expect(source).toContain("if (token && isWorkbenchEntryPath(to.path) && studyStore.currentStudy)");
|
||||||
expect(source).not.toContain("if (token && !studyStore.currentStudy && !isDesktopRuntime)");
|
expect(source).not.toContain("if (token && !studyStore.currentStudy && !isDesktopRuntime)");
|
||||||
|
|||||||
@@ -438,7 +438,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
path: "permissions/project",
|
path: "permissions/project",
|
||||||
name: "AdminPermissionsProject",
|
name: "AdminPermissionsProject",
|
||||||
component: PermissionManagement,
|
component: PermissionManagement,
|
||||||
meta: { title: "项目权限配置", systemPermission: SYSTEM_PERMISSION_PROJECT_CONFIG },
|
meta: { title: "项目访问控制", systemPermission: SYSTEM_PERMISSION_PROJECT_CONFIG },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -592,8 +592,14 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isDesktopRuntime && token && !isAdmin && to.path.startsWith("/admin")) {
|
if (isDesktopRuntime && token && !isAdmin && to.path.startsWith("/admin")) {
|
||||||
next({ path: DESKTOP_PROJECT_ENTRY_PATH });
|
const hasPmProject = studyStore.currentStudyRole === "PM" || (studyStore.currentStudy as any)?.role_in_study === "PM";
|
||||||
return;
|
if (!hasPmProject) await studyStore.ensureDefaultPmStudy().catch(() => {});
|
||||||
|
const canAccessProjectManagement = studyStore.currentStudyRole === "PM" || (studyStore.currentStudy as any)?.role_in_study === "PM";
|
||||||
|
if (!canAccessProjectManagement) {
|
||||||
|
next({ path: DESKTOP_PROJECT_ENTRY_PATH });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!studyStore.currentPermissions) await studyStore.loadCurrentStudyPermissions().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDesktopRuntime && token && isAdmin && to.path.startsWith("/admin") && studyStore.currentStudy) {
|
if (isDesktopRuntime && token && isAdmin && to.path.startsWith("/admin") && studyStore.currentStudy) {
|
||||||
|
|||||||
@@ -1060,7 +1060,8 @@ body.is-desktop-runtime .el-dialog {
|
|||||||
body.is-desktop-runtime .profile-settings-dialog,
|
body.is-desktop-runtime .profile-settings-dialog,
|
||||||
body.is-desktop-runtime .desktop-preferences-dialog,
|
body.is-desktop-runtime .desktop-preferences-dialog,
|
||||||
body.is-desktop-runtime .user-form-dialog,
|
body.is-desktop-runtime .user-form-dialog,
|
||||||
body.is-desktop-runtime .reset-password-dialog {
|
body.is-desktop-runtime .reset-password-dialog,
|
||||||
|
body.is-desktop-runtime .project-form-dialog {
|
||||||
overflow: visible !important;
|
overflow: visible !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
border-radius: 0 !important;
|
border-radius: 0 !important;
|
||||||
@@ -1112,13 +1113,15 @@ body.is-desktop-runtime .el-dialog__body {
|
|||||||
|
|
||||||
/* 账号表单去掉外框后,内容区仍需作为标题与底栏之间的连续面板。 */
|
/* 账号表单去掉外框后,内容区仍需作为标题与底栏之间的连续面板。 */
|
||||||
body.is-desktop-runtime .user-form-dialog .el-dialog__body,
|
body.is-desktop-runtime .user-form-dialog .el-dialog__body,
|
||||||
body.is-desktop-runtime .reset-password-dialog .el-dialog__body {
|
body.is-desktop-runtime .reset-password-dialog .el-dialog__body,
|
||||||
|
body.is-desktop-runtime .project-form-dialog .el-dialog__body {
|
||||||
background: rgba(248, 250, 252, 0.98);
|
background: rgba(248, 250, 252, 0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 账号操作弹窗保留单层面板:以小圆角和细边框明确边界,关闭按钮置于标题栏内。 */
|
/* 账号操作弹窗保留单层面板:以小圆角和细边框明确边界,关闭按钮置于标题栏内。 */
|
||||||
body.is-desktop-runtime .user-form-dialog,
|
body.is-desktop-runtime .user-form-dialog,
|
||||||
body.is-desktop-runtime .reset-password-dialog {
|
body.is-desktop-runtime .reset-password-dialog,
|
||||||
|
body.is-desktop-runtime .project-form-dialog {
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
overflow: hidden !important;
|
overflow: hidden !important;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.5) !important;
|
border: 1px solid rgba(148, 163, 184, 0.5) !important;
|
||||||
@@ -1126,7 +1129,8 @@ body.is-desktop-runtime .reset-password-dialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body.is-desktop-runtime .user-form-dialog .el-dialog__headerbtn,
|
body.is-desktop-runtime .user-form-dialog .el-dialog__headerbtn,
|
||||||
body.is-desktop-runtime .reset-password-dialog .el-dialog__headerbtn {
|
body.is-desktop-runtime .reset-password-dialog .el-dialog__headerbtn,
|
||||||
|
body.is-desktop-runtime .project-form-dialog .el-dialog__headerbtn {
|
||||||
top: 5px;
|
top: 5px;
|
||||||
right: 7px;
|
right: 7px;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
@@ -1222,6 +1226,7 @@ body.is-desktop-runtime .msgbox-fade-leave-to .el-message-box {
|
|||||||
:root[data-ctms-theme="dark"] body.is-desktop-runtime .desktop-preferences-dialog,
|
:root[data-ctms-theme="dark"] body.is-desktop-runtime .desktop-preferences-dialog,
|
||||||
:root[data-ctms-theme="dark"] body.is-desktop-runtime .user-form-dialog,
|
:root[data-ctms-theme="dark"] body.is-desktop-runtime .user-form-dialog,
|
||||||
:root[data-ctms-theme="dark"] body.is-desktop-runtime .reset-password-dialog,
|
:root[data-ctms-theme="dark"] body.is-desktop-runtime .reset-password-dialog,
|
||||||
|
:root[data-ctms-theme="dark"] body.is-desktop-runtime .project-form-dialog,
|
||||||
:root[data-ctms-theme="dark"] body.is-desktop-runtime .desktop-preferences-dialog .el-dialog__body {
|
:root[data-ctms-theme="dark"] body.is-desktop-runtime .desktop-preferences-dialog .el-dialog__body {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
@@ -1244,12 +1249,14 @@ body.is-desktop-runtime .msgbox-fade-leave-to .el-message-box {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root[data-ctms-theme="dark"] body.is-desktop-runtime .user-form-dialog .el-dialog__body,
|
:root[data-ctms-theme="dark"] body.is-desktop-runtime .user-form-dialog .el-dialog__body,
|
||||||
:root[data-ctms-theme="dark"] body.is-desktop-runtime .reset-password-dialog .el-dialog__body {
|
:root[data-ctms-theme="dark"] body.is-desktop-runtime .reset-password-dialog .el-dialog__body,
|
||||||
|
:root[data-ctms-theme="dark"] body.is-desktop-runtime .project-form-dialog .el-dialog__body {
|
||||||
background: rgba(17, 24, 39, 0.98);
|
background: rgba(17, 24, 39, 0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-ctms-theme="dark"] body.is-desktop-runtime .user-form-dialog,
|
:root[data-ctms-theme="dark"] body.is-desktop-runtime .user-form-dialog,
|
||||||
:root[data-ctms-theme="dark"] body.is-desktop-runtime .reset-password-dialog {
|
:root[data-ctms-theme="dark"] body.is-desktop-runtime .reset-password-dialog,
|
||||||
|
:root[data-ctms-theme="dark"] body.is-desktop-runtime .project-form-dialog {
|
||||||
border-color: rgba(71, 85, 105, 0.9) !important;
|
border-color: rgba(71, 85, 105, 0.9) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -549,6 +549,7 @@ export interface AuditLogItem {
|
|||||||
// 系统监测 - 趋势数据
|
// 系统监测 - 趋势数据
|
||||||
export interface TrendDataPoint {
|
export interface TrendDataPoint {
|
||||||
bucket_time: string;
|
bucket_time: string;
|
||||||
|
sample_state: "complete" | "partial";
|
||||||
total_checks: number;
|
total_checks: number;
|
||||||
allowed_checks: number;
|
allowed_checks: number;
|
||||||
denied_checks: number;
|
denied_checks: number;
|
||||||
@@ -557,11 +558,72 @@ export interface TrendDataPoint {
|
|||||||
cache_hits: number;
|
cache_hits: number;
|
||||||
cache_misses: number;
|
cache_misses: number;
|
||||||
cache_hit_rate: number;
|
cache_hit_rate: number;
|
||||||
|
cache_sample_available: boolean;
|
||||||
error_count: number;
|
error_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TrendPeriodSummary {
|
||||||
|
total_checks: number;
|
||||||
|
allowed_checks: number;
|
||||||
|
denied_checks: number;
|
||||||
|
deny_rate: number;
|
||||||
|
avg_elapsed_ms: number;
|
||||||
|
p95_elapsed_ms: number;
|
||||||
|
max_elapsed_ms: number;
|
||||||
|
slow_check_count: number;
|
||||||
|
active_study_count: number;
|
||||||
|
active_user_count: number;
|
||||||
|
active_endpoint_count: number;
|
||||||
|
cache_hits: number;
|
||||||
|
cache_misses: number;
|
||||||
|
cache_hit_rate: number;
|
||||||
|
cache_sample_available: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendDeniedAttribution {
|
||||||
|
endpoint_key: string;
|
||||||
|
role: string;
|
||||||
|
denied_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendSlowAttribution {
|
||||||
|
endpoint_key: string;
|
||||||
|
sample_count: number;
|
||||||
|
slow_count: number;
|
||||||
|
avg_elapsed_ms: number;
|
||||||
|
max_elapsed_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendClientAttribution {
|
||||||
|
client_type: string;
|
||||||
|
client_version: string;
|
||||||
|
client_platform: string;
|
||||||
|
total_count: number;
|
||||||
|
denied_count: number;
|
||||||
|
deny_rate: number;
|
||||||
|
avg_elapsed_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TrendsResponse {
|
export interface TrendsResponse {
|
||||||
period: string;
|
period: string;
|
||||||
|
generated_at: string;
|
||||||
|
range: {
|
||||||
|
start_at: string;
|
||||||
|
end_at: string;
|
||||||
|
previous_start_at: string;
|
||||||
|
previous_end_at: string;
|
||||||
|
bucket_seconds: number;
|
||||||
|
bucket_count: number;
|
||||||
|
timezone: string;
|
||||||
|
observed_end_at: string | null;
|
||||||
|
};
|
||||||
|
summary: TrendPeriodSummary;
|
||||||
|
previous_summary: TrendPeriodSummary;
|
||||||
|
attribution: {
|
||||||
|
top_denied: TrendDeniedAttribution[];
|
||||||
|
top_slow: TrendSlowAttribution[];
|
||||||
|
client_breakdown: TrendClientAttribution[];
|
||||||
|
};
|
||||||
data_points: TrendDataPoint[];
|
data_points: TrendDataPoint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const commands: DesktopCommand[] = [
|
|||||||
{
|
{
|
||||||
id: "hidden",
|
id: "hidden",
|
||||||
title: "系统监控",
|
title: "系统监控",
|
||||||
group: "管理后台",
|
group: "系统管理",
|
||||||
visible: false,
|
visible: false,
|
||||||
run: () => {},
|
run: () => {},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,20 +5,26 @@ import { resolve } from "node:path";
|
|||||||
const readEntryView = () => readFileSync(resolve(__dirname, "./DesktopProjectEntry.vue"), "utf8");
|
const readEntryView = () => readFileSync(resolve(__dirname, "./DesktopProjectEntry.vue"), "utf8");
|
||||||
|
|
||||||
describe("DesktopProjectEntry", () => {
|
describe("DesktopProjectEntry", () => {
|
||||||
it("offers admin backend and explicit project entry without direct in-project switching", () => {
|
it("offers system management access only to admins and authorized project PMs", () => {
|
||||||
const source = readEntryView();
|
const source = readEntryView();
|
||||||
|
|
||||||
expect(source).toContain("工作台总控");
|
expect(source).toContain("工作台总控");
|
||||||
expect(source).toContain("选择目标项目");
|
expect(source).toContain("选择目标项目");
|
||||||
expect(source).toContain("Desktop Workbench");
|
expect(source).toContain("Desktop Workbench");
|
||||||
expect(source).toContain('class="entry-background-grid"');
|
expect(source).toContain('class="entry-background-grid"');
|
||||||
expect(source).toContain("管理后台");
|
expect(source).toContain("系统管理");
|
||||||
expect(source).toContain("ADMIN SYSTEM");
|
expect(source).toContain("ADMIN SYSTEM");
|
||||||
|
expect(source).toContain("SYSTEM MANAGEMENT");
|
||||||
|
expect(source).toContain("<strong>系统管理</strong>");
|
||||||
|
expect(source).toContain('v-if="canEnterManagement"');
|
||||||
|
expect(source).toContain('projects.value.some((project) => project.role_in_study === "PM")');
|
||||||
expect(source).toContain("project-cards-grid");
|
expect(source).toContain("project-cards-grid");
|
||||||
expect(source).toContain("card-action-bar");
|
expect(source).toContain("card-action-bar");
|
||||||
expect(source).toContain("进入项目工作空间");
|
expect(source).toContain("进入项目工作空间");
|
||||||
expect(source).toContain('router.push("/admin/users")');
|
expect(source).toContain('router.push(isAdmin.value ? "/admin/users" : "/admin/projects")');
|
||||||
expect(source).toContain("studyStore.clearCurrentStudy()");
|
expect(source).toContain("studyStore.clearCurrentStudy()");
|
||||||
|
expect(source).toContain("studyStore.setCurrentStudy(pmProject)");
|
||||||
|
expect(source).toContain("projects.value.find((project) => project.role_in_study === \"PM\")");
|
||||||
expect(source).toContain("fetchStudies()");
|
expect(source).toContain("fetchStudies()");
|
||||||
expect(source).toContain("studyStore.setCurrentStudy(project)");
|
expect(source).toContain("studyStore.setCurrentStudy(project)");
|
||||||
expect(source).toContain("studyStore.loadCurrentStudyPermissions()");
|
expect(source).toContain("studyStore.loadCurrentStudyPermissions()");
|
||||||
|
|||||||
@@ -32,20 +32,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 管理后台控制卡片 - 常驻左侧底部 -->
|
<!-- ADMIN / PM 可进入系统管理,其他角色仅可选择项目 -->
|
||||||
<div v-if="isAdmin" class="sidebar-admin">
|
<div v-if="canEnterManagement" class="sidebar-admin">
|
||||||
<button class="admin-action-card" type="button" @click="enterAdmin">
|
<button class="admin-action-card" type="button" @click="enterManagement">
|
||||||
<span class="admin-glow-layer" aria-hidden="true"></span>
|
<span class="admin-glow-layer" aria-hidden="true"></span>
|
||||||
<div class="admin-card-head">
|
<div class="admin-card-head">
|
||||||
<div class="admin-icon-box">
|
<div class="admin-icon-box">
|
||||||
<el-icon><Monitor /></el-icon>
|
<el-icon><Monitor /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-text-box">
|
<div class="admin-text-box">
|
||||||
<span class="admin-kicker">ADMIN SYSTEM</span>
|
<span class="admin-kicker">{{ isAdmin ? "ADMIN SYSTEM" : "SYSTEM MANAGEMENT" }}</span>
|
||||||
<strong>管理后台</strong>
|
<strong>系统管理</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="admin-desc">人员账号管理、项目授权、审计及系统配置</p>
|
<p class="admin-desc">
|
||||||
|
{{ isAdmin ? "人员账号管理、项目授权、审计及系统配置" : "查看并维护您负责项目的授权与范围配置" }}
|
||||||
|
</p>
|
||||||
<div class="admin-foot">
|
<div class="admin-foot">
|
||||||
<span>进入控制台</span>
|
<span>进入控制台</span>
|
||||||
<el-icon><ArrowRight /></el-icon>
|
<el-icon><ArrowRight /></el-icon>
|
||||||
@@ -170,13 +172,16 @@ const loading = ref(false);
|
|||||||
const loggingOut = ref(false);
|
const loggingOut = ref(false);
|
||||||
const isTransitioning = ref(false);
|
const isTransitioning = ref(false);
|
||||||
const isAdmin = computed(() => isSystemAdmin(auth.user));
|
const isAdmin = computed(() => isSystemAdmin(auth.user));
|
||||||
|
const canEnterManagement = computed(() =>
|
||||||
|
isAdmin.value || projects.value.some((project) => project.role_in_study === "PM"),
|
||||||
|
);
|
||||||
|
|
||||||
const userDisplayName = computed(
|
const userDisplayName = computed(
|
||||||
() => auth.user?.full_name || auth.user?.username || auth.user?.email || TEXT.common.labels.userFallback,
|
() => auth.user?.full_name || auth.user?.username || auth.user?.email || TEXT.common.labels.userFallback,
|
||||||
);
|
);
|
||||||
const userInitial = computed(() => userDisplayName.value.charAt(0).toUpperCase() || TEXT.common.labels.userInitialFallback);
|
const userInitial = computed(() => userDisplayName.value.charAt(0).toUpperCase() || TEXT.common.labels.userInitialFallback);
|
||||||
const entrySubtitle = computed(() =>
|
const entrySubtitle = computed(() =>
|
||||||
isAdmin.value ? "进入管理后台,或选择一个项目开始桌面工作。" : "请选择本次要进入的项目。",
|
canEnterManagement.value ? "进入系统管理,或选择一个项目开始桌面工作。" : "请选择本次要进入的项目。",
|
||||||
);
|
);
|
||||||
|
|
||||||
const statusLabel = (status: string | undefined) =>
|
const statusLabel = (status: string | undefined) =>
|
||||||
@@ -194,12 +199,20 @@ const loadProjects = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const enterAdmin = () => {
|
const enterManagement = () => {
|
||||||
if (!isAdmin.value) return;
|
if (!canEnterManagement.value) return;
|
||||||
isTransitioning.value = true;
|
isTransitioning.value = true;
|
||||||
window.setTimeout(() => {
|
window.setTimeout(async () => {
|
||||||
studyStore.clearCurrentStudy();
|
if (isAdmin.value) {
|
||||||
router.push("/admin/users");
|
studyStore.clearCurrentStudy();
|
||||||
|
} else {
|
||||||
|
const pmProject = projects.value.find((project) => project.role_in_study === "PM");
|
||||||
|
if (pmProject) {
|
||||||
|
studyStore.setCurrentStudy(pmProject);
|
||||||
|
await studyStore.loadCurrentStudyPermissions().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await router.push(isAdmin.value ? "/admin/users" : "/admin/projects");
|
||||||
}, 300);
|
}, 300);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -414,7 +427,7 @@ onMounted(() => {
|
|||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 常驻管理后台卡片(精致浅色流光) */
|
/* 常驻系统管理卡片(精致浅色流光) */
|
||||||
.sidebar-admin {
|
.sidebar-admin {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ describe("WebWorkbenchEntry", () => {
|
|||||||
expect(source).toContain("项目内不直接切换项目");
|
expect(source).toContain("项目内不直接切换项目");
|
||||||
expect(source).toContain("项目工作区");
|
expect(source).toContain("项目工作区");
|
||||||
expect(source).toContain("fetchStudies()");
|
expect(source).toContain("fetchStudies()");
|
||||||
|
expect(source).toContain('projects.value.some((project) => project.role_in_study === "PM")');
|
||||||
|
expect(source).toContain('v-if="canEnterManagement"');
|
||||||
|
expect(source).toContain('<strong>系统管理</strong>');
|
||||||
|
expect(source).toContain('const managementActionLabel = computed(() => canEnterManagement.value ? "进入系统管理" : "");');
|
||||||
expect(source).toContain("studyStore.clearCurrentStudy()");
|
expect(source).toContain("studyStore.clearCurrentStudy()");
|
||||||
|
expect(source).toContain("studyStore.setCurrentStudy(pmProject)");
|
||||||
|
expect(source).toContain("projects.value.find((project) => project.role_in_study === \"PM\")");
|
||||||
expect(source).toContain('router.push(isAdmin.value ? "/admin/users" : "/admin/projects")');
|
expect(source).toContain('router.push(isAdmin.value ? "/admin/users" : "/admin/projects")');
|
||||||
expect(source).toContain("studyStore.setCurrentStudy(project)");
|
expect(source).toContain("studyStore.setCurrentStudy(project)");
|
||||||
expect(source).toContain("studyStore.loadCurrentStudyPermissions()");
|
expect(source).toContain("studyStore.loadCurrentStudyPermissions()");
|
||||||
|
|||||||
@@ -32,8 +32,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 管理后台/项目管理控制卡片 - 常驻左侧底部 -->
|
<!-- ADMIN / PM 可进入系统管理,其他角色仅可选择项目 -->
|
||||||
<div class="sidebar-admin">
|
<div v-if="canEnterManagement" class="sidebar-admin">
|
||||||
<button class="admin-action-card" type="button" @click="enterManagement">
|
<button class="admin-action-card" type="button" @click="enterManagement">
|
||||||
<span class="admin-glow-layer" aria-hidden="true"></span>
|
<span class="admin-glow-layer" aria-hidden="true"></span>
|
||||||
<div class="admin-card-head">
|
<div class="admin-card-head">
|
||||||
@@ -41,8 +41,8 @@
|
|||||||
<el-icon><Monitor /></el-icon>
|
<el-icon><Monitor /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-text-box">
|
<div class="admin-text-box">
|
||||||
<span class="admin-kicker">{{ isAdmin ? "ADMIN SYSTEM" : "STUDY PORTAL" }}</span>
|
<span class="admin-kicker">{{ isAdmin ? "ADMIN SYSTEM" : "SYSTEM MANAGEMENT" }}</span>
|
||||||
<strong>{{ isAdmin ? "管理后台" : "项目管理" }}</strong>
|
<strong>系统管理</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="admin-desc">
|
<p class="admin-desc">
|
||||||
@@ -206,18 +206,22 @@ const loading = ref(false);
|
|||||||
const loggingOut = ref(false);
|
const loggingOut = ref(false);
|
||||||
const isTransitioning = ref(false);
|
const isTransitioning = ref(false);
|
||||||
const isAdmin = computed(() => isSystemAdmin(auth.user));
|
const isAdmin = computed(() => isSystemAdmin(auth.user));
|
||||||
|
const canEnterManagement = computed(() =>
|
||||||
|
isAdmin.value || projects.value.some((project) => project.role_in_study === "PM"),
|
||||||
|
);
|
||||||
|
|
||||||
const userDisplayName = computed(
|
const userDisplayName = computed(
|
||||||
() => auth.user?.full_name || auth.user?.username || auth.user?.email || TEXT.common.labels.userFallback,
|
() => auth.user?.full_name || auth.user?.username || auth.user?.email || TEXT.common.labels.userFallback,
|
||||||
);
|
);
|
||||||
const userInitial = computed(() => userDisplayName.value.charAt(0).toUpperCase() || TEXT.common.labels.userInitialFallback);
|
const userInitial = computed(() => userDisplayName.value.charAt(0).toUpperCase() || TEXT.common.labels.userInitialFallback);
|
||||||
|
|
||||||
const entryDescription = computed(() =>
|
const entryDescription = computed(() => {
|
||||||
isAdmin.value
|
if (!canEnterManagement.value) return "选择本次要进入的项目进入项目工作区。";
|
||||||
? "进入管理后台处理账号、授权 and 系统配置,或选择项目进入项目工作区。"
|
return isAdmin.value
|
||||||
: "选择本次要进入的项目,或进入项目管理查看您已授权的项目范围。",
|
? "进入系统管理处理账号、授权及系统配置,或选择项目进入项目工作区。"
|
||||||
);
|
: "进入系统管理查看您已授权的项目范围,或选择项目进入项目工作区。";
|
||||||
const managementActionLabel = computed(() => isAdmin.value ? "进入管理后台" : "进入项目管理");
|
});
|
||||||
|
const managementActionLabel = computed(() => canEnterManagement.value ? "进入系统管理" : "");
|
||||||
|
|
||||||
const statusLabel = (status: string | undefined) =>
|
const statusLabel = (status: string | undefined) =>
|
||||||
status ? TEXT.enums.projectStatus[status as keyof typeof TEXT.enums.projectStatus] || status : "未知";
|
status ? TEXT.enums.projectStatus[status as keyof typeof TEXT.enums.projectStatus] || status : "未知";
|
||||||
@@ -235,9 +239,18 @@ const loadProjects = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const enterManagement = async () => {
|
const enterManagement = async () => {
|
||||||
|
if (!canEnterManagement.value) return;
|
||||||
isTransitioning.value = true;
|
isTransitioning.value = true;
|
||||||
window.setTimeout(async () => {
|
window.setTimeout(async () => {
|
||||||
studyStore.clearCurrentStudy();
|
if (isAdmin.value) {
|
||||||
|
studyStore.clearCurrentStudy();
|
||||||
|
} else {
|
||||||
|
const pmProject = projects.value.find((project) => project.role_in_study === "PM");
|
||||||
|
if (pmProject) {
|
||||||
|
studyStore.setCurrentStudy(pmProject);
|
||||||
|
await studyStore.loadCurrentStudyPermissions().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
await router.push(isAdmin.value ? "/admin/users" : "/admin/projects").catch(() => {});
|
await router.push(isAdmin.value ? "/admin/users" : "/admin/projects").catch(() => {});
|
||||||
isTransitioning.value = false;
|
isTransitioning.value = false;
|
||||||
}, 300);
|
}, 300);
|
||||||
@@ -461,7 +474,7 @@ onMounted(() => {
|
|||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 常驻管理后台卡片(精致浅色流光) */
|
/* 常驻系统管理卡片(精致浅色流光) */
|
||||||
.sidebar-admin {
|
.sidebar-admin {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,21 @@ describe("audit logs access", () => {
|
|||||||
it("keeps the audit summary header compact", () => {
|
it("keeps the audit summary header compact", () => {
|
||||||
const source = readAuditLogsView();
|
const source = readAuditLogsView();
|
||||||
|
|
||||||
expect(source).toContain("padding: 10px 16px;");
|
expect(source).toContain('class="audit-overview"');
|
||||||
expect(source).toContain("padding: 9px 12px;");
|
expect(source).toContain("min-height: 48px;");
|
||||||
expect(source).toContain("width: 32px;");
|
expect(source).toContain("width: 30px;");
|
||||||
expect(source).toContain("height: 32px;");
|
expect(source).toContain("height: 30px;");
|
||||||
expect(source).toContain(".stat-icon svg { width: 18px; height: 18px; }");
|
expect(source).toContain(".stat-icon svg { width: 17px; height: 17px; }");
|
||||||
expect(source).toContain(".stat-value { font-size: 20px;");
|
expect(source).toContain(".stat-value { font-size: 18px;");
|
||||||
expect(source).toContain(".stat-label { font-size: 11px;");
|
expect(source).toContain(".stat-label { font-size: 10px;");
|
||||||
|
expect(source).toContain("content-wrapper:has(.audit-logs-page)");
|
||||||
|
expect(source).toContain(".table-section { padding: 0 !important; }");
|
||||||
|
expect(source).toContain("position: sticky;");
|
||||||
|
expect(source).toContain('class="filter-item-form project-filter-group"');
|
||||||
|
expect(source).toContain('class="project-export-button"');
|
||||||
|
expect(source).toContain('@click="confirmExport"');
|
||||||
|
expect(source).not.toContain('class="audit-export-btn"');
|
||||||
|
expect(source).not.toContain("handleExportCommand");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loads audit logs from the selected project context", () => {
|
it("loads audit logs from the selected project context", () => {
|
||||||
@@ -67,16 +75,16 @@ describe("audit logs access", () => {
|
|||||||
expect(source).not.toContain("limit: 2000");
|
expect(source).not.toContain("limit: 2000");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("surfaces request source, IP and location filters for audit troubleshooting", () => {
|
it("keeps the filter bar focused without request-source or IP-location inputs", () => {
|
||||||
const source = readAuditLogsView();
|
const source = readAuditLogsView();
|
||||||
|
|
||||||
expect(source).toContain("filters.clientType");
|
expect(source).not.toContain("filters.clientType");
|
||||||
expect(source).toContain("filters.ipKeyword");
|
expect(source).not.toContain("filters.ipKeyword");
|
||||||
expect(source).toContain("TEXT.modules.adminAuditLogs.filterSource");
|
expect(source).not.toContain("TEXT.modules.adminAuditLogs.filterSource");
|
||||||
expect(source).toContain("TEXT.modules.adminAuditLogs.filterIpLocation");
|
expect(source).not.toContain("TEXT.modules.adminAuditLogs.filterIpLocation");
|
||||||
expect(source).toContain("TEXT.modules.adminAuditLogs.columns.source");
|
expect(source).not.toContain("TEXT.modules.adminAuditLogs.columns.source");
|
||||||
expect(source).toContain("formatClientIpLine(scope.row)");
|
expect(source).not.toContain("formatClientIpLine(scope.row)");
|
||||||
expect(source).toContain("client_ip: ipKeyword && isIpSearchToken(ipKeyword) ? ipKeyword : undefined");
|
expect(source).not.toContain("isIpSearchToken");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes request source context in audit exports", () => {
|
it("includes request source context in audit exports", () => {
|
||||||
@@ -108,13 +116,16 @@ describe("audit logs access", () => {
|
|||||||
expect(source).not.toContain("getInitials(scope.row.actorName)");
|
expect(source).not.toContain("getInitials(scope.row.actorName)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lets target and detail columns absorb remaining table width", () => {
|
it("allocates audit columns by typical content length", () => {
|
||||||
const source = readAuditLogsView();
|
const source = readAuditLogsView();
|
||||||
|
|
||||||
expect(source).toContain('<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" min-width="240">');
|
expect(source).toContain('prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="128"');
|
||||||
|
expect(source).toContain('prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="155"');
|
||||||
|
expect(source).toContain('prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" width="170"');
|
||||||
|
expect(source).toContain('<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" width="280">');
|
||||||
expect(source).toContain('<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" min-width="360">');
|
expect(source).toContain('<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" min-width="360">');
|
||||||
expect(source).not.toContain(':label="TEXT.modules.adminAuditLogs.columns.target" width="240"');
|
expect(source).toContain('width="72" align="center" class-name="result-column"');
|
||||||
expect(source).not.toContain(':label="TEXT.modules.adminAuditLogs.columns.diff" width="360"');
|
expect(source).toContain("white-space: nowrap;");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses diff lines by the last field separator before the arrow", () => {
|
it("parses diff lines by the last field separator before the arrow", () => {
|
||||||
@@ -133,6 +144,18 @@ describe("audit logs access", () => {
|
|||||||
expect(source).not.toContain("meta-card-full");
|
expect(source).not.toContain("meta-card-full");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the audit detail focused on business changes instead of technical request context", () => {
|
||||||
|
const source = readAuditLogsView();
|
||||||
|
|
||||||
|
expect(source).not.toContain('<span class="meta-label">请求来源</span>');
|
||||||
|
expect(source).not.toContain('<span class="meta-label">来源 IP</span>');
|
||||||
|
expect(source).not.toContain('<span class="meta-label">IP 位置</span>');
|
||||||
|
expect(source).not.toContain('<span class="meta-label">客户端</span>');
|
||||||
|
expect(source).not.toContain("访问上下文");
|
||||||
|
expect(source).not.toContain("formatClientMeta");
|
||||||
|
expect(source).not.toContain("formatBuildMeta");
|
||||||
|
});
|
||||||
|
|
||||||
it("groups setup diff detail rows by business item path", () => {
|
it("groups setup diff detail rows by business item path", () => {
|
||||||
const source = readAuditLogsView();
|
const source = readAuditLogsView();
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page page--flush">
|
<div class="page page--flush audit-logs-page">
|
||||||
<div class="main-content-flat unified-shell">
|
<div class="main-content-flat unified-shell">
|
||||||
<!-- 统计概览 -->
|
<!-- 统计概览 -->
|
||||||
<div class="stats-row">
|
<section class="audit-overview" aria-labelledby="audit-overview-title">
|
||||||
|
<div class="overview-heading">
|
||||||
|
<h2 id="audit-overview-title">审计概览</h2>
|
||||||
|
<span class="overview-status"><i></i>日志汇总</span>
|
||||||
|
</div>
|
||||||
|
<div class="stats-row">
|
||||||
<div class="stat-card stat-card--total">
|
<div class="stat-card stat-card--total">
|
||||||
<div class="stat-icon">
|
<div class="stat-icon">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><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"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><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"/></svg>
|
||||||
@@ -39,15 +44,30 @@
|
|||||||
<span class="stat-label">来源 IP</span>
|
<span class="stat-label">来源 IP</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- 筛选栏 -->
|
<!-- 筛选栏 -->
|
||||||
<div class="audit-toolbar unified-action-bar">
|
<div class="audit-toolbar unified-action-bar">
|
||||||
<el-form :inline="true" :model="filters" class="filter-form">
|
<el-form :inline="true" :model="filters" class="filter-form">
|
||||||
<div class="filter-item-form">
|
<div class="filter-heading">
|
||||||
|
<el-icon><Filter /></el-icon>
|
||||||
|
<span>筛选</span>
|
||||||
|
</div>
|
||||||
|
<div class="filter-item-form project-filter-group">
|
||||||
<el-select v-model="selectedStudyId" filterable :placeholder="TEXT.common.fields.projectName" @change="onProjectChange" class="filter-select-project">
|
<el-select v-model="selectedStudyId" filterable :placeholder="TEXT.common.fields.projectName" @change="onProjectChange" class="filter-select-project">
|
||||||
<el-option v-for="project in studies" :key="project.id" :label="project.name" :value="project.id" />
|
<el-option v-for="project in studies" :key="project.id" :label="project.name" :value="project.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<el-tooltip content="导出当前项目日志" placement="top">
|
||||||
|
<el-button
|
||||||
|
v-if="canProjectExport"
|
||||||
|
:icon="Download"
|
||||||
|
class="project-export-button"
|
||||||
|
:loading="exportLoading"
|
||||||
|
aria-label="导出当前项目日志"
|
||||||
|
@click="confirmExport"
|
||||||
|
/>
|
||||||
|
</el-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-item-form">
|
<div class="filter-item-form">
|
||||||
<el-select v-model="filters.eventType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEvent" @change="onServerFilterChange" class="filter-select-comp">
|
<el-select v-model="filters.eventType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEvent" @change="onServerFilterChange" class="filter-select-comp">
|
||||||
@@ -59,22 +79,6 @@
|
|||||||
<el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
|
<el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-item-form">
|
|
||||||
<el-select v-model="filters.clientType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterSource" @change="onServerFilterChange" class="filter-select-source">
|
|
||||||
<el-option v-for="opt in sourceTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
|
||||||
</el-select>
|
|
||||||
</div>
|
|
||||||
<div class="filter-item-form">
|
|
||||||
<el-input
|
|
||||||
v-model="filters.ipKeyword"
|
|
||||||
clearable
|
|
||||||
:placeholder="TEXT.modules.adminAuditLogs.filterIpLocation"
|
|
||||||
class="filter-input-ip"
|
|
||||||
@input="onLocalFilterChange"
|
|
||||||
@clear="onServerFilterChange"
|
|
||||||
@change="onServerFilterChange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="filter-item-form">
|
<div class="filter-item-form">
|
||||||
<el-select v-model="filters.result" clearable :placeholder="TEXT.modules.adminAuditLogs.filterResult" @change="onLocalFilterChange" class="filter-select-result">
|
<el-select v-model="filters.result" clearable :placeholder="TEXT.modules.adminAuditLogs.filterResult" @change="onLocalFilterChange" class="filter-select-result">
|
||||||
<el-option :label="TEXT.audit.resultSuccess" value="SUCCESS" />
|
<el-option :label="TEXT.audit.resultSuccess" value="SUCCESS" />
|
||||||
@@ -94,30 +98,19 @@
|
|||||||
@change="onLocalFilterChange"
|
@change="onLocalFilterChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-spacer"></div>
|
<el-button v-if="hasActiveFilters" text :icon="RefreshLeft" class="reset-filter-button" @click="resetFilters">重置</el-button>
|
||||||
<el-dropdown trigger="click" @command="handleExportCommand" class="audit-export-dropdown">
|
|
||||||
<el-button plain class="audit-export-btn" :loading="exportLoading">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:4px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
|
||||||
导出 <el-icon class="el-icon--right"><ArrowDown /></el-icon>
|
|
||||||
</el-button>
|
|
||||||
<template #dropdown>
|
|
||||||
<el-dropdown-menu>
|
|
||||||
<el-dropdown-item v-if="canProjectExport" command="project">{{ TEXT.modules.adminAuditLogs.exportProject }}</el-dropdown-item>
|
|
||||||
</el-dropdown-menu>
|
|
||||||
</template>
|
|
||||||
</el-dropdown>
|
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 日志表格 -->
|
<!-- 日志表格 -->
|
||||||
<div class="unified-section table-section">
|
<div class="unified-section table-section">
|
||||||
<el-table :data="logs" v-loading="loading" class="audit-table" style="width: 100%" table-layout="fixed">
|
<el-table :data="logs" v-loading="loading" class="audit-table" style="width: 100%" table-layout="fixed">
|
||||||
<el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="150">
|
<el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="128">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<span class="time-text">{{ displayDateTime(scope.row.timestamp) }}</span>
|
<span class="time-text">{{ displayDateTime(scope.row.timestamp) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="120">
|
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="155">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<div class="actor-cell">
|
<div class="actor-cell">
|
||||||
<span class="actor-name">{{ scope.row.actorName }}</span>
|
<span class="actor-name">{{ scope.row.actorName }}</span>
|
||||||
@@ -125,21 +118,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.source" width="230">
|
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" width="170">
|
||||||
<template #default="scope">
|
|
||||||
<div class="source-cell">
|
|
||||||
<span class="source-main">{{ scope.row.clientSourceLabel }}</span>
|
|
||||||
<span class="source-meta">{{ formatClientMeta(scope.row) }}</span>
|
|
||||||
<span class="source-ip">{{ formatClientIpLine(scope.row) }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" width="145">
|
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<span class="event-tag">{{ scope.row.eventLabel }}</span>
|
<span class="event-tag">{{ scope.row.eventLabel }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" min-width="240">
|
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" width="280">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<div class="target-cell">
|
<div class="target-cell">
|
||||||
<span v-if="scope.row.targetTypeLabel" class="target-text">
|
<span v-if="scope.row.targetTypeLabel" class="target-text">
|
||||||
@@ -161,7 +145,7 @@
|
|||||||
<span v-else class="text-muted">{{ TEXT.audit.emptyValue }}</span>
|
<span v-else class="text-muted">{{ TEXT.audit.emptyValue }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="76" align="center">
|
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="72" align="center" class-name="result-column">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<span class="result-badge" :class="scope.row.result === 'SUCCESS' ? 'result--success' : 'result--fail'">
|
<span class="result-badge" :class="scope.row.result === 'SUCCESS' ? 'result--success' : 'result--fail'">
|
||||||
{{ scope.row.resultLabel }}
|
{{ scope.row.resultLabel }}
|
||||||
@@ -237,35 +221,6 @@
|
|||||||
<span class="meta-value">{{ selectedLog.actionText || TEXT.common.fallback }}</span>
|
<span class="meta-value">{{ selectedLog.actionText || TEXT.common.fallback }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-meta-grid">
|
|
||||||
<div class="meta-card">
|
|
||||||
<span class="meta-label">请求来源</span>
|
|
||||||
<span class="meta-value">{{ selectedLog.clientSourceLabel || TEXT.common.fallback }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="meta-card">
|
|
||||||
<span class="meta-label">来源 IP</span>
|
|
||||||
<span class="meta-value">{{ selectedLog.clientIp || "未知 IP" }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="meta-card">
|
|
||||||
<span class="meta-label">IP 位置</span>
|
|
||||||
<span class="meta-value">{{ selectedLog.ipLocation || TEXT.common.fallback }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="meta-card">
|
|
||||||
<span class="meta-label">客户端</span>
|
|
||||||
<span class="meta-value">{{ formatClientMeta(selectedLog) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="detail-section">
|
|
||||||
<div class="detail-section-title">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 0 20"/><path d="M12 2a15.3 15.3 0 0 0 0 20"/></svg>
|
|
||||||
访问上下文
|
|
||||||
</div>
|
|
||||||
<div class="context-lines">
|
|
||||||
<div><span>User-Agent</span><code>{{ selectedLog.userAgent || TEXT.common.fallback }}</code></div>
|
|
||||||
<div><span>构建信息</span><code>{{ formatBuildMeta(selectedLog) }}</code></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 变更明细 -->
|
<!-- 变更明细 -->
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<div class="detail-section-title">
|
<div class="detail-section-title">
|
||||||
@@ -310,7 +265,7 @@
|
|||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { ArrowDown } from "@element-plus/icons-vue";
|
import { Download, Filter, RefreshLeft } from "@element-plus/icons-vue";
|
||||||
import { createAuditEvent, fetchAuditLogs } from "../../api/auditLogs";
|
import { createAuditEvent, fetchAuditLogs } from "../../api/auditLogs";
|
||||||
import { fetchStudies } from "../../api/studies";
|
import { fetchStudies } from "../../api/studies";
|
||||||
import { fetchApiEndpointPermissions } from "../../api/projectPermissions";
|
import { fetchApiEndpointPermissions } from "../../api/projectPermissions";
|
||||||
@@ -349,8 +304,6 @@ const DIFF_PREVIEW_LIMIT = 2;
|
|||||||
const filters = ref({
|
const filters = ref({
|
||||||
eventType: "",
|
eventType: "",
|
||||||
operatorId: "",
|
operatorId: "",
|
||||||
clientType: "",
|
|
||||||
ipKeyword: "",
|
|
||||||
result: "",
|
result: "",
|
||||||
range: [],
|
range: [],
|
||||||
});
|
});
|
||||||
@@ -358,6 +311,12 @@ const filters = ref({
|
|||||||
const successCount = computed(() => allLogs.value.filter(l => (l.result || 'SUCCESS') === 'SUCCESS').length);
|
const successCount = computed(() => allLogs.value.filter(l => (l.result || 'SUCCESS') === 'SUCCESS').length);
|
||||||
const failCount = computed(() => allLogs.value.filter(l => l.result === 'FAIL').length);
|
const failCount = computed(() => allLogs.value.filter(l => l.result === 'FAIL').length);
|
||||||
const uniqueIpCount = computed(() => new Set(allLogs.value.map(l => l.clientIp || "未知 IP")).size);
|
const uniqueIpCount = computed(() => new Set(allLogs.value.map(l => l.clientIp || "未知 IP")).size);
|
||||||
|
const hasActiveFilters = computed(() => Boolean(
|
||||||
|
filters.value.eventType ||
|
||||||
|
filters.value.operatorId ||
|
||||||
|
filters.value.result ||
|
||||||
|
filters.value.range?.length
|
||||||
|
));
|
||||||
|
|
||||||
const formatDiffLine = (line: any): string => {
|
const formatDiffLine = (line: any): string => {
|
||||||
if (typeof line === 'string') return line;
|
if (typeof line === 'string') return line;
|
||||||
@@ -430,11 +389,6 @@ const eventTypeOptions = Object.entries(auditDict).map(([value, cfg]) => ({
|
|||||||
value,
|
value,
|
||||||
label: cfg.label,
|
label: cfg.label,
|
||||||
}));
|
}));
|
||||||
const sourceTypeOptions = [
|
|
||||||
{ value: "web", label: "网页端" },
|
|
||||||
{ value: "desktop", label: "桌面端" },
|
|
||||||
{ value: "unknown", label: "未知/异常" },
|
|
||||||
];
|
|
||||||
const resolveUserDisplayName = (u: any): string => {
|
const resolveUserDisplayName = (u: any): string => {
|
||||||
return u?.full_name || u?.display_name || u?.username || u?.email || u?.id || TEXT.common.fallback;
|
return u?.full_name || u?.display_name || u?.username || u?.email || u?.id || TEXT.common.fallback;
|
||||||
};
|
};
|
||||||
@@ -530,34 +484,13 @@ const fetchAuditLogPages = async (baseParams: Record<string, any> = {}) => {
|
|||||||
return allItems;
|
return allItems;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isIpSearchToken = (value: string) => /^[0-9a-f:.]+$/i.test(value.trim());
|
|
||||||
|
|
||||||
const buildAuditServerParams = () => {
|
const buildAuditServerParams = () => {
|
||||||
const ipKeyword = String(filters.value.ipKeyword || "").trim();
|
|
||||||
return {
|
return {
|
||||||
action: filters.value.eventType || undefined,
|
action: filters.value.eventType || undefined,
|
||||||
operator_id: filters.value.operatorId || undefined,
|
operator_id: filters.value.operatorId || undefined,
|
||||||
client_type: filters.value.clientType || undefined,
|
|
||||||
client_ip: ipKeyword && isIpSearchToken(ipKeyword) ? ipKeyword : undefined,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatClientMeta = (log: any) => {
|
|
||||||
const meta = [log.clientType, log.clientVersion, log.clientPlatform].filter(Boolean).join("/");
|
|
||||||
if (meta) return meta;
|
|
||||||
return log.userAgent ? "未标识客户端" : TEXT.common.fallback;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatBuildMeta = (log: any) => {
|
|
||||||
const meta = [log.buildChannel, log.buildCommit].filter(Boolean).join("/");
|
|
||||||
return meta || TEXT.common.fallback;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatClientIpLine = (log: any) => {
|
|
||||||
const parts = [log.clientIp || "未知 IP", log.ipLocation].filter(Boolean);
|
|
||||||
return parts.join(" / ");
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadLogs = async () => {
|
const loadLogs = async () => {
|
||||||
if (!selectedStudyId.value) return;
|
if (!selectedStudyId.value) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -595,24 +528,6 @@ const enrichLogs = (items: any[]) => {
|
|||||||
const filterLogs = (items: any[]) =>
|
const filterLogs = (items: any[]) =>
|
||||||
items.filter((log) => {
|
items.filter((log) => {
|
||||||
if (filters.value.result && (log.result || "SUCCESS") !== filters.value.result) return false;
|
if (filters.value.result && (log.result || "SUCCESS") !== filters.value.result) return false;
|
||||||
const ipKeyword = String(filters.value.ipKeyword || "").trim().toLowerCase();
|
|
||||||
if (ipKeyword) {
|
|
||||||
const haystack = [
|
|
||||||
log.clientIp,
|
|
||||||
log.ipLocation,
|
|
||||||
log.ipCountry,
|
|
||||||
log.ipProvince,
|
|
||||||
log.ipCity,
|
|
||||||
log.ipIsp,
|
|
||||||
log.clientSourceLabel,
|
|
||||||
log.clientType,
|
|
||||||
log.clientPlatform,
|
|
||||||
log.userAgent,
|
|
||||||
log.actorName,
|
|
||||||
log.actorAccount,
|
|
||||||
];
|
|
||||||
if (!haystack.some((value) => String(value || "").toLowerCase().includes(ipKeyword))) return false;
|
|
||||||
}
|
|
||||||
if (filters.value.range?.length === 2) {
|
if (filters.value.range?.length === 2) {
|
||||||
const ts = new Date(log.timestamp);
|
const ts = new Date(log.timestamp);
|
||||||
const start = new Date(filters.value.range[0]);
|
const start = new Date(filters.value.range[0]);
|
||||||
@@ -641,6 +556,14 @@ const onLocalFilterChange = () => {
|
|||||||
refreshPagedLogs();
|
refreshPagedLogs();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
filters.value.eventType = "";
|
||||||
|
filters.value.operatorId = "";
|
||||||
|
filters.value.result = "";
|
||||||
|
filters.value.range = [];
|
||||||
|
onServerFilterChange();
|
||||||
|
};
|
||||||
|
|
||||||
const onProjectChange = async () => {
|
const onProjectChange = async () => {
|
||||||
permissionMatrix.value = null;
|
permissionMatrix.value = null;
|
||||||
users.value = [];
|
users.value = [];
|
||||||
@@ -697,10 +620,6 @@ const fetchAllForExport = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExportCommand = (command: string) => {
|
|
||||||
if (command === "project") confirmExport();
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmExport = async () => {
|
const confirmExport = async () => {
|
||||||
const currentStudy = selectedStudy.value;
|
const currentStudy = selectedStudy.value;
|
||||||
if (!currentStudy) return;
|
if (!currentStudy) return;
|
||||||
@@ -736,33 +655,77 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
:global(.web-layout-container .content-wrapper:has(.audit-logs-page)) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* 统计卡片 */
|
/* 统计卡片 */
|
||||||
|
.audit-overview {
|
||||||
|
padding: 0;
|
||||||
|
border-bottom: 1px solid var(--unified-shell-divider);
|
||||||
|
background: linear-gradient(180deg, var(--ctms-bg-card) 0%, color-mix(in srgb, var(--ctms-neutral-100) 55%, var(--ctms-bg-card)) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-heading h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ctms-text-main);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: 1px solid rgba(63, 143, 107, 0.18);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(63, 143, 107, 0.07);
|
||||||
|
color: var(--ctms-success);
|
||||||
|
font-size: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-status i {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
.stats-row {
|
.stats-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 10px;
|
gap: 0;
|
||||||
padding: 10px 16px;
|
border-top: 1px solid var(--unified-shell-divider);
|
||||||
border-bottom: 1px solid var(--unified-shell-divider);
|
background: var(--ctms-bg-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 9px 12px;
|
min-height: 48px;
|
||||||
border-radius: 10px;
|
padding: 6px 14px;
|
||||||
background: var(--ctms-neutral-100);
|
background: transparent;
|
||||||
transition: var(--ctms-transition);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card:hover {
|
.stat-card + .stat-card {
|
||||||
transform: translateY(-1px);
|
border-left: 1px solid var(--unified-shell-divider);
|
||||||
box-shadow: var(--ctms-shadow-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-icon {
|
.stat-icon {
|
||||||
width: 32px;
|
width: 30px;
|
||||||
height: 32px;
|
height: 30px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -770,19 +733,27 @@ onMounted(async () => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-icon svg { width: 18px; height: 18px; }
|
.stat-icon svg { width: 17px; height: 17px; }
|
||||||
.stat-card--total .stat-icon { background: #e8edf3; color: var(--ctms-primary); }
|
.stat-card--total .stat-icon { background: #e8edf3; color: var(--ctms-primary); }
|
||||||
.stat-card--success .stat-icon { background: #e6f4ed; color: var(--ctms-success); }
|
.stat-card--success .stat-icon { background: #e6f4ed; color: var(--ctms-success); }
|
||||||
.stat-card--fail .stat-icon { background: #fde8e8; color: var(--ctms-danger); }
|
.stat-card--fail .stat-icon { background: #fde8e8; color: var(--ctms-danger); }
|
||||||
.stat-card--operators .stat-icon { background: #eef2f6; color: var(--ctms-info); }
|
.stat-card--operators .stat-icon { background: #eef2f6; color: var(--ctms-info); }
|
||||||
|
|
||||||
.stat-body { display: flex; flex-direction: column; }
|
.stat-body { display: flex; flex-direction: column; }
|
||||||
.stat-value { font-size: 20px; font-weight: 700; line-height: 1.2; color: var(--ctms-text-main); }
|
.stat-value { font-size: 18px; font-weight: 700; line-height: 1.2; color: var(--ctms-text-main); }
|
||||||
.stat-label { font-size: 11px; color: var(--ctms-text-secondary); margin-top: 2px; }
|
.stat-label { font-size: 10px; color: var(--ctms-text-secondary); margin-top: 2px; }
|
||||||
|
|
||||||
/* 筛选栏 */
|
/* 筛选栏 */
|
||||||
.audit-toolbar {
|
.audit-toolbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 8;
|
||||||
border-bottom: 1px solid var(--unified-shell-divider);
|
border-bottom: 1px solid var(--unified-shell-divider);
|
||||||
|
background: color-mix(in srgb, var(--ctms-bg-card) 95%, transparent);
|
||||||
|
box-shadow: 0 5px 14px rgba(15, 23, 42, 0.05);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
padding-top: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-form {
|
.filter-form {
|
||||||
@@ -799,31 +770,65 @@ onMounted(async () => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-filter-group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select-project :deep(.el-select__wrapper) {
|
||||||
|
border-radius: 8px 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-export-button {
|
||||||
|
width: 32px;
|
||||||
|
min-width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: -1px;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
color: var(--ctms-primary);
|
||||||
|
border-color: color-mix(in srgb, var(--ctms-primary) 24%, var(--ctms-border-color));
|
||||||
|
background: color-mix(in srgb, var(--ctms-primary) 7%, var(--ctms-bg-card));
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-heading {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
color: var(--ctms-text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form :deep(.el-input__inner),
|
||||||
|
.filter-form :deep(.el-select__placeholder),
|
||||||
|
.filter-form :deep(.el-select__selected-item),
|
||||||
|
.filter-form :deep(.el-range-input),
|
||||||
|
.filter-form :deep(.el-button) {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form :deep(.el-select__wrapper),
|
||||||
|
.filter-form :deep(.el-input__wrapper),
|
||||||
|
.filter-form :deep(.el-date-editor) {
|
||||||
|
min-height: 32px;
|
||||||
|
box-shadow: 0 0 0 1px var(--ctms-border-color) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-filter-button {
|
||||||
|
color: var(--ctms-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.filter-select-comp { width: 132px; }
|
.filter-select-comp { width: 132px; }
|
||||||
.filter-select-project { width: 180px; }
|
.filter-select-project { width: 180px; }
|
||||||
.filter-select-source { width: 112px; }
|
|
||||||
.filter-input-ip { width: 170px; }
|
|
||||||
.filter-select-result { width: 96px; }
|
.filter-select-result { width: 96px; }
|
||||||
.date-range-picker-comp { width: 248px !important; }
|
.date-range-picker-comp { width: 248px !important; }
|
||||||
.filter-spacer { flex: 1; min-width: 16px; }
|
|
||||||
|
|
||||||
.audit-export-dropdown { flex-shrink: 0; }
|
|
||||||
.audit-export-btn {
|
|
||||||
border-radius: 8px !important;
|
|
||||||
font-weight: 600;
|
|
||||||
height: 32px;
|
|
||||||
color: var(--ctms-primary) !important;
|
|
||||||
border-color: #c0cdd7 !important;
|
|
||||||
background: #f0f6ff !important;
|
|
||||||
}
|
|
||||||
.audit-export-btn:hover {
|
|
||||||
color: var(--ctms-primary-hover) !important;
|
|
||||||
border-color: #9bb1c2 !important;
|
|
||||||
background: #e2edfa !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 表格 */
|
/* 表格 */
|
||||||
.table-section { padding: 0; }
|
.table-section { padding: 0 !important; }
|
||||||
.audit-table :deep(.el-table__inner-wrapper::before) { display: none; }
|
.audit-table :deep(.el-table__inner-wrapper::before) { display: none; }
|
||||||
.audit-table :deep(.el-table__cell) {
|
.audit-table :deep(.el-table__cell) {
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
@@ -862,38 +867,6 @@ onMounted(async () => {
|
|||||||
color: var(--ctms-text-secondary);
|
color: var(--ctms-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.source-cell {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.source-main {
|
|
||||||
display: inline-flex;
|
|
||||||
align-self: flex-start;
|
|
||||||
max-width: 100%;
|
|
||||||
padding: 1px 7px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #155e75;
|
|
||||||
background: #ecfeff;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.source-meta,
|
|
||||||
.source-ip {
|
|
||||||
display: block;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--ctms-text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-tag {
|
.event-tag {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
@@ -942,6 +915,12 @@ onMounted(async () => {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-table :deep(.result-column .cell) {
|
||||||
|
padding-right: 4px;
|
||||||
|
padding-left: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result--success { background: #e6f4ed; color: #166534; }
|
.result--success { background: #e6f4ed; color: #166534; }
|
||||||
@@ -1058,35 +1037,6 @@ onMounted(async () => {
|
|||||||
color: var(--ctms-primary);
|
color: var(--ctms-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-lines {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-lines div {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 92px minmax(0, 1fr);
|
|
||||||
gap: 10px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-lines span {
|
|
||||||
color: var(--ctms-text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-lines code {
|
|
||||||
display: block;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid var(--ctms-border-color);
|
|
||||||
background: var(--ctms-neutral-100);
|
|
||||||
color: var(--ctms-text-main);
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-count {
|
.diff-count {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -1221,6 +1171,9 @@ onMounted(async () => {
|
|||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.stats-row { grid-template-columns: repeat(2, 1fr); }
|
.stats-row { grid-template-columns: repeat(2, 1fr); }
|
||||||
.filter-form { flex-wrap: wrap; }
|
.filter-form { flex-wrap: wrap; }
|
||||||
|
.stat-card:nth-child(3) { border-left: 0; }
|
||||||
|
.stat-card:nth-child(n + 3) { border-top: 1px solid var(--unified-shell-divider); }
|
||||||
|
.filter-heading { width: 100%; }
|
||||||
.diff-item-row { grid-template-columns: 1fr; gap: 4px; }
|
.diff-item-row { grid-template-columns: 1fr; gap: 4px; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -13,15 +13,15 @@ describe("permission management custom roles", () => {
|
|||||||
const listTabEnd = source.indexOf("<!-- 标签页:生效管理 -->", listTabStart);
|
const listTabEnd = source.indexOf("<!-- 标签页:生效管理 -->", listTabStart);
|
||||||
const listTabSource = source.slice(listTabStart, listTabEnd);
|
const listTabSource = source.slice(listTabStart, listTabEnd);
|
||||||
|
|
||||||
expect(source).toContain('title="角色管理"');
|
expect(source).toContain('title="角色与权限"');
|
||||||
expect(source).toContain('label="角色列表"');
|
expect(source).toContain('label="角色定义"');
|
||||||
expect(listTabSource).not.toContain('placeholder="角色类型"');
|
expect(listTabSource).not.toContain('placeholder="角色类型"');
|
||||||
expect(listTabSource).not.toContain("typeFilter");
|
expect(listTabSource).not.toContain("typeFilter");
|
||||||
expect(listTabSource).not.toContain("loadTemplates");
|
expect(listTabSource).not.toContain("loadTemplates");
|
||||||
expect(listTabSource).not.toContain('<el-option label="预设角色" value="ROLE" />');
|
expect(listTabSource).not.toContain('<el-option label="预设角色" value="ROLE" />');
|
||||||
expect(listTabSource).not.toContain('<el-option label="自定义角色" value="CUSTOM" />');
|
expect(listTabSource).not.toContain('<el-option label="自定义角色" value="CUSTOM" />');
|
||||||
expect(listTabSource).not.toContain('<el-option label="场景角色" value="SCENARIO" />');
|
expect(listTabSource).not.toContain('<el-option label="场景角色" value="SCENARIO" />');
|
||||||
expect(source).toContain("新增角色");
|
expect(source).toContain("创建角色");
|
||||||
expect(source).toContain('label="角色名称"');
|
expect(source).toContain('label="角色名称"');
|
||||||
expect(source).toContain("roleDisplayName(row)");
|
expect(source).toContain("roleDisplayName(row)");
|
||||||
expect(source).not.toContain('title="权限模板管理"');
|
expect(source).not.toContain('title="权限模板管理"');
|
||||||
@@ -31,6 +31,17 @@ describe("permission management custom roles", () => {
|
|||||||
expect(source).toContain("const res = await fetchPermissionTemplates();");
|
expect(source).toContain("const res = await fetchPermissionTemplates();");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("supports efficient role permission editing with change summaries and module actions", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("roleEditorChangeCount");
|
||||||
|
expect(source).toContain("新增授权 {{ roleEditorGrantedCount }} 项");
|
||||||
|
expect(source).toContain("取消授权 {{ roleEditorRevokedCount }} 项");
|
||||||
|
expect(source).toContain("setRoleEditorPermissions(roleEditorModuleOps(sections), true)");
|
||||||
|
expect(source).toContain("setRoleEditorPermissions(roleEditorModuleOps(sections), false)");
|
||||||
|
expect(source).toContain(':disabled="!canEditSelectedRole || !roleEditorDirtyGuard.isDirty.value"');
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps active roles configurable for permissions and members without inline role creation", () => {
|
it("keeps active roles configurable for permissions and members without inline role creation", () => {
|
||||||
const source = readSource();
|
const source = readSource();
|
||||||
|
|
||||||
@@ -133,7 +144,9 @@ describe("permission management custom roles", () => {
|
|||||||
expect(source).toContain("{{ role.label }}");
|
expect(source).toContain("{{ role.label }}");
|
||||||
expect(source).toContain("{{ role.desc }}");
|
expect(source).toContain("{{ role.desc }}");
|
||||||
expect(source).toContain("保存 {{ roleLabel(editingRole) }} 权限");
|
expect(source).toContain("保存 {{ roleLabel(editingRole) }} 权限");
|
||||||
expect(source).toContain("await updateMember(selectedStudyId.value, memberId, { role_in_study: role });");
|
expect(source).toContain("await updateMember(selectedStudyId.value, member.id, { role_in_study: role });");
|
||||||
|
expect(source).toContain("const confirmMemberRoleChange = async");
|
||||||
|
expect(source).toContain('"调整项目角色"');
|
||||||
expect(source).toContain("await addMember(selectedStudyId.value, addMemberForm);");
|
expect(source).toContain("await addMember(selectedStudyId.value, addMemberForm);");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -142,6 +155,8 @@ describe("permission management custom roles", () => {
|
|||||||
|
|
||||||
expect(source).toContain('selectedProjectPermissionAllowed("project_members:read")');
|
expect(source).toContain('selectedProjectPermissionAllowed("project_members:read")');
|
||||||
expect(source).toContain('selectedProjectPermissionAllowed("project_members:read")');
|
expect(source).toContain('selectedProjectPermissionAllowed("project_members:read")');
|
||||||
|
expect(source).toContain("!u.is_admin && !ids.has(u.id)");
|
||||||
|
expect(source).toContain("请选择可添加的项目成员");
|
||||||
expect(source).toContain('selectedProjectPermissionAllowed("project_members:create")');
|
expect(source).toContain('selectedProjectPermissionAllowed("project_members:create")');
|
||||||
expect(source).toContain('selectedProjectPermissionAllowed("project_members:update")');
|
expect(source).toContain('selectedProjectPermissionAllowed("project_members:update")');
|
||||||
expect(source).toContain('selectedProjectPermissionAllowed("project_members:delete")');
|
expect(source).toContain('selectedProjectPermissionAllowed("project_members:delete")');
|
||||||
@@ -364,6 +379,15 @@ describe("permission management custom roles", () => {
|
|||||||
const source = readSource();
|
const source = readSource();
|
||||||
|
|
||||||
expect(source).toContain("systemPermissionsByModule");
|
expect(source).toContain("systemPermissionsByModule");
|
||||||
|
expect(source).toContain('class="system-stats" aria-label="系统级权限概览"');
|
||||||
|
expect(source).toContain('class="system-permissions-card"');
|
||||||
|
expect(source).toContain('class="perm-body perm-body--system"');
|
||||||
|
expect(source).toMatch(/\.system-perm-container \{[\s\S]*?padding: 0;[\s\S]*?gap: 8px;/);
|
||||||
|
expect(source).toMatch(/\.system-perm-row \{[\s\S]*?min-height: 36px;[\s\S]*?padding: 4px 12px;/);
|
||||||
|
expect(source).toContain(".system-module-block + .system-module-block");
|
||||||
|
expect(source).toContain(".system-perm-row:last-child");
|
||||||
|
expect(source).not.toContain("system-perm-row--alt");
|
||||||
|
expect(source).not.toContain("system-stat-divider");
|
||||||
expect(source).not.toContain("<h2 class=\"perm-title\">系统级权限</h2>");
|
expect(source).not.toContain("<h2 class=\"perm-title\">系统级权限</h2>");
|
||||||
expect(source).not.toContain("只读展示,所有系统管理操作仅限 ADMIN 角色执行");
|
expect(source).not.toContain("只读展示,所有系统管理操作仅限 ADMIN 角色执行");
|
||||||
});
|
});
|
||||||
@@ -371,10 +395,21 @@ describe("permission management custom roles", () => {
|
|||||||
it("removes the vertical gap between permission headers and body content", () => {
|
it("removes the vertical gap between permission headers and body content", () => {
|
||||||
const source = readSource();
|
const source = readSource();
|
||||||
|
|
||||||
expect(source).toContain("padding: 0 0 20px;");
|
expect(source).toContain("padding: 0 0 8px;");
|
||||||
expect(source).not.toContain("padding: 20px 0 20px;");
|
expect(source).not.toContain("padding: 20px 0 20px;");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses compact, legible typography for the selected project", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain(".project-selector :deep(.el-select__selected-item)");
|
||||||
|
expect(source).toContain('popper-class="project-selector-dropdown"');
|
||||||
|
expect(source).toContain(".project-selector-dropdown .el-select-dropdown__item");
|
||||||
|
expect(source).toContain("color: #193b5a !important;");
|
||||||
|
expect(source).toContain("font-size: 13px;");
|
||||||
|
expect(source).toContain("font-variant-numeric: tabular-nums;");
|
||||||
|
});
|
||||||
|
|
||||||
it("limits project PM member management to subordinate project roles", () => {
|
it("limits project PM member management to subordinate project roles", () => {
|
||||||
const source = readSource();
|
const source = readSource();
|
||||||
|
|
||||||
@@ -386,4 +421,17 @@ describe("permission management custom roles", () => {
|
|||||||
expect(source).toContain('addMemberForm.role_in_study = Object.keys(assignableRoleLabels.value)[0] || "";');
|
expect(source).toContain('addMemberForm.role_in_study = Object.keys(assignableRoleLabels.value)[0] || "";');
|
||||||
expect(source).not.toContain('addMemberForm.role_in_study = activeRolesInStudy.value.includes("PM") ? "PM"');
|
expect(source).not.toContain('addMemberForm.role_in_study = activeRolesInStudy.value.includes("PM") ? "PM"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("separates role configuration from matrix comparison and supports member filtering", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain('v-model="permissionView"');
|
||||||
|
expect(source).toContain('value="roles">角色配置');
|
||||||
|
expect(source).toContain('value="matrix">权限对比');
|
||||||
|
expect(source).toContain('v-if="permissionView === \'roles\'"');
|
||||||
|
expect(source).toContain("const filteredMemberRows = computed");
|
||||||
|
expect(source).toContain("const memberRoleSummary = computed");
|
||||||
|
expect(source).toContain('placeholder="搜索姓名或邮箱"');
|
||||||
|
expect(source).toContain(">账号已停用</el-tag>");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,29 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog
|
<el-dialog
|
||||||
append-to=".layout-main .content-wrapper"
|
append-to="body"
|
||||||
:title="project ? TEXT.modules.adminProjects.editTitle : TEXT.modules.adminProjects.newTitle"
|
:title="project ? TEXT.modules.adminProjects.editTitle : TEXT.modules.adminProjects.newTitle"
|
||||||
width="560px"
|
width="560px"
|
||||||
v-model="visibleProxy"
|
v-model="visibleProxy"
|
||||||
:close-on-click-modal="false"
|
:close-on-click-modal="false"
|
||||||
class="project-form-dialog"
|
class="project-form-dialog"
|
||||||
>
|
>
|
||||||
<div class="form-header">
|
|
||||||
<div class="form-icon" :class="project ? 'icon--edit' : 'icon--new'">
|
|
||||||
<svg v-if="!project" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="22" height="22">
|
|
||||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>
|
|
||||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
|
|
||||||
</svg>
|
|
||||||
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="22" height="22">
|
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="form-header-text">
|
|
||||||
<span class="form-header-title">{{ project ? '编辑项目信息' : '创建新项目' }}</span>
|
|
||||||
<span class="form-header-desc">{{ project ? form.name || '修改项目基本信息' : '填写以下信息创建临床试验项目' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" class="project-form-body">
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" class="project-form-body">
|
||||||
<el-form-item :label="TEXT.common.fields.projectName" prop="name">
|
<el-form-item :label="TEXT.common.fields.projectName" prop="name">
|
||||||
<el-input v-model="form.name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectName">
|
<el-input v-model="form.name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectName">
|
||||||
@@ -198,52 +181,6 @@ const onSubmit = async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.form-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
padding-bottom: 18px;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
border-bottom: 1px solid var(--ctms-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-icon {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 14px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-icon.icon--new {
|
|
||||||
background: linear-gradient(135deg, #e8edf3, #d5dce5);
|
|
||||||
color: var(--ctms-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-icon.icon--edit {
|
|
||||||
background: linear-gradient(135deg, var(--ctms-primary), var(--ctms-primary-active));
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-header-text {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-header-title {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--ctms-text-main);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-header-desc {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ctms-text-secondary);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-form-body :deep(.el-form-item) {
|
.project-form-body :deep(.el-form-item) {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ describe("project management access", () => {
|
|||||||
expect(layout).toContain('index="/admin/projects"');
|
expect(layout).toContain('index="/admin/projects"');
|
||||||
expect(router).toContain('name: "AdminProjects"');
|
expect(router).toContain('name: "AdminProjects"');
|
||||||
expect(router).toContain("meta: { title: TEXT.menu.projectManagement }");
|
expect(router).toContain("meta: { title: TEXT.menu.projectManagement }");
|
||||||
expect(projects).toContain('v-if="isAdmin" type="primary"');
|
expect(projects).toContain('v-if="isAdmin" content="新增项目"');
|
||||||
expect(projects).toContain('v-if="isAdmin && !scope.row.is_locked"');
|
expect(projects).toContain('v-if="isAdmin && !scope.row.is_locked"');
|
||||||
expect(projects).toContain('v-if="isAdmin" :content="TEXT.common.actions.delete"');
|
expect(projects).toContain('v-if="isAdmin" :content="TEXT.common.actions.delete"');
|
||||||
});
|
});
|
||||||
@@ -91,12 +91,26 @@ describe("project management access", () => {
|
|||||||
it("keeps the project summary header compact", () => {
|
it("keeps the project summary header compact", () => {
|
||||||
const source = readProjects();
|
const source = readProjects();
|
||||||
|
|
||||||
expect(source).toContain("padding: 10px 16px;");
|
expect(source).toContain('class="project-overview"');
|
||||||
expect(source).toContain("padding: 9px 12px;");
|
expect(source).toContain('class="create-project-button"');
|
||||||
expect(source).toContain("width: 32px;");
|
expect(source).toContain("min-height: 48px;");
|
||||||
expect(source).toContain("height: 32px;");
|
expect(source).toContain("width: 30px;");
|
||||||
expect(source).toContain("font-size: 20px;");
|
expect(source).toContain("height: 30px;");
|
||||||
expect(source).toContain("font-size: 11px;");
|
expect(source).toContain("font-size: 18px;");
|
||||||
|
expect(source).toContain("font-size: 10px;");
|
||||||
|
expect(source).toContain("content-wrapper:has(.projects-page)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts the project form in a container shared by web and desktop layouts", () => {
|
||||||
|
const form = readFileSync(resolve(__dirname, "./ProjectForm.vue"), "utf8");
|
||||||
|
const desktopStyles = readFileSync(resolve(__dirname, "../../styles/main.css"), "utf8");
|
||||||
|
|
||||||
|
expect(form).toContain('append-to="body"');
|
||||||
|
expect(form).not.toContain('append-to=".layout-main .content-wrapper"');
|
||||||
|
expect(form).not.toContain('class="form-header"');
|
||||||
|
expect(form).not.toContain("填写以下信息创建临床试验项目");
|
||||||
|
expect(desktopStyles).toContain("body.is-desktop-runtime .project-form-dialog");
|
||||||
|
expect(desktopStyles).toContain("body.is-desktop-runtime .project-form-dialog .el-dialog__body");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders project names as plain text and moves setup configuration to a gear action", () => {
|
it("renders project names as plain text and moves setup configuration to a gear action", () => {
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page page--flush">
|
<div class="page page--flush projects-page">
|
||||||
<div class="main-content-flat unified-shell">
|
<div class="main-content-flat unified-shell">
|
||||||
<!-- 统计卡片 -->
|
<!-- 项目概览 -->
|
||||||
<div class="stats-row">
|
<section class="project-overview" aria-labelledby="project-overview-title">
|
||||||
|
<div class="overview-heading">
|
||||||
|
<div class="overview-title-wrap">
|
||||||
|
<h2 id="project-overview-title">项目概览</h2>
|
||||||
|
</div>
|
||||||
|
<el-tooltip v-if="isAdmin" content="新增项目" placement="left">
|
||||||
|
<el-button type="primary" :icon="Plus" circle class="create-project-button" aria-label="新增项目" @click="openCreate" />
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="stats-row">
|
||||||
<div class="stat-card stat-card--total">
|
<div class="stat-card stat-card--total">
|
||||||
<div class="stat-icon">
|
<div class="stat-icon">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
@@ -51,15 +60,8 @@
|
|||||||
<span class="stat-label">已锁定</span>
|
<span class="stat-label">已锁定</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
<!-- 操作栏 -->
|
|
||||||
<div class="unified-action-bar actions-only-bar">
|
|
||||||
<div class="filter-spacer"></div>
|
|
||||||
<el-button v-if="isAdmin" type="primary" :icon="Plus" @click="openCreate">
|
|
||||||
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjects.projectLabel }}
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 项目表格 -->
|
<!-- 项目表格 -->
|
||||||
<div class="unified-section project-table-section">
|
<div class="unified-section project-table-section">
|
||||||
@@ -372,31 +374,70 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
:global(.web-layout-container .content-wrapper:has(.projects-page)) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-overview {
|
||||||
|
padding: 0;
|
||||||
|
border-bottom: 1px solid var(--unified-shell-divider);
|
||||||
|
background: linear-gradient(180deg, var(--ctms-bg-card) 0%, color-mix(in srgb, var(--ctms-neutral-100) 55%, var(--ctms-bg-card)) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-title-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-title-wrap h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ctms-text-main);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-project-button {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.stats-row {
|
.stats-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 10px;
|
gap: 0;
|
||||||
padding: 10px 16px;
|
border-top: 1px solid var(--unified-shell-divider);
|
||||||
|
background: var(--ctms-bg-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 9px 12px;
|
min-height: 48px;
|
||||||
border-radius: 10px;
|
padding: 6px 14px;
|
||||||
background: var(--ctms-neutral-100);
|
background: transparent;
|
||||||
transition: var(--ctms-transition);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card:hover {
|
.stat-card + .stat-card {
|
||||||
transform: translateY(-1px);
|
border-left: 1px solid var(--unified-shell-divider);
|
||||||
box-shadow: var(--ctms-shadow-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-icon {
|
.stat-icon {
|
||||||
width: 32px;
|
width: 30px;
|
||||||
height: 32px;
|
height: 30px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -405,8 +446,8 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-icon svg {
|
.stat-icon svg {
|
||||||
width: 18px;
|
width: 17px;
|
||||||
height: 18px;
|
height: 17px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card--total .stat-icon { background: #e8edf3; color: var(--ctms-primary); }
|
.stat-card--total .stat-icon { background: #e8edf3; color: var(--ctms-primary); }
|
||||||
@@ -420,28 +461,18 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
font-size: 20px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
color: var(--ctms-text-main);
|
color: var(--ctms-text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
color: var(--ctms-text-secondary);
|
color: var(--ctms-text-secondary);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions-only-bar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-spacer {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 项目单元格 */
|
/* 项目单元格 */
|
||||||
.project-cell {
|
.project-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -545,14 +576,18 @@ onMounted(() => {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
min-width: 252px;
|
min-width: 252px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
padding: 1px 3px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(248, 250, 252, 0.72);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.14);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
width: 28px;
|
width: 26px;
|
||||||
height: 28px;
|
height: 26px;
|
||||||
min-height: 28px;
|
min-height: 26px;
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -575,7 +610,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
/* 表格 */
|
/* 表格 */
|
||||||
.project-table-section {
|
.project-table-section {
|
||||||
padding: 0;
|
padding: 0 !important;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,9 +622,28 @@ onMounted(() => {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-table :deep(th.el-table__cell) {
|
||||||
|
padding-top: 7px;
|
||||||
|
padding-bottom: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-table :deep(td.el-table__cell) {
|
||||||
|
padding-top: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.stats-row {
|
.stats-row {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-card:nth-child(3) {
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:nth-child(n + 3) {
|
||||||
|
border-top: 1px solid var(--unified-shell-divider);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -12,8 +12,14 @@ describe("SystemMonitoringPage desktop shell", () => {
|
|||||||
expect(source).toContain('import { isTauriRuntime } from "@/runtime";');
|
expect(source).toContain('import { isTauriRuntime } from "@/runtime";');
|
||||||
expect(source).toContain("const isDesktop = isTauriRuntime();");
|
expect(source).toContain("const isDesktop = isTauriRuntime();");
|
||||||
expect(source).toContain("system-monitoring-page--desktop");
|
expect(source).toContain("system-monitoring-page--desktop");
|
||||||
|
expect(source).toContain("height: calc(100dvh - 52px);");
|
||||||
expect(source).toContain("height: 100%;");
|
expect(source).toContain("height: 100%;");
|
||||||
|
expect(source).toContain("content-wrapper:has(.system-monitoring-page)");
|
||||||
|
expect(source).toContain("height: calc(100dvh - 52px);");
|
||||||
|
expect(source).toContain("overflow: hidden;");
|
||||||
expect(source).toContain(".overview-hero-card");
|
expect(source).toContain(".overview-hero-card");
|
||||||
|
expect(source).toContain(":deep(.overview-hero-card > .metric-strip)");
|
||||||
|
expect(source).not.toContain(".system-monitoring-page--desktop :deep(.metric-strip)");
|
||||||
expect(source).toContain(".access-logs .audit-filters");
|
expect(source).toContain(".access-logs .audit-filters");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,14 +15,22 @@ const isDesktop = isTauriRuntime();
|
|||||||
.system-monitoring-page {
|
.system-monitoring-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: calc(100vh - 48px);
|
height: calc(100vh - 52px);
|
||||||
height: calc(100dvh - 48px);
|
height: calc(100dvh - 52px);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
margin: -6px -8px;
|
margin: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #f5f7fa;
|
background: #f5f7fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.web-layout-container .content-wrapper:has(.system-monitoring-page)) {
|
||||||
|
height: calc(100vh - 52px);
|
||||||
|
height: calc(100dvh - 52px);
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* DesktopLayout already reserves space for its title toolbar and workspace
|
* DesktopLayout already reserves space for its title toolbar and workspace
|
||||||
* tabs. Keep the shared monitoring view inside that remaining area instead
|
* tabs. Keep the shared monitoring view inside that remaining area instead
|
||||||
@@ -52,7 +60,7 @@ const isDesktop = isTauriRuntime();
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.system-monitoring-page--desktop :deep(.metric-strip) {
|
.system-monitoring-page--desktop :deep(.overview-hero-card > .metric-strip) {
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
order: 3;
|
order: 3;
|
||||||
padding-top: 6px;
|
padding-top: 6px;
|
||||||
|
|||||||
@@ -7,22 +7,6 @@
|
|||||||
:close-on-click-modal="false"
|
:close-on-click-modal="false"
|
||||||
class="user-form-dialog"
|
class="user-form-dialog"
|
||||||
>
|
>
|
||||||
<div class="form-header">
|
|
||||||
<div class="form-avatar" :class="user ? 'avatar--edit' : 'avatar--new'">
|
|
||||||
<svg v-if="!user" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="24" height="24">
|
|
||||||
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
|
||||||
<circle cx="8.5" cy="7" r="4"/>
|
|
||||||
<line x1="20" y1="8" x2="20" y2="14"/>
|
|
||||||
<line x1="23" y1="11" x2="17" y2="11"/>
|
|
||||||
</svg>
|
|
||||||
<span v-else class="avatar-initials">{{ getInitials(form.full_name) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="form-header-text">
|
|
||||||
<span class="form-header-title">{{ user ? form.full_name || '编辑用户' : '创建新用户' }}</span>
|
|
||||||
<span class="form-header-desc">{{ user ? form.email : '填写以下信息创建系统账号' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" autocomplete="off" class="user-form-body">
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" autocomplete="off" class="user-form-body">
|
||||||
<el-form-item :label="TEXT.common.fields.email" prop="email">
|
<el-form-item :label="TEXT.common.fields.email" prop="email">
|
||||||
<el-input v-model="form.email" :disabled="!!user" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.email">
|
<el-input v-model="form.email" :disabled="!!user" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.email">
|
||||||
@@ -129,13 +113,6 @@ const form = reactive({
|
|||||||
is_active: true,
|
is_active: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const getInitials = (name: string) => {
|
|
||||||
if (!name) return '?';
|
|
||||||
const parts = name.trim().split(/\s+/);
|
|
||||||
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
|
|
||||||
return name.slice(0, 2).toUpperCase();
|
|
||||||
};
|
|
||||||
|
|
||||||
const rules = reactive<FormRules>({
|
const rules = reactive<FormRules>({
|
||||||
email: [
|
email: [
|
||||||
{ required: true, message: requiredMessage(TEXT.common.fields.email), trigger: "blur" },
|
{ required: true, message: requiredMessage(TEXT.common.fields.email), trigger: "blur" },
|
||||||
@@ -232,57 +209,6 @@ const onSubmit = async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.form-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
padding-bottom: 18px;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
border-bottom: 1px solid var(--ctms-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-avatar {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 14px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-avatar.avatar--new {
|
|
||||||
background: linear-gradient(135deg, #e8edf3, #d5dce5);
|
|
||||||
color: var(--ctms-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-avatar.avatar--edit {
|
|
||||||
background: linear-gradient(135deg, var(--ctms-primary), var(--ctms-primary-active));
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-initials {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-header-text {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-header-title {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--ctms-text-main);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-header-desc {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ctms-text-secondary);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-form-body :deep(.el-form-item) {
|
.user-form-body :deep(.el-form-item) {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +1,77 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page page--flush">
|
<div class="page page--flush users-page">
|
||||||
<div class="main-content-flat unified-shell">
|
<div class="main-content-flat unified-shell">
|
||||||
<!-- 统计卡片 -->
|
<!-- 独立状态概览 -->
|
||||||
<div class="stats-row">
|
<section class="account-overview" aria-labelledby="account-overview-title">
|
||||||
<div class="stat-card stat-card--total">
|
<div class="overview-heading">
|
||||||
<div class="stat-icon">
|
<div>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
<h2 id="account-overview-title">账号概览</h2>
|
||||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
|
||||||
<circle cx="9" cy="7" r="4"/>
|
|
||||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
|
||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-body">
|
<span class="overview-live"><i></i>状态汇总</span>
|
||||||
<span class="stat-value">{{ total }}</span>
|
</div>
|
||||||
<span class="stat-label">总用户</span>
|
<div class="stats-row">
|
||||||
|
<div class="stat-card stat-card--total">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||||
|
<circle cx="9" cy="7" r="4"/>
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-body">
|
||||||
|
<span class="stat-value">{{ allUsers.length }}</span>
|
||||||
|
<span class="stat-label">总用户</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card--active">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<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>
|
||||||
|
<div class="stat-body">
|
||||||
|
<span class="stat-value">{{ activeCount }}</span>
|
||||||
|
<span class="stat-label">{{ TEXT.enums.userStatus.ACTIVE }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card--disabled">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-body">
|
||||||
|
<span class="stat-value">{{ disabledCount }}</span>
|
||||||
|
<span class="stat-label">{{ TEXT.enums.userStatus.DISABLED }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card--online">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<path d="M5 12a7 7 0 0 1 14 0"/>
|
||||||
|
<path d="M8 12a4 4 0 0 1 8 0"/>
|
||||||
|
<circle cx="12" cy="16" r="1"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-body">
|
||||||
|
<span class="stat-value">{{ onlineCount }}</span>
|
||||||
|
<span class="stat-label">当前在线</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card stat-card--active">
|
</section>
|
||||||
<div class="stat-icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
|
||||||
<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>
|
|
||||||
<div class="stat-body">
|
|
||||||
<span class="stat-value">{{ activeCount }}</span>
|
|
||||||
<span class="stat-label">{{ TEXT.enums.userStatus.ACTIVE }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card stat-card--disabled">
|
|
||||||
<div class="stat-icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
|
||||||
<circle cx="12" cy="12" r="10"/>
|
|
||||||
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="stat-body">
|
|
||||||
<span class="stat-value">{{ disabledCount }}</span>
|
|
||||||
<span class="stat-label">{{ TEXT.enums.userStatus.DISABLED }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card stat-card--online">
|
|
||||||
<div class="stat-icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
|
||||||
<path d="M5 12a7 7 0 0 1 14 0"/>
|
|
||||||
<path d="M8 12a4 4 0 0 1 8 0"/>
|
|
||||||
<circle cx="12" cy="16" r="1"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="stat-body">
|
|
||||||
<span class="stat-value">{{ onlineCount }}</span>
|
|
||||||
<span class="stat-label">当前在线</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 筛选栏 -->
|
<!-- 筛选栏 -->
|
||||||
<div class="filter-container unified-action-bar">
|
<div class="filter-container unified-action-bar">
|
||||||
<div class="filter-form">
|
<div class="filter-form">
|
||||||
<div class="filter-item-form">
|
<div class="filter-heading">
|
||||||
|
<el-icon><Filter /></el-icon>
|
||||||
|
<span>筛选</span>
|
||||||
|
</div>
|
||||||
|
<div class="filter-item-form filter-item-form--search">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="searchKeyword"
|
v-model="searchKeyword"
|
||||||
:placeholder="TEXT.common.placeholders.keyword"
|
:placeholder="TEXT.common.placeholders.keyword"
|
||||||
@@ -70,11 +82,19 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-item-form">
|
<div class="filter-item-form">
|
||||||
<el-select v-model="statusFilter" :placeholder="TEXT.common.fields.status" style="width: 140px" clearable class="filter-select-comp" @change="applyFilters">
|
<el-select v-model="statusFilter" placeholder="账号状态" clearable class="filter-select-comp" @change="applyFilters">
|
||||||
<el-option :label="TEXT.enums.userStatus.ACTIVE" value="ACTIVE" />
|
<el-option :label="TEXT.enums.userStatus.ACTIVE" value="ACTIVE" />
|
||||||
<el-option :label="TEXT.enums.userStatus.DISABLED" value="DISABLED" />
|
<el-option :label="TEXT.enums.userStatus.DISABLED" value="DISABLED" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="filter-item-form">
|
||||||
|
<el-select v-model="loginStatusFilter" placeholder="登录状态" clearable class="filter-select-comp" @change="applyFilters">
|
||||||
|
<el-option label="在线" value="ONLINE" />
|
||||||
|
<el-option label="离线" value="OFFLINE" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<el-button v-if="hasActiveFilters" text :icon="RefreshLeft" class="reset-filter-button" @click="resetFilters">重置筛选</el-button>
|
||||||
|
<span class="filter-result" aria-live="polite">{{ hasActiveFilters ? `筛选结果 ${total} 人` : `共 ${total} 人` }}</span>
|
||||||
<div class="filter-spacer"></div>
|
<div class="filter-spacer"></div>
|
||||||
<el-button type="primary" :icon="Plus" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }}</el-button>
|
<el-button type="primary" :icon="Plus" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,11 +113,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="clinical_department" :label="TEXT.modules.adminUsers.clinicalDepartmentLabel" min-width="120" show-overflow-tooltip />
|
<el-table-column prop="clinical_department" :label="TEXT.modules.adminUsers.clinicalDepartmentLabel" min-width="120" class-name="department-column" show-overflow-tooltip>
|
||||||
<el-table-column :label="TEXT.common.fields.status" min-width="100">
|
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<span class="status-dot" :class="'dot--' + (scope.row.status || '').toLowerCase()"></span>
|
<span class="department-text">{{ scope.row.clinical_department }}</span>
|
||||||
{{ statusLabel(scope.row.status) }}
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="TEXT.common.fields.status" min-width="100" class-name="account-status-column">
|
||||||
|
<template #default="scope">
|
||||||
|
<div class="account-status-cell">
|
||||||
|
<span class="status-dot" :class="'dot--' + (scope.row.status || '').toLowerCase()"></span>
|
||||||
|
<span>{{ statusLabel(scope.row.status) }}</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="登录状态" min-width="140">
|
<el-table-column label="登录状态" min-width="140">
|
||||||
@@ -117,7 +143,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.common.labels.actions" align="center" width="168">
|
<el-table-column :label="TEXT.common.labels.actions" align="center" width="176" class-name="actions-column">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<div class="action-row">
|
<div class="action-row">
|
||||||
<el-tooltip content="登录记录" placement="top">
|
<el-tooltip content="登录记录" placement="top">
|
||||||
@@ -176,7 +202,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { Edit, Delete, Key, Lock, Unlock, Search, Plus, Clock } from "@element-plus/icons-vue";
|
import { Edit, Delete, Key, Lock, Unlock, Search, Plus, Clock, RefreshLeft, Filter } from "@element-plus/icons-vue";
|
||||||
import { fetchUsers, updateUser, deleteUser } from "../../api/users";
|
import { fetchUsers, updateUser, deleteUser } from "../../api/users";
|
||||||
import { fetchStudies } from "../../api/studies";
|
import { fetchStudies } from "../../api/studies";
|
||||||
import { listMembers } from "../../api/members";
|
import { listMembers } from "../../api/members";
|
||||||
@@ -199,6 +225,7 @@ const formVisible = ref(false);
|
|||||||
const resetVisible = ref(false);
|
const resetVisible = ref(false);
|
||||||
const searchKeyword = ref("");
|
const searchKeyword = ref("");
|
||||||
const statusFilter = ref("");
|
const statusFilter = ref("");
|
||||||
|
const loginStatusFilter = ref("");
|
||||||
const editingUser = ref<UserInfo | null>(null);
|
const editingUser = ref<UserInfo | null>(null);
|
||||||
const resetUser = ref<UserInfo | null>(null);
|
const resetUser = ref<UserInfo | null>(null);
|
||||||
const loginActivitiesUser = ref<UserInfo | null>(null);
|
const loginActivitiesUser = ref<UserInfo | null>(null);
|
||||||
@@ -209,6 +236,7 @@ let keywordSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
|||||||
const activeCount = computed(() => allUsers.value.filter(u => u.status === 'ACTIVE').length);
|
const activeCount = computed(() => allUsers.value.filter(u => u.status === 'ACTIVE').length);
|
||||||
const disabledCount = computed(() => allUsers.value.filter(u => u.status === 'DISABLED').length);
|
const disabledCount = computed(() => allUsers.value.filter(u => u.status === 'DISABLED').length);
|
||||||
const onlineCount = computed(() => allUsers.value.filter(u => u.login_status === 'ONLINE').length);
|
const onlineCount = computed(() => allUsers.value.filter(u => u.login_status === 'ONLINE').length);
|
||||||
|
const hasActiveFilters = computed(() => Boolean(searchKeyword.value.trim() || statusFilter.value || loginStatusFilter.value));
|
||||||
|
|
||||||
const statusLabel = (status: string) => {
|
const statusLabel = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -236,6 +264,7 @@ const loadUsers = async () => {
|
|||||||
limit: pageSize.value,
|
limit: pageSize.value,
|
||||||
keyword: searchKeyword.value || undefined,
|
keyword: searchKeyword.value || undefined,
|
||||||
status: statusFilter.value || undefined,
|
status: statusFilter.value || undefined,
|
||||||
|
login_status: loginStatusFilter.value || undefined,
|
||||||
}),
|
}),
|
||||||
fetchUsers({ skip: 0, limit: 10000 }),
|
fetchUsers({ skip: 0, limit: 10000 }),
|
||||||
]);
|
]);
|
||||||
@@ -258,6 +287,13 @@ const applyFilters = () => {
|
|||||||
loadUsers();
|
loadUsers();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
searchKeyword.value = "";
|
||||||
|
statusFilter.value = "";
|
||||||
|
loginStatusFilter.value = "";
|
||||||
|
applyFilters();
|
||||||
|
};
|
||||||
|
|
||||||
const clearKeywordSearchTimer = () => {
|
const clearKeywordSearchTimer = () => {
|
||||||
if (!keywordSearchTimer) return;
|
if (!keywordSearchTimer) return;
|
||||||
clearTimeout(keywordSearchTimer);
|
clearTimeout(keywordSearchTimer);
|
||||||
@@ -275,7 +311,7 @@ const scheduleKeywordSearch = () => {
|
|||||||
|
|
||||||
watch(searchKeyword, () => {
|
watch(searchKeyword, () => {
|
||||||
scheduleKeywordSearch();
|
scheduleKeywordSearch();
|
||||||
});
|
}, { flush: "sync" });
|
||||||
|
|
||||||
const onPageSizeChange = (size: number) => {
|
const onPageSizeChange = (size: number) => {
|
||||||
pageSize.value = size;
|
pageSize.value = size;
|
||||||
@@ -406,32 +442,85 @@ onBeforeUnmount(() => {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.web-layout-container .content-wrapper:has(.users-page)) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-overview {
|
||||||
|
padding: 0;
|
||||||
|
border-bottom: 1px solid var(--unified-shell-divider);
|
||||||
|
background: linear-gradient(180deg, var(--ctms-bg-card) 0%, color-mix(in srgb, var(--ctms-neutral-100) 55%, var(--ctms-bg-card)) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 8px 16px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-heading h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ctms-text-main);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-live {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: 1px solid rgba(34, 166, 99, 0.18);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(34, 166, 99, 0.07);
|
||||||
|
color: #188454;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-live i {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #22a663;
|
||||||
|
box-shadow: 0 0 0 3px rgba(34, 166, 99, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
.stats-row {
|
.stats-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 10px;
|
gap: 0;
|
||||||
padding: 10px 16px;
|
overflow: hidden;
|
||||||
border-bottom: 1px solid var(--unified-shell-divider);
|
border-top: 1px solid var(--unified-shell-divider);
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-left: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--ctms-bg-card);
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 9px 12px;
|
min-height: 48px;
|
||||||
border-radius: 10px;
|
padding: 6px 14px;
|
||||||
background: var(--ctms-neutral-100);
|
background: transparent;
|
||||||
transition: var(--ctms-transition);
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card:hover {
|
.stat-card + .stat-card {
|
||||||
transform: translateY(-1px);
|
border-left: 1px solid var(--unified-shell-divider);
|
||||||
box-shadow: var(--ctms-shadow-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-icon {
|
.stat-icon {
|
||||||
width: 32px;
|
width: 30px;
|
||||||
height: 32px;
|
height: 30px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -440,8 +529,8 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-icon svg {
|
.stat-icon svg {
|
||||||
width: 18px;
|
width: 17px;
|
||||||
height: 18px;
|
height: 17px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card--total .stat-icon {
|
.stat-card--total .stat-icon {
|
||||||
@@ -470,19 +559,28 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
font-size: 20px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
color: var(--ctms-text-main);
|
color: var(--ctms-text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
color: var(--ctms-text-secondary);
|
color: var(--ctms-text-secondary);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 筛选栏 */
|
/* 筛选栏 */
|
||||||
|
.filter-container {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 8;
|
||||||
|
background: color-mix(in srgb, var(--ctms-bg-card) 94%, transparent);
|
||||||
|
box-shadow: 0 5px 14px rgba(15, 23, 42, 0.05);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
.filter-form {
|
.filter-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -492,17 +590,55 @@ onBeforeUnmount(() => {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-heading {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
color: var(--ctms-text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-item-form {
|
.filter-item-form {
|
||||||
margin-bottom: 0 !important;
|
margin-bottom: 0 !important;
|
||||||
margin-right: 0 !important;
|
margin-right: 0 !important;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-item-form--search {
|
||||||
|
flex: 0 1 320px;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-input-comp {
|
.filter-input-comp {
|
||||||
width: 220px;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-form :deep(.el-input__inner),
|
||||||
|
.filter-form :deep(.el-select__placeholder),
|
||||||
|
.filter-form :deep(.el-select__selected-item) {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form :deep(.el-button) {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select-comp {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-filter-button {
|
||||||
|
color: var(--ctms-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-result {
|
||||||
|
color: var(--ctms-text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-spacer {
|
.filter-spacer {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -522,7 +658,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.user-name {
|
.user-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
color: var(--ctms-text-main);
|
color: var(--ctms-text-main);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -530,13 +666,26 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.user-email {
|
.user-email {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
color: var(--ctms-text-secondary);
|
color: var(--ctms-text-secondary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.department-text,
|
||||||
|
.account-status-cell {
|
||||||
|
color: var(--ctms-text-main);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-status-cell {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* 状态点 */
|
/* 状态点 */
|
||||||
.status-dot {
|
.status-dot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -574,15 +723,15 @@ onBeforeUnmount(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
column-gap: 6px;
|
column-gap: 6px;
|
||||||
color: var(--ctms-text-main);
|
color: var(--ctms-text-main);
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-status-cell small,
|
.login-status-cell small,
|
||||||
.login-time-cell small {
|
.login-time-cell small {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
margin-top: 2px;
|
margin-top: 0;
|
||||||
color: var(--ctms-text-secondary);
|
color: var(--ctms-text-secondary);
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-time-cell {
|
.login-time-cell {
|
||||||
@@ -605,7 +754,7 @@ onBeforeUnmount(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 2px;
|
padding: 1px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(248, 250, 252, 0.72);
|
background: rgba(248, 250, 252, 0.72);
|
||||||
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.14);
|
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.14);
|
||||||
@@ -613,9 +762,9 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
width: 28px;
|
width: 26px;
|
||||||
height: 28px;
|
height: 26px;
|
||||||
min-height: 28px;
|
min-height: 26px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
@@ -629,6 +778,13 @@ onBeforeUnmount(() => {
|
|||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-table :deep(td.actions-column .cell) {
|
||||||
|
padding-right: 6px;
|
||||||
|
padding-left: 6px;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
}
|
||||||
|
|
||||||
.action-row :deep(.el-button .el-icon) {
|
.action-row :deep(.el-button .el-icon) {
|
||||||
width: 15px;
|
width: 15px;
|
||||||
height: 15px;
|
height: 15px;
|
||||||
@@ -648,7 +804,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
/* 表格区域 */
|
/* 表格区域 */
|
||||||
.user-table-section {
|
.user-table-section {
|
||||||
padding: 0;
|
padding: 0 !important;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
@@ -662,13 +818,17 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.user-table :deep(th.el-table__cell) {
|
.user-table :deep(th.el-table__cell) {
|
||||||
padding-top: 10px;
|
padding-top: 7px;
|
||||||
padding-bottom: 10px;
|
padding-bottom: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-table :deep(td.el-table__cell) {
|
.user-table :deep(td.el-table__cell) {
|
||||||
padding-top: 10px;
|
padding-top: 5px;
|
||||||
padding-bottom: 10px;
|
padding-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-table :deep(.cell) {
|
||||||
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-wrap {
|
.pagination-wrap {
|
||||||
@@ -692,8 +852,36 @@ onBeforeUnmount(() => {
|
|||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-card:nth-child(3) {
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:nth-child(n + 3) {
|
||||||
|
border-top: 1px solid var(--unified-shell-divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-heading {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-spacer {
|
.filter-spacer {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-item-form--search {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select-comp {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-item-form:not(.filter-item-form--search) {
|
||||||
|
flex: 1 1 calc(50% - 5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form > .el-button--primary {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -19,6 +19,27 @@ describe("admin user login status", () => {
|
|||||||
expect(drawer).not.toContain("access_token");
|
expect(drawer).not.toContain("access_token");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("offers responsive account and login-status filters with clear reset feedback", () => {
|
||||||
|
const users = readSource("./Users.vue");
|
||||||
|
|
||||||
|
expect(users).toContain('placeholder="账号状态"');
|
||||||
|
expect(users).toContain('placeholder="登录状态"');
|
||||||
|
expect(users).toContain('login_status: loginStatusFilter.value || undefined');
|
||||||
|
expect(users).toContain('class="account-overview"');
|
||||||
|
expect(users).toContain("position: sticky");
|
||||||
|
expect(users).not.toContain("filterByStat");
|
||||||
|
expect(users).not.toContain("账号与登录会话状态");
|
||||||
|
expect(users).toContain("min-height: 48px");
|
||||||
|
expect(users).toContain('class-name="actions-column"');
|
||||||
|
expect(users).toContain("text-overflow: clip");
|
||||||
|
expect(users).toContain(".department-text,\n.account-status-cell");
|
||||||
|
expect(users).toContain("padding: 0 !important;");
|
||||||
|
expect(users).toContain("border-radius: 0;");
|
||||||
|
expect(users).toContain("content-wrapper:has(.users-page)");
|
||||||
|
expect(users).toContain("resetFilters");
|
||||||
|
expect(users).toContain("筛选结果");
|
||||||
|
});
|
||||||
|
|
||||||
it("mounts account edit and emergency-reset dialogs to a container shared by web and desktop layouts", () => {
|
it("mounts account edit and emergency-reset dialogs to a container shared by web and desktop layouts", () => {
|
||||||
const userForm = readSource("./UserForm.vue");
|
const userForm = readSource("./UserForm.vue");
|
||||||
const resetPassword = readSource("./UserResetPassword.vue");
|
const resetPassword = readSource("./UserResetPassword.vue");
|
||||||
@@ -27,6 +48,8 @@ describe("admin user login status", () => {
|
|||||||
expect(resetPassword).toContain('append-to="body"');
|
expect(resetPassword).toContain('append-to="body"');
|
||||||
expect(userForm).not.toContain('append-to=".layout-main .content-wrapper"');
|
expect(userForm).not.toContain('append-to=".layout-main .content-wrapper"');
|
||||||
expect(resetPassword).not.toContain('append-to=".layout-main .content-wrapper"');
|
expect(resetPassword).not.toContain('append-to=".layout-main .content-wrapper"');
|
||||||
|
expect(userForm).not.toContain('class="form-header"');
|
||||||
|
expect(userForm).not.toContain("填写以下信息创建系统账号");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the desktop dialog treatment without an additional outer card for account actions", () => {
|
it("uses the desktop dialog treatment without an additional outer card for account actions", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user