Compare commits
10 Commits
84e55af8fe
...
efc325568d
| Author | SHA1 | Date | |
|---|---|---|---|
| efc325568d | |||
| c351ca59c4 | |||
| 6056c8364a | |||
| 360a2ba2b1 | |||
| e507ab98cb | |||
| 9b2b8e90f6 | |||
| 53249daf8d | |||
| 061792c73f | |||
| 1886765db8 | |||
| b7484c8e01 |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
.git
|
||||
.gitignore
|
||||
.worktrees
|
||||
.agents
|
||||
.claude
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
**/.env
|
||||
**/.env.*
|
||||
pg_data
|
||||
tmp
|
||||
.install-logs
|
||||
.pytest_cache
|
||||
**/.pytest_cache
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
backend/.coverage
|
||||
backend/.pytest_cache
|
||||
nginx/certs
|
||||
@@ -5,6 +5,7 @@ Thumbs.db
|
||||
# IDE/editor
|
||||
.idea/
|
||||
.vscode/
|
||||
.claude/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# CTMS 项目快速上手
|
||||
|
||||
## 交互安装入口
|
||||
- 推荐执行 `./install.sh`,通过键盘 `↑/↓` 选择安装、更新、卸载、资源状态等操作;该入口不接受命令行参数。
|
||||
- 菜单顶部会常驻显示当前 CTMS 容器部署状态,包括已检测到的环境、Compose 项目和容器运行数量。
|
||||
- 底层脚本位于 `scripts/`:`install.sh`、`update.sh`、`uninstall.sh`、`status.sh`;状态脚本使用 `docker compose stats --no-stream` 采集资源快照并美化展示。
|
||||
|
||||
## 生产部署
|
||||
- 生产入口:`docker-compose.yaml`
|
||||
- 初始化方式:`docker compose run --rm backend-init`
|
||||
|
||||
@@ -42,6 +42,12 @@ class ExtendResponse(BaseModel):
|
||||
router = APIRouter()
|
||||
AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avatars"
|
||||
AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
AVATAR_ALLOWED_CONTENT_TYPES = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
}
|
||||
|
||||
|
||||
def issue_user_token(db_user) -> Token:
|
||||
@@ -215,10 +221,15 @@ async def upload_avatar(
|
||||
current_user=Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UserRead:
|
||||
ext = AVATAR_ALLOWED_CONTENT_TYPES.get(file.content_type or "")
|
||||
if not ext:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="头像仅支持图片格式",
|
||||
)
|
||||
AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
user_dir = AVATAR_ROOT / str(current_user.id)
|
||||
user_dir.mkdir(parents=True, exist_ok=True)
|
||||
ext = Path(file.filename).suffix or ".png"
|
||||
filename = f"{uuid.uuid4()}{ext}"
|
||||
dest = user_dir / filename
|
||||
content = await file.read()
|
||||
|
||||
@@ -14,13 +14,13 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, is_system_admin, list_active_pm_study_ids
|
||||
from app.core.deps import get_current_user, get_db_session, is_system_admin
|
||||
from app.core.permission_monitor import get_permission_monitor
|
||||
from app.models.permission_access_log import PermissionAccessLog
|
||||
from app.models.permission_metric_snapshot import PermissionMetricSnapshot
|
||||
from app.models.security_access_log import SecurityAccessLog
|
||||
from app.models.user import User
|
||||
from app.services.ip_location import resolve_ip_location
|
||||
from app.services.ip_location import IpLocation, resolve_ip_location
|
||||
|
||||
router = APIRouter(prefix="/permission-monitoring", tags=["permission-monitoring"])
|
||||
|
||||
@@ -39,10 +39,7 @@ class MonitoringScope:
|
||||
async def resolve_monitoring_scope(db: AsyncSession, current_user) -> MonitoringScope:
|
||||
if is_system_admin(current_user):
|
||||
return MonitoringScope(is_admin=True, study_ids=set())
|
||||
return MonitoringScope(
|
||||
is_admin=False,
|
||||
study_ids=await list_active_pm_study_ids(db, current_user.id),
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
|
||||
|
||||
def _apply_monitoring_scope_to_log_query(query, scope: MonitoringScope):
|
||||
@@ -269,17 +266,13 @@ async def get_access_logs(
|
||||
).select_from(PermissionAccessLog)
|
||||
user_stats_query = (
|
||||
select(
|
||||
PermissionAccessLog.user_id,
|
||||
User.full_name,
|
||||
PermissionAccessLog.role,
|
||||
PermissionAccessLog.ip_address.label("sample_ip_address"),
|
||||
func.count().label("total_count"),
|
||||
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied_count"),
|
||||
func.count(func.distinct(PermissionAccessLog.ip_address)).label("unique_ip_count"),
|
||||
func.max(PermissionAccessLog.created_at).label("last_seen_at"),
|
||||
)
|
||||
.outerjoin(User, PermissionAccessLog.user_id == User.id)
|
||||
.group_by(PermissionAccessLog.ip_address, PermissionAccessLog.user_id, User.full_name, PermissionAccessLog.role)
|
||||
.group_by(PermissionAccessLog.ip_address)
|
||||
.order_by(desc("total_count"), desc("last_seen_at"))
|
||||
.limit(10)
|
||||
)
|
||||
@@ -295,6 +288,26 @@ async def get_access_logs(
|
||||
summary_row = summary_result.one()
|
||||
user_stats_result = await db.execute(user_stats_query)
|
||||
user_stats_rows = user_stats_result.all()
|
||||
latest_user_by_ip = {}
|
||||
if user_stats_rows:
|
||||
ranked_ips = [stat.sample_ip_address for stat in user_stats_rows if stat.sample_ip_address]
|
||||
latest_user_query = (
|
||||
select(
|
||||
PermissionAccessLog.ip_address,
|
||||
PermissionAccessLog.user_id,
|
||||
User.full_name,
|
||||
PermissionAccessLog.role,
|
||||
PermissionAccessLog.created_at,
|
||||
)
|
||||
.outerjoin(User, PermissionAccessLog.user_id == User.id)
|
||||
.where(PermissionAccessLog.ip_address.in_(ranked_ips))
|
||||
.order_by(PermissionAccessLog.ip_address, desc(PermissionAccessLog.created_at))
|
||||
)
|
||||
for condition in conditions:
|
||||
latest_user_query = latest_user_query.where(condition)
|
||||
latest_user_result = await db.execute(latest_user_query)
|
||||
for row in latest_user_result.all():
|
||||
latest_user_by_ip.setdefault(row.ip_address, row)
|
||||
|
||||
query = query.order_by(desc(PermissionAccessLog.created_at))
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
@@ -326,11 +339,12 @@ async def get_access_logs(
|
||||
user_stats = []
|
||||
for stat in user_stats_rows:
|
||||
sample_location = resolve_ip_location(stat.sample_ip_address)
|
||||
latest_user = latest_user_by_ip.get(stat.sample_ip_address)
|
||||
user_stats.append(
|
||||
{
|
||||
"user_id": str(stat.user_id),
|
||||
"user_name": stat.full_name or "未知用户",
|
||||
"role": stat.role,
|
||||
"user_id": str(latest_user.user_id) if latest_user else "",
|
||||
"user_name": latest_user.full_name if latest_user and latest_user.full_name else "未知用户",
|
||||
"role": latest_user.role if latest_user else "",
|
||||
"total_count": stat.total_count,
|
||||
"denied_count": stat.denied_count,
|
||||
"unique_ip_count": stat.unique_ip_count,
|
||||
@@ -364,6 +378,43 @@ def _security_account_label(auth_status: str, user_identifier: str | None, user_
|
||||
return "未知账号"
|
||||
|
||||
|
||||
SENSITIVE_PROBE_MARKERS = (
|
||||
"/.env",
|
||||
".env",
|
||||
"/.git",
|
||||
".git/config",
|
||||
"backup",
|
||||
"config.php",
|
||||
"wp-config",
|
||||
"database.yml",
|
||||
)
|
||||
|
||||
|
||||
CHINA_IP_COUNTRY_LABELS = {"中国", "China", "Mainland China", "中国香港", "中国澳门", "中国台湾"}
|
||||
|
||||
|
||||
def _is_non_china_ip(ip_location: IpLocation) -> bool:
|
||||
country = (ip_location.country or "").strip()
|
||||
return bool(country) and country not in CHINA_IP_COUNTRY_LABELS and not country.startswith("中国")
|
||||
|
||||
|
||||
def _classify_security_access_log(log: SecurityAccessLog, ip_location: IpLocation | None = None) -> dict[str, str]:
|
||||
path = (log.path or "").lower()
|
||||
if any(marker in path for marker in SENSITIVE_PROBE_MARKERS):
|
||||
return {"category": "PROBE", "severity": "CRITICAL"}
|
||||
if ip_location and _is_non_china_ip(ip_location):
|
||||
return {"category": "ABNORMAL_IP", "severity": "HIGH"}
|
||||
if log.status_code >= 500:
|
||||
return {"category": "SERVER_ERROR", "severity": "HIGH"}
|
||||
if log.auth_status == "INVALID_TOKEN":
|
||||
return {"category": "INVALID_TOKEN", "severity": "MEDIUM"}
|
||||
if log.auth_status == "ANONYMOUS" and log.status_code in {401, 403}:
|
||||
return {"category": "ANONYMOUS_API", "severity": "MEDIUM"}
|
||||
if log.status_code == 404:
|
||||
return {"category": "NOT_FOUND_NOISE", "severity": "LOW"}
|
||||
return {"category": "OTHER", "severity": "LOW"}
|
||||
|
||||
|
||||
@router.get("/security-logs", status_code=status.HTTP_200_OK)
|
||||
async def get_security_access_logs(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
@@ -421,6 +472,7 @@ async def get_security_access_logs(
|
||||
|
||||
items = []
|
||||
for log in logs:
|
||||
ip_location = resolve_ip_location(log.client_ip)
|
||||
items.append(
|
||||
{
|
||||
"id": str(log.id),
|
||||
@@ -429,10 +481,16 @@ async def get_security_access_logs(
|
||||
"status_code": log.status_code,
|
||||
"elapsed_ms": round(log.elapsed_ms, 2),
|
||||
"client_ip": log.client_ip,
|
||||
"ip_location": ip_location.location,
|
||||
"ip_country": ip_location.country,
|
||||
"ip_province": ip_location.province,
|
||||
"ip_city": ip_location.city,
|
||||
"ip_isp": ip_location.isp,
|
||||
"user_agent": log.user_agent,
|
||||
"auth_status": log.auth_status,
|
||||
"user_identifier": log.user_identifier,
|
||||
"account_label": _security_account_label(log.auth_status, log.user_identifier, user_names),
|
||||
**_classify_security_access_log(log, ip_location),
|
||||
"created_at": log.created_at.isoformat(),
|
||||
}
|
||||
)
|
||||
@@ -626,14 +684,26 @@ async def get_ip_locations(
|
||||
)
|
||||
)
|
||||
result = await db.execute(_apply_monitoring_scope_to_log_query(query, scope))
|
||||
security_result = await db.execute(
|
||||
select(
|
||||
SecurityAccessLog.client_ip,
|
||||
SecurityAccessLog.user_identifier,
|
||||
SecurityAccessLog.auth_status,
|
||||
SecurityAccessLog.status_code,
|
||||
).where(
|
||||
SecurityAccessLog.created_at >= start_time,
|
||||
SecurityAccessLog.client_ip.is_not(None),
|
||||
)
|
||||
)
|
||||
buckets: dict[tuple[str, str, str], dict] = {}
|
||||
all_ip_addresses: set[str] = set()
|
||||
all_user_ids: set[uuid.UUID] = set()
|
||||
all_user_ids: set[str] = set()
|
||||
total_count = 0
|
||||
allowed_count = 0
|
||||
denied_count = 0
|
||||
|
||||
for ip_address, user_id, allowed in result.all():
|
||||
def add_ip_location_row(ip_address: str, user_id: str | uuid.UUID | None, allowed: bool) -> None:
|
||||
nonlocal total_count, allowed_count, denied_count
|
||||
ip_info = resolve_ip_location(ip_address)
|
||||
key = (ip_info.country, ip_info.province, ip_info.city)
|
||||
location = " / ".join(part for part in [ip_info.country, ip_info.province, ip_info.city] if part) or ip_info.location or "未知"
|
||||
@@ -655,14 +725,28 @@ async def get_ip_locations(
|
||||
bucket["total_count"] += 1
|
||||
bucket["allowed_count" if allowed else "denied_count"] += 1
|
||||
bucket["ip_addresses"].add(ip_address)
|
||||
bucket["user_ids"].add(user_id)
|
||||
if user_id:
|
||||
bucket["user_ids"].add(str(user_id))
|
||||
total_count += 1
|
||||
if allowed:
|
||||
allowed_count += 1
|
||||
else:
|
||||
denied_count += 1
|
||||
all_ip_addresses.add(ip_address)
|
||||
all_user_ids.add(user_id)
|
||||
if user_id:
|
||||
all_user_ids.add(str(user_id))
|
||||
|
||||
for ip_address, user_id, allowed in result.all():
|
||||
add_ip_location_row(ip_address, user_id, allowed)
|
||||
|
||||
for client_ip, user_identifier, auth_status, status_code in security_result.all():
|
||||
user_id = None
|
||||
if auth_status == "AUTHENTICATED" and user_identifier:
|
||||
try:
|
||||
user_id = uuid.UUID(user_identifier)
|
||||
except ValueError:
|
||||
user_id = user_identifier
|
||||
add_ip_location_row(client_ip, user_id, status_code < 400)
|
||||
|
||||
items = sorted(buckets.values(), key=lambda item: item["total_count"], reverse=True)[:limit]
|
||||
return {
|
||||
|
||||
@@ -969,54 +969,54 @@ SYSTEM_PERMISSIONS: dict[str, dict] = {
|
||||
"description": "管理权限模板",
|
||||
"roles": ["ADMIN"],
|
||||
},
|
||||
# 权限监控
|
||||
# 系统监测
|
||||
"system:monitoring:metrics": {
|
||||
"module": "system_monitoring",
|
||||
"action": "read",
|
||||
"description": "查看权限监控指标",
|
||||
"roles": ["ADMIN", "PM"],
|
||||
"description": "查看系统监测指标",
|
||||
"roles": ["ADMIN"],
|
||||
},
|
||||
"system:monitoring:cache_stats": {
|
||||
"module": "system_monitoring",
|
||||
"action": "read",
|
||||
"description": "查看缓存统计",
|
||||
"roles": ["ADMIN", "PM"],
|
||||
"roles": ["ADMIN"],
|
||||
},
|
||||
"system:monitoring:alerts": {
|
||||
"module": "system_monitoring",
|
||||
"action": "read",
|
||||
"description": "查看权限告警",
|
||||
"roles": ["ADMIN", "PM"],
|
||||
"roles": ["ADMIN"],
|
||||
},
|
||||
"system:monitoring:health": {
|
||||
"module": "system_monitoring",
|
||||
"action": "read",
|
||||
"description": "查看权限系统健康状态",
|
||||
"roles": ["ADMIN", "PM"],
|
||||
"roles": ["ADMIN"],
|
||||
},
|
||||
"system:monitoring:access_logs": {
|
||||
"module": "system_monitoring",
|
||||
"action": "read",
|
||||
"description": "查看权限访问日志",
|
||||
"roles": ["ADMIN", "PM"],
|
||||
"roles": ["ADMIN"],
|
||||
},
|
||||
"system:monitoring:trends": {
|
||||
"module": "system_monitoring",
|
||||
"action": "read",
|
||||
"description": "查看权限趋势数据",
|
||||
"roles": ["ADMIN", "PM"],
|
||||
"roles": ["ADMIN"],
|
||||
},
|
||||
"system:monitoring:reset_metrics": {
|
||||
"module": "system_monitoring",
|
||||
"action": "update",
|
||||
"description": "重置监控指标",
|
||||
"roles": ["ADMIN", "PM"],
|
||||
"description": "重置系统监测指标",
|
||||
"roles": ["ADMIN"],
|
||||
},
|
||||
"system:monitoring:clear_alerts": {
|
||||
"module": "system_monitoring",
|
||||
"action": "update",
|
||||
"description": "清除告警",
|
||||
"roles": ["ADMIN", "PM"],
|
||||
"roles": ["ADMIN"],
|
||||
},
|
||||
"system:monitoring:security_logs": {
|
||||
"module": "system_monitoring",
|
||||
@@ -1044,6 +1044,6 @@ SYSTEM_MODULE_LABELS: dict[str, str] = {
|
||||
"system_users": "账号管理",
|
||||
"system_projects": "项目管理",
|
||||
"system_permissions": "权限管理",
|
||||
"system_monitoring": "权限监控",
|
||||
"system_monitoring": "系统监测",
|
||||
"system_audit": "审计日志",
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_new_ae_records_default_to_follow_up_status():
|
||||
crud_source = Path("app/crud/ae.py").read_text(encoding="utf-8")
|
||||
model_source = Path("app/models/ae.py").read_text(encoding="utf-8")
|
||||
|
||||
assert 'status="FOLLOW_UP"' in crud_source
|
||||
assert 'default="FOLLOW_UP"' in model_source
|
||||
assert 'status="NEW"' not in crud_source
|
||||
assert 'default="NEW"' not in model_source
|
||||
@@ -1,86 +0,0 @@
|
||||
"""合同费用基础字段测试"""
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
import uuid
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.api.v1 import fees_contracts
|
||||
from app.models.contract_fee import ContractFee
|
||||
from app.schemas.contract_fee import ContractFeeCreate, ContractFeeRead, ContractFeeUpdate
|
||||
|
||||
|
||||
def _request_with_query(query: str) -> Request:
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/api/v1/fees/contracts",
|
||||
"query_string": query.encode(),
|
||||
"headers": [],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_contract_fee_list_query_accepts_legacy_project_params():
|
||||
"""合同费用列表应兼容旧 projectId/centerId 查询参数。"""
|
||||
study_id = uuid.uuid4()
|
||||
center_id = uuid.uuid4()
|
||||
resolver = getattr(fees_contracts, "_resolve_contract_fee_list_query", None)
|
||||
|
||||
assert resolver is not None
|
||||
resolved_study_id, resolved_center_id = resolver(
|
||||
_request_with_query(f"projectId={study_id}¢erId={center_id}"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
|
||||
assert resolved_study_id == study_id
|
||||
assert resolved_center_id == center_id
|
||||
|
||||
|
||||
def test_contract_fee_schema_includes_contract_basic_fields():
|
||||
"""合同费用应包含合同基础信息字段。"""
|
||||
study_id = uuid.uuid4()
|
||||
center_id = uuid.uuid4()
|
||||
|
||||
payload = ContractFeeCreate(
|
||||
study_id=study_id,
|
||||
center_id=center_id,
|
||||
contract_no="CT-001",
|
||||
signed_date=date(2026, 5, 27),
|
||||
contract_amount=Decimal("120000.00"),
|
||||
currency="CNY",
|
||||
remark="首版合同",
|
||||
contract_cases=12,
|
||||
)
|
||||
|
||||
assert payload.contract_no == "CT-001"
|
||||
assert payload.signed_date == date(2026, 5, 27)
|
||||
assert payload.currency == "CNY"
|
||||
assert payload.remark == "首版合同"
|
||||
assert payload.study_id == study_id
|
||||
assert "project_id" not in ContractFeeCreate.model_fields
|
||||
|
||||
update_payload = ContractFeeUpdate(contract_no="CT-002", currency="USD", remark="变更合同信息")
|
||||
assert update_payload.model_dump(exclude_unset=True) == {
|
||||
"contract_no": "CT-002",
|
||||
"currency": "USD",
|
||||
"remark": "变更合同信息",
|
||||
}
|
||||
|
||||
for field in ("contract_no", "signed_date", "currency", "remark"):
|
||||
assert field in ContractFeeRead.model_fields
|
||||
assert "study_id" in ContractFeeRead.model_fields
|
||||
assert "project_id" not in ContractFeeRead.model_fields
|
||||
|
||||
|
||||
def test_contract_fee_model_includes_contract_basic_columns():
|
||||
"""合同费用模型应持久化合同基础信息。"""
|
||||
columns = ContractFee.__table__.columns
|
||||
|
||||
assert "contract_no" in columns
|
||||
assert "signed_date" in columns
|
||||
assert "currency" in columns
|
||||
assert "remark" in columns
|
||||
@@ -1,81 +0,0 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.schemas.drug_shipment import DrugShipmentCreate
|
||||
|
||||
|
||||
def shipment_payload(**overrides):
|
||||
payload = {
|
||||
"direction": "SEND",
|
||||
"center_id": uuid.uuid4(),
|
||||
"ship_date": date(2026, 6, 4),
|
||||
"receive_date": None,
|
||||
"quantity": 1,
|
||||
"batch_no": "DP-001",
|
||||
"carrier": "顺丰",
|
||||
"tracking_no": "SF100003",
|
||||
"status": "IN_TRANSIT",
|
||||
"remark": None,
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def test_create_allows_pending_shipment_execution_fields_to_be_empty():
|
||||
shipment = DrugShipmentCreate.model_validate(
|
||||
shipment_payload(
|
||||
status="PENDING",
|
||||
ship_date=None,
|
||||
quantity=None,
|
||||
batch_no=None,
|
||||
carrier=None,
|
||||
tracking_no=None,
|
||||
receive_date=None,
|
||||
remark=None,
|
||||
)
|
||||
)
|
||||
|
||||
assert shipment.ship_date is None
|
||||
assert shipment.quantity is None
|
||||
assert shipment.batch_no is None
|
||||
assert shipment.carrier is None
|
||||
assert shipment.tracking_no is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field_name", ["ship_date", "quantity", "batch_no", "carrier", "tracking_no"])
|
||||
def test_create_requires_shipment_execution_fields_after_pending(field_name):
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
DrugShipmentCreate.model_validate(shipment_payload(status="IN_TRANSIT", **{field_name: None}))
|
||||
|
||||
assert "发运信息" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_create_allows_pending_receipt_fields_to_be_empty():
|
||||
shipment = DrugShipmentCreate.model_validate(shipment_payload())
|
||||
|
||||
assert shipment.receive_date is None
|
||||
assert shipment.remark is None
|
||||
|
||||
|
||||
def test_create_requires_receive_date_when_signed():
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
DrugShipmentCreate.model_validate(shipment_payload(status="SIGNED", receive_date=None))
|
||||
|
||||
assert "接收日期" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_create_rejects_removed_returned_status():
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
DrugShipmentCreate.model_validate(shipment_payload(status="RETURNED", receive_date=date(2026, 6, 5)))
|
||||
|
||||
assert "无效状态" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_create_requires_remark_when_exceptional():
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
DrugShipmentCreate.model_validate(shipment_payload(status="EXCEPTION", remark=""))
|
||||
|
||||
assert "备注" in str(exc_info.value)
|
||||
@@ -1,39 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import text
|
||||
import pytest
|
||||
|
||||
from app.crud.faq_category import create_category
|
||||
from app.crud.faq_item import create_item
|
||||
from app.schemas.faq import CategoryCreate, FaqCreate
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_category_defaults_to_active(db_session):
|
||||
result = await db_session.execute(text("SELECT id FROM studies LIMIT 1"))
|
||||
study_id = result.scalar_one()
|
||||
|
||||
category = await create_category(
|
||||
db_session,
|
||||
CategoryCreate(study_id=study_id, name="AE", sort_order=0),
|
||||
)
|
||||
|
||||
assert category.is_active is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_item_defaults_to_active(db_session):
|
||||
result = await db_session.execute(text("SELECT id FROM studies LIMIT 1"))
|
||||
study_id = result.scalar_one()
|
||||
category = await create_category(
|
||||
db_session,
|
||||
CategoryCreate(study_id=study_id, name=f"AE-{uuid.uuid4().hex[:8]}", sort_order=0),
|
||||
)
|
||||
|
||||
item = await create_item(
|
||||
db_session,
|
||||
FaqCreate(study_id=study_id, category_id=category.id, question="AE 如何记录?"),
|
||||
created_by=uuid.uuid4(),
|
||||
)
|
||||
|
||||
assert item.is_active is True
|
||||
@@ -1,37 +0,0 @@
|
||||
"""单元测试:IP 属地解析服务"""
|
||||
|
||||
from app.services.ip_location import Ip2RegionResolver
|
||||
|
||||
|
||||
class FakeSearcher:
|
||||
def search(self, _ip: str) -> str:
|
||||
return "中国|广东省|深圳市|电信|CN"
|
||||
|
||||
|
||||
def test_ip_location_handles_special_addresses():
|
||||
resolver = Ip2RegionResolver(db_path="/not-exists/ip2region.xdb")
|
||||
|
||||
assert resolver.lookup(None).location == "未知"
|
||||
assert resolver.lookup("not-an-ip").location == "未知"
|
||||
assert resolver.lookup("127.0.0.1").location == "本机"
|
||||
assert resolver.lookup("192.168.1.1").location == "局域网"
|
||||
|
||||
|
||||
def test_ip2region_result_parses_province_city_isp():
|
||||
resolver = Ip2RegionResolver(db_path="/not-exists/ip2region.xdb")
|
||||
resolver._searchers[4] = FakeSearcher()
|
||||
|
||||
result = resolver.lookup("8.8.8.8")
|
||||
|
||||
assert result.country == "中国"
|
||||
assert result.province == "广东省"
|
||||
assert result.city == "深圳市"
|
||||
assert result.isp == "电信"
|
||||
assert result.location == "中国 / 广东省 / 深圳市 / 电信"
|
||||
|
||||
|
||||
def test_default_resolver_can_use_packaged_xdb():
|
||||
resolver = Ip2RegionResolver()
|
||||
result = resolver.lookup("8.8.8.8")
|
||||
|
||||
assert result.location not in {"公网", "未知"}
|
||||
@@ -1,272 +0,0 @@
|
||||
"""
|
||||
第2批模块迁移测试:members 和 sites 模块
|
||||
测试接口级权限系统在 members 和 sites 模块中的应用
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.project_permissions import role_has_api_permission
|
||||
from app.models.api_endpoint_permission import ApiEndpointPermission
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_member_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的PM可以添加项目成员"""
|
||||
study_id = uuid.uuid4()
|
||||
|
||||
# 创建权限
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="PM",
|
||||
endpoint_key="project_members:create",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
# 验证权限检查
|
||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_members:create")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_member_without_permission(db_session: AsyncSession):
|
||||
"""验证无权限的CRA无法添加项目成员"""
|
||||
study_id = uuid.uuid4()
|
||||
|
||||
# 创建权限(拒绝)
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="CRA",
|
||||
endpoint_key="project_members:create",
|
||||
allowed=False,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
# 验证权限检查
|
||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "project_members:create")
|
||||
assert allowed is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_members_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的PM可以查询项目成员列表"""
|
||||
study_id = uuid.uuid4()
|
||||
|
||||
# 创建权限
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="PM",
|
||||
endpoint_key="project_members:list",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
# 验证权限检查
|
||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_members:list")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_member_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的PM可以更新项目成员"""
|
||||
study_id = uuid.uuid4()
|
||||
|
||||
# 创建权限
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="PM",
|
||||
endpoint_key="project_members:update",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
# 验证权限检查
|
||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_members:update")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_member_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的PM可以删除项目成员"""
|
||||
study_id = uuid.uuid4()
|
||||
|
||||
# 创建权限
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="PM",
|
||||
endpoint_key="project_members:delete",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
# 验证权限检查
|
||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_members:delete")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_member_candidates_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的PM可以查询项目成员候选人"""
|
||||
study_id = uuid.uuid4()
|
||||
|
||||
# 创建权限
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="PM",
|
||||
endpoint_key="project_members:candidates",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
# 验证权限检查
|
||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_members:candidates")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_site_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的PM可以创建中心"""
|
||||
study_id = uuid.uuid4()
|
||||
|
||||
# 创建权限
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="PM",
|
||||
endpoint_key="sites:create",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
# 验证权限检查
|
||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "sites:create")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sites_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的PM可以查询中心列表"""
|
||||
study_id = uuid.uuid4()
|
||||
|
||||
# 创建权限
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="PM",
|
||||
endpoint_key="sites:list",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
# 验证权限检查
|
||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "sites:list")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sites_cra_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的CRA可以查询中心列表"""
|
||||
study_id = uuid.uuid4()
|
||||
|
||||
# 创建权限
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="CRA",
|
||||
endpoint_key="sites:list",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
# 验证权限检查
|
||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "sites:list")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_site_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的PM可以更新中心"""
|
||||
study_id = uuid.uuid4()
|
||||
|
||||
# 创建权限
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="PM",
|
||||
endpoint_key="sites:update",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
# 验证权限检查
|
||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "sites:update")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_site_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的PM可以删除中心"""
|
||||
study_id = uuid.uuid4()
|
||||
|
||||
# 创建权限
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="PM",
|
||||
endpoint_key="sites:delete",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
# 验证权限检查
|
||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "sites:delete")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_members_permission_denied_for_cra(db_session: AsyncSession):
|
||||
"""验证CRA无法执行成员管理操作"""
|
||||
study_id = uuid.uuid4()
|
||||
|
||||
# 创建权限(拒绝)
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="CRA",
|
||||
endpoint_key="project_members:create",
|
||||
allowed=False,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
# 验证权限检查
|
||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "project_members:create")
|
||||
assert allowed is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sites_permission_denied_for_cra_write(db_session: AsyncSession):
|
||||
"""验证CRA无法执行中心写操作"""
|
||||
study_id = uuid.uuid4()
|
||||
|
||||
# 创建权限(拒绝)
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="CRA",
|
||||
endpoint_key="sites:create",
|
||||
allowed=False,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
# 验证权限检查
|
||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "sites:create")
|
||||
assert allowed is False
|
||||
|
||||
@@ -1,489 +0,0 @@
|
||||
"""
|
||||
第3批模块迁移测试:12个模块,63个端点
|
||||
测试接口级权限系统在所有第3批模块中的应用
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.project_permissions import role_has_api_permission
|
||||
from app.models.api_endpoint_permission import ApiEndpointPermission
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 启动管理 (startup)
|
||||
# ============================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_ethics_create_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的PM可以创建伦理记录"""
|
||||
study_id = uuid.uuid4()
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="PM",
|
||||
endpoint_key="startup_ethics:create",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "startup_ethics:create")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_ethics_list_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的PM可以查询伦理记录列表"""
|
||||
study_id = uuid.uuid4()
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="PM",
|
||||
endpoint_key="startup_ethics:list",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "startup_ethics:list")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_initiation_create_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的PM可以创建立项记录"""
|
||||
study_id = uuid.uuid4()
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="PM",
|
||||
endpoint_key="startup_initiation:create",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "startup_initiation:create")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_auth_create_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的PM可以创建启动会或培训授权"""
|
||||
study_id = uuid.uuid4()
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="PM",
|
||||
endpoint_key="startup_auth:create",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "startup_auth:create")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_auth_read_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的CRA可以查询启动会或培训授权"""
|
||||
study_id = uuid.uuid4()
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="CRA",
|
||||
endpoint_key="startup_auth:read",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "startup_auth:read")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 项目权限管理已迁移为系统级权限
|
||||
# ============================================================================
|
||||
|
||||
def test_project_permissions_are_not_project_matrix_permissions():
|
||||
"""项目权限配置由 system:permissions:project_config 控制,不再进入项目矩阵。"""
|
||||
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, SYSTEM_PERMISSIONS
|
||||
|
||||
assert "permissions:read" not in API_ENDPOINT_PERMISSIONS
|
||||
assert "permissions:update" not in API_ENDPOINT_PERMISSIONS
|
||||
assert "system:permissions:project_config" in SYSTEM_PERMISSIONS
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 项目概览 (overview) - 1个端点
|
||||
# ============================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overview_get_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的PM可以查询项目概览"""
|
||||
study_id = uuid.uuid4()
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="PM",
|
||||
endpoint_key="project_overview:read",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_overview:read")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 监查访视问题汇总 (monitoring_visit_issues)
|
||||
# ============================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_monitoring_issues_list_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的CRA可以查询监查访视问题列表"""
|
||||
study_id = uuid.uuid4()
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="CRA",
|
||||
endpoint_key="monitoring_issues:list",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "monitoring_issues:list")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 药物发货 (drug_shipments) - 5个端点
|
||||
# ============================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drug_shipments_create_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的CTA可以创建药物发货"""
|
||||
study_id = uuid.uuid4()
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="CTA",
|
||||
endpoint_key="drug_shipments:create",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "CTA", "drug_shipments:create")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drug_shipments_list_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的CTA可以查询药物发货列表"""
|
||||
study_id = uuid.uuid4()
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="CTA",
|
||||
endpoint_key="drug_shipments:list",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "CTA", "drug_shipments:list")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 设备管理 (material_equipments) - 5个端点
|
||||
# ============================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_material_equipments_create_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的CRA可以创建设备"""
|
||||
study_id = uuid.uuid4()
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="CRA",
|
||||
endpoint_key="material_equipments:create",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "material_equipments:create")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_material_equipments_list_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的CTA可以查询设备列表"""
|
||||
study_id = uuid.uuid4()
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="CTA",
|
||||
endpoint_key="material_equipments:list",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "CTA", "material_equipments:list")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 参与者PD (subject_pds) - 4个端点
|
||||
# ============================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subject_pds_create_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的CRA可以创建参与者PD"""
|
||||
study_id = uuid.uuid4()
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="CRA",
|
||||
endpoint_key="subject_pds:create",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "subject_pds:create")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subject_pds_list_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的CRA可以查询参与者PD列表"""
|
||||
study_id = uuid.uuid4()
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="CRA",
|
||||
endpoint_key="subject_pds:list",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "subject_pds:list")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 审计日志 (audit_logs) - 3个端点
|
||||
# ============================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audit_logs_list_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的PM可以查询审计日志列表"""
|
||||
study_id = uuid.uuid4()
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="PM",
|
||||
endpoint_key="audit_logs:list",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "audit_logs:list")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audit_logs_export_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的PM可以导出审计日志"""
|
||||
study_id = uuid.uuid4()
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="PM",
|
||||
endpoint_key="audit_logs:export",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "audit_logs:export")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 访视管理 (visits) - 5个端点
|
||||
# ============================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_visits_create_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的PV可以创建访视"""
|
||||
study_id = uuid.uuid4()
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="PV",
|
||||
endpoint_key="visits:create",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "PV", "visits:create")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_visits_list_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的PV可以查询访视列表"""
|
||||
study_id = uuid.uuid4()
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="PV",
|
||||
endpoint_key="visits:list",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "PV", "visits:list")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 注意事项 (precautions) - 5个端点
|
||||
# ============================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_precautions_create_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的QA可以创建注意事项"""
|
||||
study_id = uuid.uuid4()
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="QA",
|
||||
endpoint_key="precautions:create",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "QA", "precautions:create")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_precautions_list_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的QA可以查询注意事项列表"""
|
||||
study_id = uuid.uuid4()
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="QA",
|
||||
endpoint_key="precautions:list",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "QA", "precautions:list")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 病史记录 (subject_histories)
|
||||
# ============================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subject_histories_list_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的CRA可以查询病史记录列表"""
|
||||
study_id = uuid.uuid4()
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="CRA",
|
||||
endpoint_key="subject_histories:list",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "subject_histories:list")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subject_histories_read_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的CRA可以查询病史记录详情"""
|
||||
study_id = uuid.uuid4()
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="CRA",
|
||||
endpoint_key="subject_histories:read",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "subject_histories:read")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 项目里程碑 (project_milestones) - 2个端点
|
||||
# ============================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_milestones_list_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的PM可以查询项目里程碑列表"""
|
||||
study_id = uuid.uuid4()
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="PM",
|
||||
endpoint_key="project_milestones:read",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_milestones:read")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_milestones_update_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的PM可以更新项目里程碑"""
|
||||
study_id = uuid.uuid4()
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="PM",
|
||||
endpoint_key="project_milestones:update",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "project_milestones:update")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 权限拒绝场景
|
||||
# ============================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_permission_denied_for_cra(db_session: AsyncSession):
|
||||
"""验证CRA无法执行启动管理操作"""
|
||||
study_id = uuid.uuid4()
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="CRA",
|
||||
endpoint_key="startup_ethics:create",
|
||||
allowed=False,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "startup_ethics:create")
|
||||
assert allowed is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_removed_project_permissions_are_denied_for_project_roles(db_session: AsyncSession):
|
||||
"""项目角色不能再通过 permissions:update 这种残留 key 获得权限管理能力。"""
|
||||
study_id = uuid.uuid4()
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "permissions:update")
|
||||
assert allowed is False
|
||||
@@ -1,107 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_is_locked_migration_is_idempotent():
|
||||
source = Path("alembic/versions/20260116_01_add_is_locked_to_studies.py").read_text(encoding="utf-8")
|
||||
|
||||
assert 'if "is_locked" not in columns:' in source
|
||||
assert 'if "is_locked" in columns:' in source
|
||||
|
||||
|
||||
def test_removed_workflow_tables_are_dropped_conditionally():
|
||||
source = Path("alembic/versions/20260116_05_remove_document_workflows.py").read_text(encoding="utf-8")
|
||||
|
||||
assert 'if "workflow_actions" in tables:' in source
|
||||
assert 'if "version_workflows" in tables:' in source
|
||||
assert 'if "workflow_nodes" in tables:' in source
|
||||
assert 'if "workflow_templates" in tables:' in source
|
||||
|
||||
|
||||
def test_migration_state_check_script_exists():
|
||||
source = Path("scripts/check_migration_state.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "missing alembic_version table" in source
|
||||
assert "studies" in source
|
||||
assert "subjects" in source
|
||||
assert "monitoring_visit_issues" in source
|
||||
|
||||
|
||||
def test_remove_qa_role_migration_casts_json_permissions_for_key_lookup():
|
||||
source = Path("alembic/versions/20260521_01_remove_qa_role.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "permissions ? 'QA'" not in source
|
||||
assert "permissions::jsonb ? 'QA'" in source
|
||||
|
||||
|
||||
def test_role_template_copy_migration_updates_display_copy_for_current_role_keys():
|
||||
source = Path("alembic/versions/20260522_01_update_role_template_copy.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "项目负责人,统筹项目全局,协调进度、资源与关键决策。" in source
|
||||
assert "负责各中心临床监查执行,跟进现场质量、数据和问题闭环。" in source
|
||||
assert "负责合同、药品及相关项目事务管理,保障执行支持与物资协同。" in source
|
||||
assert "负责医学审核与稽查,关注质量风险、合规性和医学一致性。" in source
|
||||
assert "负责药物警戒相关工作,跟踪安全性事件并支持风险评估。" in source
|
||||
assert '"IMP": ("CTA"' in source
|
||||
assert '"MEDICAL_REVIEW": ("QA"' in source
|
||||
assert "category = 'QA'" not in source
|
||||
|
||||
|
||||
def test_permission_template_migrations_do_not_seed_stale_startup_permissions():
|
||||
stale_keys = (
|
||||
"budget:create",
|
||||
"budget:list",
|
||||
"budget:read",
|
||||
"budget:update",
|
||||
"budget:delete",
|
||||
"timeline:create",
|
||||
"timeline:list",
|
||||
"timeline:read",
|
||||
"timeline:update",
|
||||
"timeline:delete",
|
||||
)
|
||||
for path in Path("alembic/versions").glob("*.py"):
|
||||
if path.name == "20260527_04_remove_stale_startup_permissions.py":
|
||||
continue
|
||||
source = path.read_text(encoding="utf-8")
|
||||
for key in stale_keys:
|
||||
assert key not in source, f"{key} should not be seeded by {path}"
|
||||
|
||||
|
||||
def test_legacy_startup_ethics_permission_keys_are_removed_by_followup_migration():
|
||||
source = Path("alembic/versions/20260527_05_remove_legacy_startup_ethics_permission_keys.py").read_text(encoding="utf-8")
|
||||
|
||||
assert '"feasibility:create"' in source
|
||||
assert '"feasibility:list"' in source
|
||||
assert '"feasibility:read"' in source
|
||||
assert '"feasibility:update"' in source
|
||||
assert '"feasibility:delete"' in source
|
||||
assert '"ethics:create"' in source
|
||||
assert '"ethics:list"' in source
|
||||
assert '"ethics:read"' in source
|
||||
assert '"ethics:update"' in source
|
||||
assert '"ethics:delete"' in source
|
||||
assert "startup_initiation:" not in source
|
||||
assert "startup_ethics:" not in source
|
||||
assert "DELETE FROM api_endpoint_permissions" in source
|
||||
assert "api_endpoint_permissions" in source
|
||||
assert "permission_templates" in source
|
||||
assert "permission_template_versions" in source
|
||||
|
||||
|
||||
def test_precautions_migration_renames_table_and_attachment_entity_type():
|
||||
source = Path("alembic/versions/20260527_08_rename_knowledge_notes_to_precautions.py").read_text(encoding="utf-8")
|
||||
|
||||
assert 'rename_table("knowledge_notes", "precautions")' in source
|
||||
assert "entity_type = 'precaution'" in source
|
||||
assert "entity_type = 'knowledge_note'" in source
|
||||
assert "api_endpoint_permissions" in source
|
||||
assert "permission_templates" in source
|
||||
|
||||
|
||||
def test_etmf_migration_reuses_existing_document_scope_type():
|
||||
source = Path("alembic/versions/20260527_09_add_etmf_nodes.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "postgresql.ENUM(" in source
|
||||
assert 'name="document_scope_type"' in source
|
||||
assert "create_type=False" in source
|
||||
assert "scope_type = sa.Enum" not in source
|
||||
@@ -1,72 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.crud import monitoring_visit_issue as issue_crud
|
||||
from app.models.monitoring_visit_issue import MonitoringVisitIssue
|
||||
from app.schemas.monitoring_visit_issue import MonitoringVisitIssueCreate
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_monitoring_visit_issue_template_fields_can_be_filtered(tmp_path):
|
||||
db_path = tmp_path / "monitoring-issues.db"
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True)
|
||||
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(MonitoringVisitIssue.__table__.create)
|
||||
|
||||
study_id = uuid.UUID("11111111-1111-1111-1111-111111111111")
|
||||
site_id = uuid.UUID("22222222-2222-2222-2222-222222222222")
|
||||
async with SessionLocal() as session:
|
||||
created = await issue_crud.create_issue(
|
||||
session,
|
||||
study_id,
|
||||
MonitoringVisitIssueCreate(
|
||||
issue_no="MV-001",
|
||||
site_id=site_id,
|
||||
category="原始记录",
|
||||
subject_code="SUBJ-001",
|
||||
status="OPEN",
|
||||
severity="严重",
|
||||
mark="SDV",
|
||||
visit_cycle="V1",
|
||||
center_query="请补充签名日期",
|
||||
center_latest_reply="待中心回复",
|
||||
rectification_completed=False,
|
||||
due_at=datetime(2026, 5, 1, tzinfo=timezone.utc),
|
||||
),
|
||||
created_by=None,
|
||||
)
|
||||
|
||||
assert created.site_id == site_id
|
||||
assert created.severity == "严重"
|
||||
assert created.mark == "SDV"
|
||||
assert created.visit_cycle == "V1"
|
||||
assert created.center_query == "请补充签名日期"
|
||||
assert created.center_latest_reply == "待中心回复"
|
||||
assert created.rectification_completed is False
|
||||
|
||||
matched = await issue_crud.list_issues(
|
||||
session,
|
||||
study_id,
|
||||
site_id=site_id,
|
||||
severity="严重",
|
||||
mark="SDV",
|
||||
visit_cycle="V1",
|
||||
rectification_completed=False,
|
||||
due_from=datetime(2026, 5, 1, tzinfo=timezone.utc).date(),
|
||||
due_to=datetime(2026, 5, 1, tzinfo=timezone.utc).date(),
|
||||
)
|
||||
unmatched = await issue_crud.list_issues(
|
||||
session,
|
||||
study_id,
|
||||
site_id=uuid.UUID("33333333-3333-3333-3333-333333333333"),
|
||||
)
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
assert [item.issue_no for item in matched] == ["MV-001"]
|
||||
assert unmatched == []
|
||||
@@ -1,27 +1,59 @@
|
||||
"""监控API测试:权限系统监控API端点验证。"""
|
||||
|
||||
import inspect
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.permission_monitor import set_permission_monitor, PermissionMonitor
|
||||
from app.api.v1 import permission_monitoring
|
||||
from app.api.v1.system_permissions import list_system_permissions
|
||||
|
||||
|
||||
class FakeIpInfo:
|
||||
def __init__(self, province: str, city: str, isp: str = "电信") -> None:
|
||||
self.country = "中国"
|
||||
def __init__(self, province: str, city: str, isp: str = "电信", country: str = "中国") -> None:
|
||||
self.country = country
|
||||
self.province = province
|
||||
self.city = city
|
||||
self.isp = isp
|
||||
self.location = f"中国 / {province} / {city} / {isp}"
|
||||
self.location = " / ".join(part for part in [country, province, city, isp] if part)
|
||||
|
||||
|
||||
class AdminUserStub:
|
||||
is_admin = True
|
||||
|
||||
|
||||
class ProjectPmUserStub:
|
||||
id = uuid.uuid4()
|
||||
is_admin = False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_monitoring_scope_rejects_project_pm(db_session):
|
||||
"""系统监测模块仅允许系统管理员访问,项目 PM 不再具备监测范围。"""
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await permission_monitoring.resolve_monitoring_scope(db_session, ProjectPmUserStub())
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_permission_monitoring_definitions_are_admin_only():
|
||||
"""系统级权限定义中的系统监测模块不应再标注 PM 角色。"""
|
||||
data = await list_system_permissions()
|
||||
monitoring_items = [
|
||||
item for item in data["permissions"]
|
||||
if item["module"] == "system_monitoring"
|
||||
]
|
||||
|
||||
assert monitoring_items
|
||||
assert data["module_labels"]["system_monitoring"] == "系统监测"
|
||||
assert all(item["roles"] == ["ADMIN"] 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:
|
||||
study_exists = (
|
||||
await db_session.execute(text("SELECT id FROM studies WHERE id = :id"), {"id": str(study_id)})
|
||||
@@ -495,6 +527,117 @@ async def test_ip_locations_keeps_private_network_location_label(db_session, mon
|
||||
assert result["items"][0]["city"] == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ip_locations_includes_abnormal_security_ips(db_session, monkeypatch):
|
||||
"""来源分析应同时统计安全事件中的异常来源 IP。"""
|
||||
await db_session.execute(text("DELETE FROM permission_access_logs"))
|
||||
await db_session.execute(text("DELETE FROM security_access_logs"))
|
||||
await db_session.commit()
|
||||
|
||||
study_id = "00000000-0000-0000-0000-000000000801"
|
||||
user_id = "00000000-0000-0000-0000-000000000901"
|
||||
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO studies (id, code, name, status, is_locked, visit_schedule, active_roles)
|
||||
VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule, :active_roles)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": study_id,
|
||||
"code": "IP-LOCATION-SECURITY-STUDY",
|
||||
"name": "IP Location Security Study",
|
||||
"status": "ACTIVE",
|
||||
"is_locked": False,
|
||||
"visit_schedule": "[]",
|
||||
"active_roles": "[]",
|
||||
},
|
||||
)
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status)
|
||||
VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": user_id,
|
||||
"email": "source-analysis@example.com",
|
||||
"password_hash": "hash",
|
||||
"full_name": "来源分析用户",
|
||||
"clinical_department": "临床运营",
|
||||
"is_admin": False,
|
||||
"status": "ACTIVE",
|
||||
},
|
||||
)
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO permission_access_logs
|
||||
(id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, created_at)
|
||||
VALUES
|
||||
(:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, CURRENT_TIMESTAMP)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"study_id": study_id,
|
||||
"user_id": user_id,
|
||||
"endpoint_key": "admin.permissions.read",
|
||||
"role": "PM",
|
||||
"allowed": True,
|
||||
"elapsed_ms": 3.2,
|
||||
"ip_address": "10.3.1.1",
|
||||
},
|
||||
)
|
||||
for ip_address in ["8.8.8.8", "1.1.1.1"]:
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO security_access_logs
|
||||
(id, method, path, status_code, elapsed_ms, client_ip, user_agent, auth_status, user_identifier, created_at)
|
||||
VALUES
|
||||
(:id, :method, :path, :status_code, :elapsed_ms, :client_ip, :user_agent, :auth_status, :user_identifier, CURRENT_TIMESTAMP)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin",
|
||||
"status_code": 403,
|
||||
"elapsed_ms": 8.5,
|
||||
"client_ip": ip_address,
|
||||
"user_agent": "pytest",
|
||||
"auth_status": "ANONYMOUS",
|
||||
"user_identifier": None,
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
ip_info_by_address = {
|
||||
"10.3.1.1": FakeIpInfo("", "", "", "局域网"),
|
||||
"8.8.8.8": FakeIpInfo("California", "Mountain View", "Google", "United States"),
|
||||
"1.1.1.1": FakeIpInfo("Queensland", "Brisbane", "Cloudflare", "Australia"),
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
permission_monitoring,
|
||||
"resolve_ip_location",
|
||||
lambda ip: ip_info_by_address[ip],
|
||||
)
|
||||
|
||||
result = await permission_monitoring.get_ip_locations(db=db_session, _=AdminUserStub(), days=7, limit=10)
|
||||
locations = {item["country"]: item for item in result["items"]}
|
||||
|
||||
assert result["summary"]["total_count"] == 3
|
||||
assert result["summary"]["denied_count"] == 2
|
||||
assert result["summary"]["unique_ip_count"] == 3
|
||||
assert result["summary"]["unique_user_count"] == 1
|
||||
assert locations["United States"]["total_count"] == 1
|
||||
assert locations["United States"]["denied_count"] == 1
|
||||
assert locations["Australia"]["total_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_access_logs_include_user_behavior_summary(db_session):
|
||||
"""访问日志应返回用户行为审计汇总和用户排行。"""
|
||||
@@ -606,10 +749,121 @@ async def test_access_logs_include_user_behavior_summary(db_session):
|
||||
assert user_a_ips == {"10.1.1.1", "10.1.1.2"}
|
||||
|
||||
|
||||
def test_access_logs_user_stats_query_avoids_postgresql_uuid_max():
|
||||
"""访问日志用户统计不能对 UUID 字段使用 max,PostgreSQL 不支持 max(uuid)。"""
|
||||
source = inspect.getsource(permission_monitoring.get_access_logs)
|
||||
|
||||
assert "func.max(PermissionAccessLog.user_id)" not in source
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_security_access_logs_include_anonymous_ip_attempts(db_session):
|
||||
async def test_access_logs_ip_ranking_aggregates_by_ip_total(db_session):
|
||||
"""IP 访问排行应按同一 IP 的整体访问量聚合排序,而不是按用户/IP/角色拆分。"""
|
||||
await db_session.execute(text("DELETE FROM permission_access_logs"))
|
||||
await db_session.commit()
|
||||
|
||||
study_id = "00000000-0000-0000-0000-000000001201"
|
||||
user_a = "00000000-0000-0000-0000-000000001301"
|
||||
user_b = "00000000-0000-0000-0000-000000001302"
|
||||
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO studies (id, code, name, status, is_locked, visit_schedule, active_roles)
|
||||
VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule, :active_roles)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": study_id,
|
||||
"code": "ACCESS-IP-RANK-STUDY",
|
||||
"name": "Access IP Rank Study",
|
||||
"status": "ACTIVE",
|
||||
"is_locked": False,
|
||||
"visit_schedule": "[]",
|
||||
"active_roles": "[]",
|
||||
},
|
||||
)
|
||||
for user_id, email, name in [
|
||||
(user_a, "ip-rank-a@example.com", "IP排行用户A"),
|
||||
(user_b, "ip-rank-b@example.com", "IP排行用户B"),
|
||||
]:
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status)
|
||||
VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": user_id,
|
||||
"email": email,
|
||||
"password_hash": "hash",
|
||||
"full_name": name,
|
||||
"clinical_department": "临床运营",
|
||||
"is_admin": False,
|
||||
"status": "ACTIVE",
|
||||
},
|
||||
)
|
||||
|
||||
rows = [
|
||||
(user_a, "183.230.169.20", True),
|
||||
(user_a, "183.230.169.20", True),
|
||||
(user_b, "183.230.169.20", False),
|
||||
(user_a, "45.148.10.95", False),
|
||||
(user_b, "52.53.218.145", False),
|
||||
]
|
||||
for user, ip_address, allowed in rows:
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO permission_access_logs
|
||||
(id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, created_at)
|
||||
VALUES
|
||||
(:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, CURRENT_TIMESTAMP)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"study_id": study_id,
|
||||
"user_id": user,
|
||||
"endpoint_key": "admin.permissions.read",
|
||||
"role": "CRA",
|
||||
"allowed": allowed,
|
||||
"elapsed_ms": 4.0,
|
||||
"ip_address": ip_address,
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
result = 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,
|
||||
page=1,
|
||||
page_size=50,
|
||||
)
|
||||
|
||||
assert result["user_stats"][0]["sample_ip_address"] == "183.230.169.20"
|
||||
assert result["user_stats"][0]["total_count"] == 3
|
||||
assert result["user_stats"][0]["denied_count"] == 1
|
||||
assert {stat["sample_ip_address"] for stat in result["user_stats"][1:]} == {"52.53.218.145", "45.148.10.95"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_security_access_logs_include_anonymous_ip_attempts(db_session, monkeypatch):
|
||||
"""安全访问日志应覆盖未登录或未知账号的底层访问尝试。"""
|
||||
await db_session.execute(text("DELETE FROM security_access_logs"))
|
||||
monkeypatch.setattr(
|
||||
permission_monitoring,
|
||||
"resolve_ip_location",
|
||||
lambda ip: FakeIpInfo("上海市", "上海市", "联通"),
|
||||
)
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
@@ -636,5 +890,148 @@ async def test_security_access_logs_include_anonymous_ip_attempts(db_session):
|
||||
assert result["summary"]["anonymous_count"] == 1
|
||||
assert result["summary"]["error_count"] == 1
|
||||
assert result["items"][0]["client_ip"] == "203.0.113.10"
|
||||
assert result["items"][0]["ip_location"] == "中国 / 上海市 / 上海市 / 联通"
|
||||
assert result["items"][0]["ip_country"] == "中国"
|
||||
assert result["items"][0]["ip_province"] == "上海市"
|
||||
assert result["items"][0]["ip_city"] == "上海市"
|
||||
assert result["items"][0]["ip_isp"] == "联通"
|
||||
assert result["items"][0]["account_label"] == "未知账号"
|
||||
assert result["items"][0]["status_code"] == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_security_access_logs_classify_sensitive_path_probe(db_session):
|
||||
"""安全中心应把敏感路径探测识别为严重安全事件。"""
|
||||
await db_session.execute(text("DELETE FROM security_access_logs"))
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO security_access_logs
|
||||
(id, method, path, status_code, elapsed_ms, client_ip, user_agent, auth_status, user_identifier, created_at)
|
||||
VALUES
|
||||
('00000000-0000-4000-8000-000000000502', 'GET', '/api/.git/config', 404, 0.7,
|
||||
'198.51.100.20', 'probe-bot/1.0', 'ANONYMOUS', NULL, CURRENT_TIMESTAMP)
|
||||
"""
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
result = await permission_monitoring.get_security_access_logs(
|
||||
db=db_session,
|
||||
_=AdminUserStub(),
|
||||
status_min=400,
|
||||
auth_status=None,
|
||||
page=1,
|
||||
page_size=20,
|
||||
)
|
||||
|
||||
assert result["items"][0]["category"] == "PROBE"
|
||||
assert result["items"][0]["severity"] == "CRITICAL"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_security_access_logs_prioritize_sensitive_probe_over_foreign_ip(db_session, monkeypatch):
|
||||
"""敏感路径探测应优先于海外 IP 分类,避免降低风险等级。"""
|
||||
await db_session.execute(text("DELETE FROM security_access_logs"))
|
||||
monkeypatch.setattr(
|
||||
permission_monitoring,
|
||||
"resolve_ip_location",
|
||||
lambda ip: FakeIpInfo("South Holland", "", "", "Netherlands"),
|
||||
)
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO security_access_logs
|
||||
(id, method, path, status_code, elapsed_ms, client_ip, user_agent, auth_status, user_identifier, created_at)
|
||||
VALUES
|
||||
('00000000-0000-4000-8000-000000000505', 'GET', '/api/.git/config', 404, 0.7,
|
||||
'45.148.10.95', 'probe-bot/1.0', 'ANONYMOUS', NULL, CURRENT_TIMESTAMP)
|
||||
"""
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
result = await permission_monitoring.get_security_access_logs(
|
||||
db=db_session,
|
||||
_=AdminUserStub(),
|
||||
status_min=400,
|
||||
auth_status=None,
|
||||
page=1,
|
||||
page_size=20,
|
||||
)
|
||||
|
||||
assert result["items"][0]["category"] == "PROBE"
|
||||
assert result["items"][0]["severity"] == "CRITICAL"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_security_access_logs_classify_non_china_ip_as_abnormal(db_session, monkeypatch):
|
||||
"""安全事件明细应把非中国公网 IP 归类为异常 IP。"""
|
||||
await db_session.execute(text("DELETE FROM security_access_logs"))
|
||||
monkeypatch.setattr(
|
||||
permission_monitoring,
|
||||
"resolve_ip_location",
|
||||
lambda ip: FakeIpInfo("California", "San Jose", "Google", "United States"),
|
||||
)
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO security_access_logs
|
||||
(id, method, path, status_code, elapsed_ms, client_ip, user_agent, auth_status, user_identifier, created_at)
|
||||
VALUES
|
||||
('00000000-0000-4000-8000-000000000504', 'GET', '/api/v1/studies/', 401, 8.4,
|
||||
'52.53.218.145', 'foreign-client/1.0', 'INVALID_TOKEN', NULL, CURRENT_TIMESTAMP)
|
||||
"""
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
result = await permission_monitoring.get_security_access_logs(
|
||||
db=db_session,
|
||||
_=AdminUserStub(),
|
||||
status_min=400,
|
||||
auth_status=None,
|
||||
page=1,
|
||||
page_size=20,
|
||||
)
|
||||
|
||||
item = result["items"][0]
|
||||
assert item["category"] == "ABNORMAL_IP"
|
||||
assert item["severity"] == "HIGH"
|
||||
assert item["ip_location"] == "United States / California / San Jose / Google"
|
||||
assert item["ip_country"] == "United States"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_security_access_logs_resolve_public_ip_with_packaged_ip2region(db_session):
|
||||
"""安全中心公网 IP 应复用 ip2region 解析结果,避免前端只能显示未知位置。"""
|
||||
await db_session.execute(text("DELETE FROM security_access_logs"))
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO security_access_logs
|
||||
(id, method, path, status_code, elapsed_ms, client_ip, user_agent, auth_status, user_identifier, created_at)
|
||||
VALUES
|
||||
('00000000-0000-4000-8000-000000000503', 'GET', '/api/v1/auth/login', 404, 1.2,
|
||||
'52.53.218.145', 'probe-bot/1.0', 'ANONYMOUS', NULL, CURRENT_TIMESTAMP)
|
||||
"""
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
result = await permission_monitoring.get_security_access_logs(
|
||||
db=db_session,
|
||||
_=AdminUserStub(),
|
||||
status_min=400,
|
||||
auth_status=None,
|
||||
page=1,
|
||||
page_size=20,
|
||||
)
|
||||
|
||||
item = result["items"][0]
|
||||
assert item["client_ip"] == "52.53.218.145"
|
||||
assert item["ip_location"] not in {"", "公网", "未知"}
|
||||
assert item["ip_country"] == "United States"
|
||||
assert item["ip_province"] == "California"
|
||||
assert item["ip_city"] == "San Jose"
|
||||
assert item["category"] == "ABNORMAL_IP"
|
||||
|
||||
@@ -274,6 +274,27 @@ async def test_dev_login_allows_plaintext_only_in_development(client_and_db):
|
||||
assert resp.json()["access_token"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_avatar_upload_rejects_non_image_files(client_and_db):
|
||||
client, _ = client_and_db
|
||||
original_env = settings.ENV
|
||||
settings.ENV = "development"
|
||||
try:
|
||||
login_resp = await client.post("/api/v1/auth/dev-login", json={"email": "admin@test.com", "password": "admin123"})
|
||||
finally:
|
||||
settings.ENV = original_env
|
||||
|
||||
token = login_resp.json()["access_token"]
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/me/avatar",
|
||||
files={"file": ("avatar.txt", b"not an image", "text/plain")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["detail"] == "头像仅支持图片格式"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dev_login_is_disabled_outside_development(client_and_db):
|
||||
client, _ = client_and_db
|
||||
@@ -357,4 +378,3 @@ async def test_login_challenge_cannot_be_reused(client_and_db):
|
||||
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 401
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
"""旧合同基础信息模块移除测试"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_api_router_no_longer_registers_legacy_finance_contracts():
|
||||
router_source = (ROOT / "app" / "api" / "v1" / "router.py").read_text()
|
||||
|
||||
assert "finance_contracts" not in router_source
|
||||
assert "finance-contracts" not in router_source
|
||||
|
||||
|
||||
def test_active_backend_code_no_longer_imports_finance_contract_model():
|
||||
checked_paths = [
|
||||
ROOT / "app" / "db" / "base.py",
|
||||
ROOT / "app" / "crud" / "study.py",
|
||||
ROOT / "app" / "crud" / "site.py",
|
||||
ROOT / "app" / "crud" / "overview.py",
|
||||
]
|
||||
|
||||
for path in checked_paths:
|
||||
assert "FinanceContract" not in path.read_text()
|
||||
@@ -1,40 +0,0 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.user import User
|
||||
from app.services.site_contact_display import build_contact_display
|
||||
|
||||
|
||||
def make_user(user_id: uuid.UUID, full_name: str, email: str) -> User:
|
||||
return User(
|
||||
id=user_id,
|
||||
email=email,
|
||||
password_hash="hash",
|
||||
full_name=full_name,
|
||||
clinical_department="PMO",
|
||||
)
|
||||
|
||||
|
||||
def test_build_contact_display_resolves_comma_separated_user_ids():
|
||||
first_id = uuid.uuid4()
|
||||
second_id = uuid.uuid4()
|
||||
users = {
|
||||
first_id: make_user(first_id, "张三", "zhangsan@example.com"),
|
||||
second_id: make_user(second_id, "李四", "lisi@example.com"),
|
||||
}
|
||||
|
||||
display = build_contact_display(f"{first_id}, {second_id}", users)
|
||||
|
||||
assert display == "张三、李四"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["PM", "CRA", "PV", "QA", "CTA"])
|
||||
def test_build_contact_display_is_role_independent_for_site_read_roles(role: str):
|
||||
user_id = uuid.uuid4()
|
||||
users = {user_id: make_user(user_id, "周成", "zhoucheng@example.com")}
|
||||
|
||||
display = build_contact_display(str(user_id), users)
|
||||
|
||||
assert role
|
||||
assert display == "周成"
|
||||
@@ -1,54 +0,0 @@
|
||||
from datetime import date, datetime
|
||||
from types import SimpleNamespace
|
||||
import uuid
|
||||
|
||||
from app.crud.subject import _validate_actual_medication_count
|
||||
from app.schemas.subject import SubjectRead, SubjectUpdate
|
||||
|
||||
|
||||
def test_subject_update_accepts_actual_medication_count():
|
||||
payload = SubjectUpdate(actual_medication_count=12)
|
||||
|
||||
assert payload.actual_medication_count == 12
|
||||
|
||||
|
||||
def test_subject_update_accepts_screening_date():
|
||||
payload = SubjectUpdate(screening_date=date(2026, 5, 25))
|
||||
|
||||
assert payload.screening_date == date(2026, 5, 25)
|
||||
|
||||
|
||||
def test_subject_update_does_not_expose_status_input():
|
||||
assert "status" not in SubjectUpdate.model_fields
|
||||
|
||||
|
||||
def test_subject_read_includes_actual_medication_count():
|
||||
subject = SimpleNamespace(
|
||||
id=uuid.UUID("00000000-0000-0000-0000-000000000001"),
|
||||
study_id=uuid.UUID("00000000-0000-0000-0000-000000000002"),
|
||||
site_id=uuid.UUID("00000000-0000-0000-0000-000000000003"),
|
||||
subject_no="S001",
|
||||
status="ENROLLED",
|
||||
screening_date=None,
|
||||
consent_date=None,
|
||||
enrollment_date=None,
|
||||
baseline_date=None,
|
||||
completion_date=None,
|
||||
actual_medication_count=10,
|
||||
drop_reason=None,
|
||||
created_at=datetime(2026, 5, 9, 0, 0, 0),
|
||||
updated_at=datetime(2026, 5, 9, 0, 0, 0),
|
||||
)
|
||||
|
||||
data = SubjectRead.model_validate(subject)
|
||||
|
||||
assert data.actual_medication_count == 10
|
||||
|
||||
|
||||
def test_actual_medication_count_cannot_be_negative():
|
||||
try:
|
||||
_validate_actual_medication_count(-1)
|
||||
except ValueError as exc:
|
||||
assert "实际用药次数不能小于0" in str(exc)
|
||||
else:
|
||||
raise AssertionError("negative actual medication count should be rejected")
|
||||
+23
-3
@@ -19,10 +19,28 @@ services:
|
||||
container_name: ctms_frontend_dev
|
||||
restart: always
|
||||
working_dir: /app
|
||||
command: sh -c "npm ci && npm run dev -- --host 0.0.0.0"
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -e
|
||||
lock_hash="$$(sha256sum package-lock.json | awk '{print $$1}')"
|
||||
marker="node_modules/.ctms-package-lock.sha256"
|
||||
if [ ! -f "$$marker" ] || [ "$$(cat "$$marker")" != "$$lock_hash" ]; then
|
||||
npm ci
|
||||
printf '%s' "$$lock_hash" > "$$marker"
|
||||
fi
|
||||
npm run dev -- --host 0.0.0.0
|
||||
environment:
|
||||
NPM_CONFIG_REGISTRY: ${NPM_CONFIG_REGISTRY:-https://registry.npmmirror.com}
|
||||
VITE_RUNTIME_ENV: ${ENV:-development}
|
||||
VITE_ALLOW_INSECURE_DEV_LOGIN: ${VITE_ALLOW_INSECURE_DEV_LOGIN:-true}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:5173/"]
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 90
|
||||
start_period: 5s
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- frontend_node_modules:/app/node_modules
|
||||
@@ -42,8 +60,10 @@ services:
|
||||
- ./nginx/certs:/etc/nginx/certs:ro
|
||||
- ./frontend/public/favicon.ico:/usr/share/nginx/html/favicon.ico:ro
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend-dev
|
||||
backend:
|
||||
condition: service_started
|
||||
frontend-dev:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- ctms_net
|
||||
|
||||
|
||||
+5
-4
@@ -1,6 +1,7 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
pull_policy: if_not_present
|
||||
container_name: ctms_db
|
||||
restart: always
|
||||
environment:
|
||||
@@ -10,8 +11,6 @@ services:
|
||||
volumes:
|
||||
# 持久化数据到 Linux 本地,防止重启丢失
|
||||
- ./pg_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ctms_user -d ctms_db"]
|
||||
interval: 5s
|
||||
@@ -24,10 +23,9 @@ services:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
pull: false
|
||||
container_name: ctms_backend
|
||||
restart: always
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://ctms_user:secret_password@db/ctms_db
|
||||
ENV: ${ENV:-development}
|
||||
@@ -44,6 +42,7 @@ services:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
pull: false
|
||||
command: python scripts/init_production.py
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://ctms_user:secret_password@db/ctms_db
|
||||
@@ -62,7 +61,9 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: nginx/Dockerfile
|
||||
pull: false
|
||||
args:
|
||||
NPM_CONFIG_REGISTRY: ${NPM_CONFIG_REGISTRY:-https://registry.npmmirror.com}
|
||||
VITE_RUNTIME_ENV: ${ENV:-production}
|
||||
VITE_ALLOW_INSECURE_DEV_LOGIN: ${VITE_ALLOW_INSECURE_DEV_LOGIN:-false}
|
||||
container_name: ctms_nginx
|
||||
|
||||
@@ -94,18 +94,44 @@ grep -n "^\\.env\\.\\*$" .gitignore
|
||||
|
||||
## 推荐脚本安装
|
||||
|
||||
安装脚本入口:
|
||||
推荐使用根目录交互入口:
|
||||
|
||||
```bash
|
||||
bash scripts/install-ctms.sh dev|main|release [选项]
|
||||
./install.sh
|
||||
```
|
||||
|
||||
执行后通过键盘 `↑/↓` 选择安装、更新、卸载、资源状态等操作;`install.sh` 不接受命令行参数。菜单顶部会常驻显示当前 CTMS 容器部署状态,包括已检测到的环境、Compose 项目和容器运行数量。
|
||||
|
||||
底层脚本入口(适合自动化或排障时直接调用):
|
||||
|
||||
```text
|
||||
scripts/install.sh 安装:依赖检查、配置准备、构建启动、迁移、健康检查
|
||||
scripts/update.sh 更新:复用安装流程,不执行 git pull
|
||||
scripts/uninstall.sh 卸载:默认仅停止并移除容器,不删除数据
|
||||
scripts/status.sh 状态:使用 docker compose stats --no-stream 采集资源快照并美化展示
|
||||
scripts/common.sh 公共函数:环境映射、输出样式、危险操作确认
|
||||
```
|
||||
|
||||
安装脚本直接调用格式:
|
||||
|
||||
```bash
|
||||
bash scripts/install.sh dev|main|release [选项]
|
||||
```
|
||||
|
||||
更新、卸载、状态脚本直接调用格式:
|
||||
|
||||
```bash
|
||||
bash scripts/update.sh dev|main|release [安装脚本选项]
|
||||
bash scripts/uninstall.sh dev|main|release
|
||||
bash scripts/status.sh dev|main|release
|
||||
```
|
||||
|
||||
常用示例:
|
||||
|
||||
```bash
|
||||
bash scripts/install-ctms.sh dev
|
||||
bash scripts/install-ctms.sh main --base-url http://127.0.0.1:8888
|
||||
bash scripts/install-ctms.sh release --base-url https://ctms.example.com
|
||||
bash scripts/install.sh dev
|
||||
bash scripts/install.sh main --base-url http://127.0.0.1:8888
|
||||
bash scripts/install.sh release --base-url https://ctms.example.com
|
||||
```
|
||||
|
||||
可选参数:
|
||||
@@ -143,7 +169,7 @@ docker compose down -v
|
||||
如果只想查看参数说明:
|
||||
|
||||
```bash
|
||||
bash scripts/install-ctms.sh --help
|
||||
bash scripts/install.sh --help
|
||||
```
|
||||
|
||||
## 手工安装/排障:dev 分支
|
||||
|
||||
@@ -84,71 +84,6 @@ async def test_admin_always_allowed(db_session, study_id):
|
||||
assert result is True
|
||||
```
|
||||
|
||||
### 2. 权限配置测试
|
||||
|
||||
**文件**: `backend/tests/test_api_permissions_config.py`
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, MODULE_TO_ENDPOINTS
|
||||
|
||||
def test_api_endpoint_permissions_structure():
|
||||
"""测试API端点权限配置结构"""
|
||||
for endpoint_key, config in API_ENDPOINT_PERMISSIONS.items():
|
||||
assert "module" in config
|
||||
assert "action" in config
|
||||
assert "description" in config
|
||||
assert "default_roles" in config
|
||||
assert config["action"] in ["read", "write"]
|
||||
assert isinstance(config["default_roles"], list)
|
||||
|
||||
def test_module_to_endpoints_mapping():
|
||||
"""测试模块到端点的映射"""
|
||||
for module, actions in MODULE_TO_ENDPOINTS.items():
|
||||
assert "read" in actions
|
||||
assert "write" in actions
|
||||
assert isinstance(actions["read"], list)
|
||||
assert isinstance(actions["write"], list)
|
||||
|
||||
# 验证所有端点都在API_ENDPOINT_PERMISSIONS中定义
|
||||
for endpoint_key in actions["read"] + actions["write"]:
|
||||
assert endpoint_key in API_ENDPOINT_PERMISSIONS
|
||||
|
||||
def test_subjects_endpoints_configured():
|
||||
"""测试subjects模块的端点配置"""
|
||||
expected_endpoints = [
|
||||
"POST:/subjects",
|
||||
"GET:/subjects",
|
||||
"GET:/subjects/{id}",
|
||||
"PATCH:/subjects/{id}",
|
||||
"DELETE:/subjects/{id}",
|
||||
]
|
||||
for endpoint_key in expected_endpoints:
|
||||
assert endpoint_key in API_ENDPOINT_PERMISSIONS
|
||||
assert API_ENDPOINT_PERMISSIONS[endpoint_key]["module"] == "subjects"
|
||||
|
||||
def test_fees_endpoints_configured():
|
||||
"""测试fees模块的端点配置"""
|
||||
expected_endpoints = [
|
||||
"POST:/fees/contracts",
|
||||
"GET:/fees/contracts",
|
||||
"GET:/fees/contracts/{id}",
|
||||
"PATCH:/fees/contracts/{id}",
|
||||
"DELETE:/fees/contracts/{id}",
|
||||
"POST:/fees/contracts/{id}/payments",
|
||||
"PATCH:/fees/payments/{id}",
|
||||
"DELETE:/fees/payments/{id}",
|
||||
"POST:/finance/contracts",
|
||||
"GET:/finance/contracts",
|
||||
"GET:/finance/contracts/{id}",
|
||||
"PATCH:/finance/contracts/{id}",
|
||||
"DELETE:/finance/contracts/{id}",
|
||||
]
|
||||
for endpoint_key in expected_endpoints:
|
||||
assert endpoint_key in API_ENDPOINT_PERMISSIONS
|
||||
assert API_ENDPOINT_PERMISSIONS[endpoint_key]["module"] == "fees"
|
||||
```
|
||||
|
||||
## 集成测试
|
||||
|
||||
### 1. 权限管理API测试
|
||||
|
||||
@@ -37,7 +37,6 @@ Run: `npm run test:unit -- src/views/Login.test.ts`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/Register.vue`
|
||||
- Test: `frontend/src/views/Register.test.ts`
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
@@ -45,7 +44,7 @@ Run: `npm run test:unit -- src/views/Login.test.ts`
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test:unit -- src/views/Register.test.ts`
|
||||
Run: `npm run test:unit`
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
@@ -53,12 +52,12 @@ Run: `npm run test:unit -- src/views/Register.test.ts`
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test:unit -- src/views/Register.test.ts`
|
||||
Run: `npm run test:unit`
|
||||
|
||||
### Task 3: Final verification
|
||||
|
||||
Run:
|
||||
- `npm run test:unit -- src/views/Login.test.ts src/views/Register.test.ts`
|
||||
- `npm run test:unit -- src/views/Login.test.ts`
|
||||
- `npm run type-check`
|
||||
|
||||
不执行 git commit,遵循用户指令。
|
||||
|
||||
@@ -255,7 +255,6 @@ Expected: pass.
|
||||
|
||||
- Replace: `frontend/src/views/ia/EtmfPlaceholder.vue`
|
||||
- Modify: `frontend/src/locales/zh-CN.ts`
|
||||
- Test: `frontend/src/views/ia/EtmfPlaceholder.test.ts`
|
||||
|
||||
**Step 1: Write failing page test**
|
||||
|
||||
@@ -297,7 +296,7 @@ Run:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm test -- src/views/ia/EtmfPlaceholder.test.ts
|
||||
npm test -- src/api/etmf.test.ts
|
||||
```
|
||||
|
||||
Expected: pass.
|
||||
@@ -354,7 +353,7 @@ Expected: pass.
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm test -- src/api/etmf.test.ts src/views/ia/EtmfPlaceholder.test.ts src/utils/projectRoutePermissions.test.ts
|
||||
npm test -- src/api/etmf.test.ts src/utils/projectRoutePermissions.test.ts
|
||||
```
|
||||
|
||||
Expected: pass.
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
- Modify: `frontend/src/components/QuickActions.vue`
|
||||
- Modify: `frontend/src/views/ia/SubjectManagement.vue`
|
||||
- Modify: `frontend/src/utils/permission.ts`
|
||||
- Test: `frontend/src/components/QuickActions.test.ts`
|
||||
- Test: `frontend/src/views/ia/SubjectManagement.test.ts`
|
||||
- Test: `frontend/src/utils/permission.test.ts`
|
||||
|
||||
|
||||
@@ -13,9 +13,7 @@
|
||||
### Task 1: Route And Navigation Tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/detailNavigation.test.ts`
|
||||
- Modify: `frontend/src/views/detailBreadcrumbContext.test.ts`
|
||||
- Modify: `frontend/src/views/ia/MaterialEquipment.test.ts`
|
||||
- Modify: `frontend/src/router.test.ts`
|
||||
|
||||
**Steps:**
|
||||
1. Add the future equipment detail view to detail-navigation expectations.
|
||||
|
||||
@@ -470,13 +470,7 @@ curl -H "Authorization: Bearer $TOKEN" \
|
||||
**测试**:
|
||||
- `tests/test_api_permissions.py` - 权限检查测试
|
||||
- `tests/test_api_permissions_endpoints.py` - 权限管理 API 测试
|
||||
- `tests/test_api_permissions_config.py` - 权限配置测试
|
||||
- `tests/test_migrated_endpoints.py` - 已迁移端点测试(第1批)
|
||||
- `tests/test_migrated_endpoints_batch2.py` - 已迁移端点测试(第2批)
|
||||
- `tests/test_migrated_endpoints_batch3.py` - 已迁移端点测试(第3批)
|
||||
- `tests/test_permission_performance.py` - 性能测试
|
||||
- `tests/test_permission_security.py` - 安全测试
|
||||
- `tests/test_permission_cache.py` - 缓存测试
|
||||
- `tests/test_permission_monitoring.py` - 监控测试
|
||||
- `tests/test_permission_monitoring_api.py` - 监控 API 测试
|
||||
|
||||
|
||||
+11
-1
@@ -2,8 +2,18 @@ FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/
|
||||
ENV NPM_CONFIG_REGISTRY=$NPM_CONFIG_REGISTRY
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci \
|
||||
--prefer-offline \
|
||||
--no-audit \
|
||||
--fetch-retries=5 \
|
||||
--fetch-retry-mintimeout=20000 \
|
||||
--fetch-retry-maxtimeout=120000 \
|
||||
--replace-registry-host=npmjs
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const apiGet = vi.fn();
|
||||
const apiPost = vi.fn();
|
||||
const apiDelete = vi.fn();
|
||||
|
||||
vi.mock("./axios", () => ({
|
||||
apiGet,
|
||||
apiPost,
|
||||
apiDelete,
|
||||
}));
|
||||
|
||||
describe("attachments api", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uses canonical collection URLs with trailing slash", async () => {
|
||||
const { fetchAttachments, uploadAttachment } = await import("./attachments");
|
||||
const file = new File(["x"], "x.txt", { type: "text/plain" });
|
||||
|
||||
fetchAttachments("study-1", "startup_feasibility", "entity-1");
|
||||
uploadAttachment("study-1", "startup_feasibility", "entity-1", file);
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith("/api/v1/studies/study-1/startup_feasibility/entity-1/attachments/");
|
||||
expect(apiPost).toHaveBeenCalledWith(
|
||||
"/api/v1/studies/study-1/startup_feasibility/entity-1/attachments/",
|
||||
expect.any(FormData),
|
||||
expect.objectContaining({ headers: { "Content-Type": "multipart/form-data" } })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { AxiosAdapter, AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const errorMessage = vi.fn();
|
||||
|
||||
vi.mock("element-plus", () => ({
|
||||
ElMessage: {
|
||||
error: errorMessage,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../router", () => ({
|
||||
default: {
|
||||
push: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../utils/auth", () => ({
|
||||
getToken: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../store/study", () => ({
|
||||
useStudyStore: vi.fn(() => ({
|
||||
clearCurrentStudy: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../session/sessionManager", () => ({
|
||||
extendAccessToken: vi.fn(),
|
||||
forceLogout: vi.fn(),
|
||||
LOGOUT_REASON_AUTH_EXPIRED: "auth-expired",
|
||||
}));
|
||||
|
||||
describe("api axios retry", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
errorMessage.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("retries network failures 10 times at 30 second intervals before showing one error", async () => {
|
||||
const api = (await import("./axios")).default;
|
||||
const adapter = vi.fn<AxiosAdapter>(async (config) => {
|
||||
const error = new Error("Network Error") as AxiosError;
|
||||
error.config = config as InternalAxiosRequestConfig;
|
||||
error.isAxiosError = true;
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
const request = api.get("/api/v1/projects", { adapter }).catch((error) => error);
|
||||
await vi.dynamicImportSettled();
|
||||
|
||||
expect(adapter).toHaveBeenCalledTimes(1);
|
||||
expect(errorMessage).not.toHaveBeenCalled();
|
||||
|
||||
for (let retry = 1; retry < 10; retry += 1) {
|
||||
await vi.advanceTimersByTimeAsync(30000);
|
||||
expect(adapter).toHaveBeenCalledTimes(retry + 1);
|
||||
expect(errorMessage).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30000);
|
||||
expect(adapter).toHaveBeenCalledTimes(11);
|
||||
await expect(request).resolves.toMatchObject({ message: "Network Error" });
|
||||
expect(errorMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+37
-12
@@ -1,20 +1,41 @@
|
||||
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from "axios";
|
||||
import { ElMessage } from "element-plus";
|
||||
import router from "../router";
|
||||
import { getToken } from "../utils/auth";
|
||||
import type { ApiError } from "../types/api";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { TEXT } from "../locales";
|
||||
import { extendAccessToken, forceLogout, LOGOUT_REASON_AUTH_EXPIRED } from "../session/sessionManager";
|
||||
|
||||
const instance: AxiosInstance = axios.create({
|
||||
baseURL: "/",
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
const NETWORK_RETRY_LIMIT = 10;
|
||||
const NETWORK_RETRY_DELAY_MS = 30000;
|
||||
|
||||
export type ApiRequestConfig = AxiosRequestConfig & {
|
||||
suppressErrorMessage?: boolean;
|
||||
_retry?: boolean;
|
||||
_networkRetryCount?: number;
|
||||
};
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
|
||||
const clearInvalidStudyAndNavigate = async () => {
|
||||
try {
|
||||
const [{ useStudyStore }, { default: router }] = await Promise.all([
|
||||
import("../store/study"),
|
||||
import("../router"),
|
||||
]);
|
||||
useStudyStore().clearCurrentStudy();
|
||||
router.push("/admin/projects");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const forceAuthExpiredLogout = async () => {
|
||||
const { forceLogout, LOGOUT_REASON_AUTH_EXPIRED } = await import("../session/sessionManager");
|
||||
forceLogout(LOGOUT_REASON_AUTH_EXPIRED);
|
||||
};
|
||||
|
||||
instance.interceptors.request.use((config: InternalAxiosRequestConfig & ApiRequestConfig) => {
|
||||
@@ -40,19 +61,23 @@ instance.interceptors.response.use(
|
||||
// 认证相关的错误由具体页面自行处理,避免重复提示
|
||||
return Promise.reject(error);
|
||||
}
|
||||
if (!status && error.config) {
|
||||
const config = error.config as ApiRequestConfig;
|
||||
const retryCount = config._networkRetryCount || 0;
|
||||
if (retryCount < NETWORK_RETRY_LIMIT) {
|
||||
config._networkRetryCount = retryCount + 1;
|
||||
await wait(NETWORK_RETRY_DELAY_MS);
|
||||
return instance(config);
|
||||
}
|
||||
}
|
||||
// 如果当前项目不存在或已失效,清理项目并跳到项目管理页
|
||||
if (status === 404 && typeof data?.message === "string" && data.message.toLowerCase().includes("study")) {
|
||||
try {
|
||||
const studyStore = useStudyStore();
|
||||
studyStore.clearCurrentStudy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
router.push("/admin/projects");
|
||||
await clearInvalidStudyAndNavigate();
|
||||
}
|
||||
if (status === 401) {
|
||||
const config = error.config as ApiRequestConfig;
|
||||
if (config && !config._retry) {
|
||||
const { extendAccessToken } = await import("../session/sessionManager");
|
||||
const result = await extendAccessToken("response-401");
|
||||
if (result.token) {
|
||||
config._retry = true;
|
||||
@@ -61,11 +86,11 @@ instance.interceptors.response.use(
|
||||
return instance(config);
|
||||
}
|
||||
if (result.authFailed) {
|
||||
forceLogout(LOGOUT_REASON_AUTH_EXPIRED);
|
||||
await forceAuthExpiredLogout();
|
||||
}
|
||||
}
|
||||
} else if (status === 403 && (data as any)?.detail === "账号已停用") {
|
||||
forceLogout(LOGOUT_REASON_AUTH_EXPIRED);
|
||||
await forceAuthExpiredLogout();
|
||||
} else {
|
||||
const suppressErrorMessage = (error.config as ApiRequestConfig | undefined)?.suppressErrorMessage;
|
||||
if (!suppressErrorMessage) {
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const apiPost = vi.fn();
|
||||
const apiPatch = vi.fn();
|
||||
const apiDelete = vi.fn();
|
||||
const apiGet = vi.fn();
|
||||
|
||||
vi.mock("./axios", () => ({
|
||||
apiGet,
|
||||
apiPost,
|
||||
apiPatch,
|
||||
apiDelete,
|
||||
}));
|
||||
|
||||
describe("faqs category api", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("passes study_id as a query parameter for category write permission checks", async () => {
|
||||
const { createFaqCategory, updateFaqCategory, deleteFaqCategory } = await import("./faqs");
|
||||
const payload = { study_id: "study-1", name: "用药咨询" };
|
||||
|
||||
createFaqCategory(payload);
|
||||
updateFaqCategory("category-1", payload);
|
||||
deleteFaqCategory("category-1", "study-1");
|
||||
|
||||
expect(apiPost).toHaveBeenCalledWith("/api/v1/faqs/categories/", payload, { params: { study_id: "study-1" } });
|
||||
expect(apiPatch).toHaveBeenCalledWith("/api/v1/faqs/categories/category-1", payload, {
|
||||
params: { study_id: "study-1" },
|
||||
});
|
||||
expect(apiDelete).toHaveBeenCalledWith("/api/v1/faqs/categories/category-1", {
|
||||
params: { study_id: "study-1" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("faqs item api", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("passes study_id as a query parameter for item write permission checks", async () => {
|
||||
const { createFaqItem, updateFaqItem, deleteFaqItem } = await import("./faqs");
|
||||
const payload = { study_id: "study-1", category_id: "category-1", question: "是否需要空腹用药?" };
|
||||
|
||||
createFaqItem(payload);
|
||||
updateFaqItem("item-1", payload);
|
||||
deleteFaqItem("item-1", "study-1");
|
||||
|
||||
expect(apiPost).toHaveBeenCalledWith("/api/v1/faqs/items/", payload, { params: { study_id: "study-1" } });
|
||||
expect(apiPatch).toHaveBeenCalledWith("/api/v1/faqs/items/item-1", payload, {
|
||||
params: { study_id: "study-1" },
|
||||
});
|
||||
expect(apiDelete).toHaveBeenCalledWith("/api/v1/faqs/items/item-1", {
|
||||
params: { study_id: "study-1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("passes study_id as a query parameter for item detail permission checks", async () => {
|
||||
const { fetchFaqItem, fetchFaqReplies, createFaqReply, deleteFaqReply, setFaqBestReply, setFaqStatus } = await import("./faqs");
|
||||
|
||||
fetchFaqItem("item-1", "study-1");
|
||||
fetchFaqReplies("item-1", "study-1");
|
||||
createFaqReply("item-1", "study-1", { content: "建议复查。" });
|
||||
deleteFaqReply("item-1", "reply-1", "study-1");
|
||||
setFaqBestReply("item-1", "study-1", { best_reply_id: "reply-1" });
|
||||
setFaqStatus("item-1", "study-1", { status: "RESOLVED" });
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith("/api/v1/faqs/items/item-1", {
|
||||
params: { study_id: "study-1" },
|
||||
});
|
||||
expect(apiGet).toHaveBeenCalledWith("/api/v1/faqs/items/item-1/replies", {
|
||||
params: { study_id: "study-1" },
|
||||
});
|
||||
expect(apiPost).toHaveBeenCalledWith("/api/v1/faqs/items/item-1/replies", { content: "建议复查。" }, {
|
||||
params: { study_id: "study-1" },
|
||||
});
|
||||
expect(apiDelete).toHaveBeenCalledWith("/api/v1/faqs/items/item-1/replies/reply-1", {
|
||||
params: { study_id: "study-1" },
|
||||
});
|
||||
expect(apiPatch).toHaveBeenCalledWith("/api/v1/faqs/items/item-1/best-reply", { best_reply_id: "reply-1" }, {
|
||||
params: { study_id: "study-1" },
|
||||
});
|
||||
expect(apiPatch).toHaveBeenCalledWith("/api/v1/faqs/items/item-1/status", { status: "RESOLVED" }, {
|
||||
params: { study_id: "study-1" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./feeContracts.ts"), "utf8");
|
||||
|
||||
describe("feeContracts API naming", () => {
|
||||
it("uses study_id for contract fee project identity", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("study_id: string");
|
||||
expect(source).toContain("params: { study_id: string; center_id?: string; q?: string }");
|
||||
expect(source).not.toContain("project_id: string");
|
||||
expect(source).not.toContain("projectId");
|
||||
expect(source).not.toContain("centerId");
|
||||
});
|
||||
|
||||
it("does not expose currency in contract fee payloads because amounts are fixed to ten-thousand yuan", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).not.toContain("currency?: string");
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,7 @@ export const updateApiEndpointPermissions = (
|
||||
export const fetchApiOperations = () =>
|
||||
apiGet<{ operations: ApiOperation[] }>(`/api/v1/api-permissions/operations`);
|
||||
|
||||
// 权限系统监控API
|
||||
// 系统监测API
|
||||
export const fetchPermissionMetrics = () =>
|
||||
apiGet<PermissionMetricsResponse>(`/api/v1/permission-monitoring/metrics`);
|
||||
|
||||
@@ -55,7 +55,7 @@ export const resetPermissionMetrics = () =>
|
||||
export const clearPermissionAlerts = () =>
|
||||
apiPost<void>(`/api/v1/permission-monitoring/clear-alerts`);
|
||||
|
||||
// 权限监控 - 新增API
|
||||
// 系统监测 - 新增API
|
||||
export const fetchAccessLogs = (params: {
|
||||
study_id?: string;
|
||||
user_id?: string;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,28 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatAuditRow } from "./auditExportFormatter";
|
||||
import type { AuditEvent } from "..";
|
||||
|
||||
const baseEvent: AuditEvent = {
|
||||
eventType: "DOCUMENT_UPDATED",
|
||||
eventLabel: "更新文档",
|
||||
actorId: "operator-1",
|
||||
actorName: "张三",
|
||||
actorRole: "PM",
|
||||
actorRoleLabel: "项目经理",
|
||||
targetType: "DOCUMENT",
|
||||
targetTypeLabel: "文档",
|
||||
targetId: "doc-001",
|
||||
actionText: "更新了文档",
|
||||
result: "SUCCESS",
|
||||
resultLabel: "成功",
|
||||
timestamp: "2026-06-04T10:27:02Z",
|
||||
};
|
||||
|
||||
describe("formatAuditRow", () => {
|
||||
it("keeps target identifiers inside a complete Chinese parenthesis pair", () => {
|
||||
const row = formatAuditRow(baseEvent);
|
||||
|
||||
expect(row.description).toContain("对象:文档(doc-001)");
|
||||
expect(row.description).not.toContain("doc-001)");
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./FaqCategoryForm.vue"), "utf8");
|
||||
|
||||
describe("FaqCategoryForm drawer editor", () => {
|
||||
it("uses the shared right-side drawer pattern instead of a dialog", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("<el-drawer");
|
||||
expect(source).toContain("faq-category-editor-drawer");
|
||||
expect(source).toContain('direction="rtl"');
|
||||
expect(source).toContain("editor-header");
|
||||
expect(source).toContain("drawer-footer");
|
||||
expect(source).not.toContain("<el-dialog");
|
||||
});
|
||||
|
||||
it("labels the required name field as category name", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("TEXT.modules.knowledgeMedicalConsult.categoryName");
|
||||
expect(source).not.toContain(':label="TEXT.common.fields.name"');
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./FaqItemForm.vue"), "utf8");
|
||||
|
||||
describe("FaqItemForm drawer", () => {
|
||||
it("uses the unified drawer pattern instead of a dialog", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("<el-drawer");
|
||||
expect(source).toContain('class="faq-item-editor-drawer"');
|
||||
expect(source).toContain('class="editor-header"');
|
||||
expect(source).toContain('class="drawer-footer"');
|
||||
expect(source).not.toContain("<el-dialog");
|
||||
expect(source).not.toContain('append-to=".layout-main .content-wrapper"');
|
||||
});
|
||||
});
|
||||
+1222
-122
File diff suppressed because it is too large
Load Diff
@@ -1,55 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const readLayout = () => readFileSync(resolve(__dirname, "./Layout.vue"), "utf8");
|
||||
|
||||
describe("Layout breadcrumbs", () => {
|
||||
it("shows the file version module before document detail titles", () => {
|
||||
const source = readLayout();
|
||||
|
||||
expect(source).toContain('"/documents": { label: TEXT.menu.fileVersionManagement');
|
||||
expect(source).toContain("study.viewContext?.pageTitle");
|
||||
expect(source).toContain("items.push({ label: study.viewContext.pageTitle, path: route.path })");
|
||||
});
|
||||
|
||||
it("uses context page titles instead of generic route detail titles", () => {
|
||||
const source = readLayout();
|
||||
|
||||
expect(source).toContain("const hasContextPageTitle = Boolean(study.viewContext?.pageTitle)");
|
||||
expect(source).toContain("if (!hasContextPageTitle && title");
|
||||
});
|
||||
|
||||
it("keeps project management as the parent for admin project detail breadcrumbs", () => {
|
||||
const source = readLayout();
|
||||
|
||||
expect(source).toContain('if (route.path.startsWith("/admin/projects/"))');
|
||||
expect(source).toContain("label: TEXT.menu.projectManagement");
|
||||
expect(source).toContain('path: "/admin/projects"');
|
||||
});
|
||||
|
||||
it("uses stable breadcrumb keys when context titles change", () => {
|
||||
const source = readLayout();
|
||||
|
||||
expect(source).toContain(':key="breadcrumbKey(item, index)"');
|
||||
expect(source).not.toContain(':key="index"');
|
||||
expect(source).toContain("const breadcrumbKey = (item: any, index: number)");
|
||||
});
|
||||
|
||||
it("guards route component rendering while router resolves async views", () => {
|
||||
const source = readLayout();
|
||||
|
||||
expect(source).toContain('<component v-if="Component" :is="Component" />');
|
||||
});
|
||||
|
||||
it("confirms manual logout and records the logout reason", () => {
|
||||
const source = readLayout();
|
||||
|
||||
expect(source).toContain('command="logout" :disabled="loggingOut" divided');
|
||||
expect(source).toContain("ElMessageBox.confirm(");
|
||||
expect(source).toContain("确认退出登录");
|
||||
expect(source).toContain("退出后需要重新登录才能继续访问项目数据。");
|
||||
expect(source).toContain("forceLogout(LOGOUT_REASON_MANUAL)");
|
||||
expect(source).toContain('loggingOut ? "正在退出..." : TEXT.menu.logout');
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,16 @@ describe("PermissionAccessLogs", () => {
|
||||
expect(source).not.toContain("筛选范围内行为总量");
|
||||
});
|
||||
|
||||
it("keeps audit grid widths consistent with SecurityCenter", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("grid-template-columns: minmax(320px, 1fr) minmax(0, 1.35fr);");
|
||||
expect(source).toContain("@media (max-width: 1100px)");
|
||||
expect(source).toContain("grid-template-columns: repeat(2, minmax(0, 1fr));");
|
||||
expect(source).toContain("@media (max-width: 720px)");
|
||||
expect(source).toContain("grid-template-columns: 1fr;");
|
||||
});
|
||||
|
||||
it("removes the duplicated audit hero copy", () => {
|
||||
const source = readSource();
|
||||
|
||||
@@ -65,6 +75,7 @@ describe("PermissionAccessLogs", () => {
|
||||
expect(source).not.toContain("权限监控 / 访问日志");
|
||||
expect(source).not.toContain("用户行为审计");
|
||||
expect(source).not.toContain("按用户行为聚合访问记录,接口访问细节保留在终端式流水中");
|
||||
expect(source).not.toContain("查看详细的接口访问与安全事件记录");
|
||||
});
|
||||
|
||||
it("streams terminal logs from top to bottom and refreshes in realtime", () => {
|
||||
@@ -85,14 +96,56 @@ describe("PermissionAccessLogs", () => {
|
||||
|
||||
expect(source).toContain("ip=${ip}");
|
||||
expect(source).toContain("user.sample_ip_address || \"未知 IP\"");
|
||||
expect(source).toContain("ipRankingItems");
|
||||
expect(source).toContain("IP访问排行");
|
||||
expect(source).toContain("账号");
|
||||
expect(source).toContain("rank-location");
|
||||
expect(source).not.toContain("TOP {{ userStats.length }}");
|
||||
expect(source).not.toContain("rank-ip-line");
|
||||
expect(source).not.toContain("来源IP");
|
||||
expect(source).not.toContain("去重IP");
|
||||
});
|
||||
|
||||
it("merges abnormal security IPs into the IP ranking and limits the list to top 10", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("const IP_RANKING_LIMIT = 10;");
|
||||
expect(source).toContain("const SECURITY_RANKING_PAGE_SIZE = 200;");
|
||||
expect(source).toContain("const ipRankingItems = computed<IpRankingItem[]>(() =>");
|
||||
expect(source).toContain("securityLogs.value.forEach((event) =>");
|
||||
expect(source).toContain('const isAbnormal = event.category === "ABNORMAL_IP";');
|
||||
expect(source).toContain('ABNORMAL_IP: "异常IP"');
|
||||
expect(source).toContain(".slice(0, IP_RANKING_LIMIT)");
|
||||
expect(source).toContain("TOP {{ ipRankingItems.length }}");
|
||||
expect(source).toContain('v-for="(item, index) in ipRankingItems"');
|
||||
expect(source).toContain("异常IP</el-tag>");
|
||||
});
|
||||
|
||||
it("sorts IP ranking by overall access total before risk labels", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("existing.total += user.total_count;");
|
||||
expect(source).toContain("existing.riskCount += user.denied_count;");
|
||||
expect(source).toContain("if (b.total !== a.total) return b.total - a.total;");
|
||||
expect(source.indexOf("if (b.total !== a.total) return b.total - a.total;")).toBeLessThan(
|
||||
source.indexOf("if (b.riskCount !== a.riskCount) return b.riskCount - a.riskCount;"),
|
||||
);
|
||||
expect(source.indexOf("if (b.total !== a.total) return b.total - a.total;")).toBeLessThan(
|
||||
source.indexOf("if (b.isAbnormal !== a.isAbnormal) return Number(b.isAbnormal) - Number(a.isAbnormal);"),
|
||||
);
|
||||
});
|
||||
|
||||
it("loads all security pages for complete abnormal IP ranking data", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("const first = await fetchSecurityAccessLogs({ status_min: 400, page: 1, page_size: SECURITY_RANKING_PAGE_SIZE });");
|
||||
expect(source).toContain("const totalPages = Math.ceil(first.data.total / SECURITY_RANKING_PAGE_SIZE);");
|
||||
expect(source).toContain("for (let nextPage = 2; nextPage <= totalPages; nextPage += 1)");
|
||||
expect(source).toContain("allItems.push(...res.data.items);");
|
||||
expect(source).toContain("securityLogs.value = allItems;");
|
||||
expect(source).not.toContain("page_size: 80");
|
||||
});
|
||||
|
||||
it("renders low-level security access logs for anonymous and invalid requests", () => {
|
||||
const source = readSource();
|
||||
|
||||
@@ -111,6 +164,16 @@ describe("PermissionAccessLogs", () => {
|
||||
expect(source).not.toContain("记录匿名、无效令牌、拒绝和异常状态请求,用于排查未知 IP 攻击");
|
||||
});
|
||||
|
||||
it("shows interface audit log total instead of current page line count", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("{{ formatNumber(total) }} 条");
|
||||
expect(source).toContain("共 {{ formatNumber(total) }} 条");
|
||||
expect(source).toContain(":total=\"total\"");
|
||||
expect(source).not.toContain("{{ terminalLines.length }} 条");
|
||||
expect(source).not.toContain("最近 {{ terminalLines.length }} 条");
|
||||
});
|
||||
|
||||
it("opens both audit logs in realtime downloadable dialogs", () => {
|
||||
const source = readSource();
|
||||
|
||||
|
||||
@@ -44,21 +44,23 @@
|
||||
<div class="section-head">
|
||||
<div class="section-title-group">
|
||||
<h4>IP访问排行</h4>
|
||||
<p>按来源 IP 排序,账号作为辅助定位</p>
|
||||
</div>
|
||||
<el-tag effect="plain" size="small" type="info">TOP {{ userStats.length }}</el-tag>
|
||||
<el-tag effect="plain" size="small" type="info">TOP {{ ipRankingItems.length }}</el-tag>
|
||||
</div>
|
||||
<div v-if="userStats.length" class="user-rank-list">
|
||||
<div v-for="(user, index) in userStats" :key="`${user.sample_ip_address}-${user.user_id}-${user.role}`" class="user-rank-row">
|
||||
<div v-if="ipRankingItems.length" class="user-rank-list">
|
||||
<div v-for="(item, index) in ipRankingItems" :key="item.ip" class="user-rank-row" :class="{ 'abnormal-rank-row': item.isAbnormal }">
|
||||
<span class="rank-no" :class="{ 'rank-top': index < 3 }">{{ String(index + 1).padStart(2, "0") }}</span>
|
||||
<div class="rank-user">
|
||||
<strong>{{ user.sample_ip_address || "未知 IP" }}</strong>
|
||||
<small>{{ user.user_name }} / {{ roleLabel(user.role) }}</small>
|
||||
<span class="rank-location">{{ user.primary_location || "未知属地" }}</span>
|
||||
<strong>
|
||||
{{ item.ip }}
|
||||
<el-tag v-if="item.isAbnormal" class="rank-risk-tag" effect="plain" size="small" type="danger">异常IP</el-tag>
|
||||
</strong>
|
||||
<small>{{ item.label }}</small>
|
||||
<span class="rank-location">{{ item.location }}</span>
|
||||
</div>
|
||||
<div class="rank-count">
|
||||
<strong>{{ user.total_count }}</strong>
|
||||
<small :class="{ 'denied-highlight': user.denied_count > 0 }">{{ user.denied_count }} 拒绝</small>
|
||||
<strong>{{ item.total }}</strong>
|
||||
<small :class="{ 'denied-highlight': item.riskCount > 0 }">{{ item.riskCount }} 异常/拒绝</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,7 +71,6 @@
|
||||
<div class="section-head">
|
||||
<div class="section-title-group">
|
||||
<h4>日志审计</h4>
|
||||
<p>查看详细的接口访问与安全事件记录</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-actions">
|
||||
@@ -81,7 +82,7 @@
|
||||
<strong>接口访问审计日志</strong>
|
||||
<span>记录所有 API 接口的访问行为</span>
|
||||
</div>
|
||||
<el-tag effect="dark" size="small" round>{{ terminalLines.length }} 条</el-tag>
|
||||
<el-tag effect="dark" size="small" round>{{ formatNumber(total) }} 条</el-tag>
|
||||
</div>
|
||||
<div v-if="showSecurityLog" class="log-action-item log-action-security" @click="openSecurityLogDialog">
|
||||
<div class="log-action-icon security">
|
||||
@@ -103,7 +104,7 @@
|
||||
<div class="dialog-head">
|
||||
<strong>接口访问审计日志</strong>
|
||||
<div class="dialog-actions">
|
||||
<el-tag effect="plain" type="info">最近 {{ terminalLines.length }} 条</el-tag>
|
||||
<el-tag effect="plain" type="info">共 {{ formatNumber(total) }} 条</el-tag>
|
||||
<el-button size="small" type="primary" @click="downloadInterfaceLog">下载日志</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,6 +181,8 @@ const MetricIconSpeed = () => h("svg", { viewBox: "0 0 24 24", fill: "none", str
|
||||
]);
|
||||
|
||||
const REALTIME_POLL_INTERVAL_MS = 3000;
|
||||
const IP_RANKING_LIMIT = 10;
|
||||
const SECURITY_RANKING_PAGE_SIZE = 200;
|
||||
|
||||
const emptySummary: AccessLogsSummary = {
|
||||
total_count: 0,
|
||||
@@ -228,6 +231,26 @@ const SECURITY_AUTH_LABELS: Record<string, string> = {
|
||||
AUTHENTICATED: "已认证",
|
||||
};
|
||||
|
||||
const SECURITY_CATEGORY_LABELS: Record<string, string> = {
|
||||
ABNORMAL_IP: "异常IP",
|
||||
PROBE: "敏感路径探测",
|
||||
INVALID_TOKEN: "无效令牌",
|
||||
SERVER_ERROR: "服务异常",
|
||||
ANONYMOUS_API: "匿名 API",
|
||||
NOT_FOUND_NOISE: "普通 404",
|
||||
OTHER: "其他",
|
||||
};
|
||||
|
||||
interface IpRankingItem {
|
||||
ip: string;
|
||||
location: string;
|
||||
label: string;
|
||||
total: number;
|
||||
riskCount: number;
|
||||
isAbnormal: boolean;
|
||||
lastSeenAt: string | null;
|
||||
}
|
||||
|
||||
const formatTerminalTime = (iso: string) => {
|
||||
const date = new Date(iso);
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
@@ -239,6 +262,13 @@ const formatIpLocation = (row: AccessLogItem) => {
|
||||
return parts.length ? parts.join(" / ") : row.ip_location;
|
||||
};
|
||||
|
||||
const formatSecurityIpLocation = (row: SecurityAccessLogItem) => {
|
||||
const parts = [row.ip_country && row.ip_country !== "中国" ? row.ip_country : "", row.ip_province, row.ip_city, row.ip_isp].filter(Boolean);
|
||||
return parts.length ? parts.join(" / ") : row.ip_location;
|
||||
};
|
||||
|
||||
const categoryLabel = (category: string) => SECURITY_CATEGORY_LABELS[category] || category || "其他";
|
||||
|
||||
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
|
||||
|
||||
const metricCards = computed(() => [
|
||||
@@ -304,6 +334,77 @@ const securityTerminalLines = computed(() => {
|
||||
return lines.filter((line) => line.toLowerCase().includes(token));
|
||||
});
|
||||
|
||||
const ipRankingItems = computed<IpRankingItem[]>(() => {
|
||||
const rankingByIp = new Map<string, IpRankingItem>();
|
||||
|
||||
userStats.value.forEach((user) => {
|
||||
const ip = user.sample_ip_address || "未知 IP";
|
||||
const existing = rankingByIp.get(ip);
|
||||
const lastSeenAt =
|
||||
existing?.lastSeenAt && new Date(existing.lastSeenAt).getTime() > new Date(user.last_seen_at || 0).getTime()
|
||||
? existing.lastSeenAt
|
||||
: user.last_seen_at;
|
||||
|
||||
if (!existing) {
|
||||
rankingByIp.set(ip, {
|
||||
ip,
|
||||
location: user.primary_location || "未知属地",
|
||||
label: `${user.user_name || "未知用户"} / ${roleLabel(user.role)}`,
|
||||
total: user.total_count,
|
||||
riskCount: user.denied_count,
|
||||
isAbnormal: false,
|
||||
lastSeenAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
existing.total += user.total_count;
|
||||
existing.riskCount += user.denied_count;
|
||||
existing.lastSeenAt = lastSeenAt;
|
||||
if (existing.location === "未知属地" && user.primary_location) existing.location = user.primary_location;
|
||||
});
|
||||
|
||||
securityLogs.value.forEach((event) => {
|
||||
const ip = event.client_ip || "未知 IP";
|
||||
const existing = rankingByIp.get(ip);
|
||||
const isAbnormal = event.category === "ABNORMAL_IP";
|
||||
const location = formatSecurityIpLocation(event) || existing?.location || "未知属地";
|
||||
const lastSeenAt =
|
||||
existing?.lastSeenAt && new Date(existing.lastSeenAt).getTime() > new Date(event.created_at).getTime()
|
||||
? existing.lastSeenAt
|
||||
: event.created_at;
|
||||
|
||||
if (!existing) {
|
||||
rankingByIp.set(ip, {
|
||||
ip,
|
||||
location,
|
||||
label: isAbnormal ? categoryLabel(event.category) : `${categoryLabel(event.category)} / ${event.account_label || "未知账号"}`,
|
||||
total: 1,
|
||||
riskCount: isAbnormal || event.severity === "HIGH" || event.severity === "CRITICAL" ? 1 : 0,
|
||||
isAbnormal,
|
||||
lastSeenAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
existing.location = location;
|
||||
existing.total += 1;
|
||||
existing.riskCount += isAbnormal || event.severity === "HIGH" || event.severity === "CRITICAL" ? 1 : 0;
|
||||
existing.isAbnormal = existing.isAbnormal || isAbnormal;
|
||||
existing.lastSeenAt = lastSeenAt;
|
||||
if (isAbnormal) existing.label = categoryLabel(event.category);
|
||||
});
|
||||
|
||||
return [...rankingByIp.values()]
|
||||
.sort((a, b) => {
|
||||
if (b.total !== a.total) return b.total - a.total;
|
||||
if (b.riskCount !== a.riskCount) return b.riskCount - a.riskCount;
|
||||
if (b.isAbnormal !== a.isAbnormal) return Number(b.isAbnormal) - Number(a.isAbnormal);
|
||||
return new Date(b.lastSeenAt || 0).getTime() - new Date(a.lastSeenAt || 0).getTime();
|
||||
})
|
||||
.slice(0, IP_RANKING_LIMIT);
|
||||
});
|
||||
|
||||
const onFilterChange = () => {
|
||||
page.value = 1;
|
||||
loadData();
|
||||
@@ -363,9 +464,15 @@ const loadSecurityData = async (options: { silent?: boolean } = {}) => {
|
||||
if (!showSecurityLog.value) return;
|
||||
if (!options.silent) securityLoading.value = true;
|
||||
try {
|
||||
const res = await fetchSecurityAccessLogs({ status_min: 400, page: 1, page_size: 80 });
|
||||
securityLogs.value = res.data.items;
|
||||
securitySummary.value = res.data.summary || emptySecuritySummary;
|
||||
const first = await fetchSecurityAccessLogs({ status_min: 400, page: 1, page_size: SECURITY_RANKING_PAGE_SIZE });
|
||||
const allItems = [...first.data.items];
|
||||
const totalPages = Math.ceil(first.data.total / SECURITY_RANKING_PAGE_SIZE);
|
||||
for (let nextPage = 2; nextPage <= totalPages; nextPage += 1) {
|
||||
const res = await fetchSecurityAccessLogs({ status_min: 400, page: nextPage, page_size: SECURITY_RANKING_PAGE_SIZE });
|
||||
allItems.push(...res.data.items);
|
||||
}
|
||||
securityLogs.value = allItems;
|
||||
securitySummary.value = first.data.summary || emptySecuritySummary;
|
||||
if (securityLogDialogVisible.value) scrollSecurityTerminalToBottom();
|
||||
} catch {
|
||||
if (options.silent) return;
|
||||
@@ -551,7 +658,7 @@ defineExpose({ refresh });
|
||||
|
||||
.audit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 380px minmax(0, 1fr);
|
||||
grid-template-columns: minmax(320px, 1fr) minmax(0, 1.35fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@@ -632,6 +739,10 @@ defineExpose({ refresh });
|
||||
}
|
||||
|
||||
.rank-user strong {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
color: var(--audit-ink);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
@@ -667,6 +778,14 @@ defineExpose({ refresh });
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.abnormal-rank-row {
|
||||
background: #fff7f7;
|
||||
}
|
||||
|
||||
.rank-risk-tag {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -798,17 +917,15 @@ defineExpose({ refresh });
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.audit-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.audit-metrics,
|
||||
.audit-grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.audit-metrics {
|
||||
.audit-metrics,
|
||||
.audit-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./PermissionIpLocations.vue"), "utf8");
|
||||
const readMapSource = () => readFileSync(resolve(__dirname, "./chinaProvinceMap.ts"), "utf8");
|
||||
|
||||
describe("PermissionIpLocations", () => {
|
||||
it("renders IP location visualization without duplicate region detail table", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("fetchIpLocations");
|
||||
expect(source).toContain("unique_ip_count");
|
||||
expect(source).not.toContain("来源地域明细");
|
||||
expect(source).not.toContain("table-card");
|
||||
expect(source).not.toContain("<el-table :data=\"items\"");
|
||||
});
|
||||
|
||||
it("renders an interactive China map and primary metric cards", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("VChart");
|
||||
expect(source).toContain("chinaMapOption");
|
||||
expect(source).toContain("用户分布图");
|
||||
expect(source).toContain("访问次数");
|
||||
expect(source).toContain("访问用户数");
|
||||
expect(source).toContain("来源 IP 数");
|
||||
expect(source).toContain("summary");
|
||||
});
|
||||
|
||||
it("uses the standard geojson.cn China map data instead of generated rectangles", () => {
|
||||
const source = readSource();
|
||||
const mapSource = readMapSource();
|
||||
|
||||
expect(source).toContain("../assets/china.json");
|
||||
expect(mapSource).toContain("geojson.cn/api/china/1.6.3/china.json");
|
||||
expect(mapSource).not.toContain("ProvinceBox");
|
||||
expect(mapSource).not.toContain("provinceBoxes");
|
||||
});
|
||||
|
||||
it("keeps the map legend and disables map zoom interactions", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("visualMap:");
|
||||
expect(source).toContain("VisualMapComponent");
|
||||
expect(source).toContain("max: maxValue.value");
|
||||
expect(source).toContain("roam: false");
|
||||
expect(source).not.toContain("scaleLimit");
|
||||
});
|
||||
|
||||
it("uses narrow metric cards without helper subtitles", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("min-height: 64px");
|
||||
expect(source).toContain("padding: 10px 16px");
|
||||
expect(source).toContain("right: -46px");
|
||||
expect(source).toContain("bottom: -52px");
|
||||
expect(source).toContain("border-radius: 18px");
|
||||
expect(source).not.toContain("<small>{{ card.hint }}</small>");
|
||||
expect(source).not.toContain("hint:");
|
||||
expect(source).not.toContain("权限检查总量");
|
||||
});
|
||||
|
||||
it("removes the duplicated hero copy above the map", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("ip-locations-toolbar");
|
||||
expect(source).not.toContain("权限监控 / IP属地");
|
||||
expect(source).not.toContain("按访问日志聚合省市、用户与来源 IP");
|
||||
expect(source).not.toContain("悬停省份查看访问次数、访问用户数和来源 IP 数");
|
||||
expect(source).not.toContain("hero-desc");
|
||||
expect(source).not.toContain("eyebrow");
|
||||
});
|
||||
|
||||
it("places the period filter toolbar on the left", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain(".ip-hero");
|
||||
expect(source).toContain("justify-content: flex-start");
|
||||
expect(source).not.toContain("justify-content: flex-end");
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,7 @@
|
||||
<section class="ip-hero">
|
||||
<div class="ip-locations-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="toolbar-title">IP 属地分析</span>
|
||||
<span class="toolbar-desc">查看访问来源的地理分布</span>
|
||||
<span class="toolbar-title">访问来源分析</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-radio-group v-model="days" size="small" @change="loadData">
|
||||
@@ -13,7 +12,7 @@
|
||||
<el-radio-button :value="30">30天</el-radio-button>
|
||||
<el-radio-button :value="90">90天</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button :loading="loading" size="small" @click="loadData">
|
||||
<el-button :loading="loading" size="small" :disabled="loading" @click="loadData">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px; margin-right: 4px"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
刷新
|
||||
</el-button>
|
||||
@@ -33,23 +32,44 @@
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-loading="loading" class="distribution-card">
|
||||
<section class="distribution-card">
|
||||
<div class="map-panel">
|
||||
<div class="section-head">
|
||||
<div class="section-title-group">
|
||||
<h4>中国地图</h4>
|
||||
<p>访问来源地理分布热力图</p>
|
||||
<h4>来源分布</h4>
|
||||
</div>
|
||||
<div class="map-head-actions">
|
||||
<el-segmented v-model="mapView" :options="mapViewOptions" size="small" />
|
||||
<el-tag type="info" effect="plain" round size="small">{{ currentPeriodLabel }}</el-tag>
|
||||
<el-tooltip content="全屏预览" placement="top">
|
||||
<el-button class="map-fullscreen-button" size="small" circle aria-label="全屏预览" @click="openFullscreenPreview">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3H3v5"/><path d="M16 3h5v5"/><path d="M21 16v5h-5"/><path d="M3 16v5h5"/><path d="M3 3l6 6"/><path d="M21 3l-6 6"/><path d="M21 21l-6-6"/><path d="M3 21l6-6"/></svg>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="source-map-wrap">
|
||||
<v-chart
|
||||
ref="sourceChartRef"
|
||||
:key="mapView"
|
||||
:option="sourceMapOption"
|
||||
:init-options="chartInitOptions"
|
||||
:update-options="chartUpdateOptions"
|
||||
autoresize
|
||||
class="source-map"
|
||||
/>
|
||||
<div class="flow-legend map-legend" aria-label="访问链路图例">
|
||||
<span class="legend-item"><i class="legend-dot server"></i>服务器</span>
|
||||
<span class="legend-item"><i class="legend-dot normal"></i>正常访问点</span>
|
||||
<span class="legend-item"><i class="legend-dot abnormal"></i>异常访问点</span>
|
||||
</div>
|
||||
<el-tag type="info" effect="plain" round size="small">{{ currentPeriodLabel }}</el-tag>
|
||||
</div>
|
||||
<v-chart :option="chinaMapOption" autoresize class="china-map" />
|
||||
</div>
|
||||
|
||||
<aside class="rank-panel">
|
||||
<div class="section-head compact">
|
||||
<div class="section-title-group">
|
||||
<h4>热点属地</h4>
|
||||
<p>按访问次数排序</p>
|
||||
<h4>热点来源地</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="topRegions.length" class="region-list">
|
||||
@@ -63,23 +83,65 @@
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="fullscreenVisible"
|
||||
class="source-map-dialog"
|
||||
fullscreen
|
||||
destroy-on-close
|
||||
:show-close="false"
|
||||
:close-on-click-modal="false"
|
||||
append-to-body
|
||||
@opened="resizeFullscreenChart"
|
||||
>
|
||||
<template #header>
|
||||
<div class="fullscreen-map-header">
|
||||
<div class="fullscreen-title-group">
|
||||
<h3>来源分布全屏预览</h3>
|
||||
<span>{{ currentPeriodLabel }}</span>
|
||||
</div>
|
||||
<div class="fullscreen-actions">
|
||||
<el-segmented v-model="mapView" :options="mapViewOptions" size="small" />
|
||||
<el-button size="small" @click="fullscreenVisible = false">退出全屏</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="fullscreen-map-wrap">
|
||||
<v-chart
|
||||
ref="fullscreenChartRef"
|
||||
:key="fullscreenMapKey"
|
||||
:option="sourceMapOption"
|
||||
:init-options="fullscreenChartInitOptions"
|
||||
:update-options="chartUpdateOptions"
|
||||
autoresize
|
||||
class="fullscreen-source-map"
|
||||
/>
|
||||
<div class="flow-legend map-legend" aria-label="访问链路图例">
|
||||
<span class="legend-item"><i class="legend-dot server"></i>服务器</span>
|
||||
<span class="legend-item"><i class="legend-dot normal"></i>正常访问点</span>
|
||||
<span class="legend-item"><i class="legend-dot abnormal"></i>异常访问点</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, ref } from "vue";
|
||||
import { computed, h, nextTick, onMounted, ref } from "vue";
|
||||
import VChart from "vue-echarts";
|
||||
import { registerMap, use } from "echarts/core";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import { MapChart } from "echarts/charts";
|
||||
import { TooltipComponent, VisualMapComponent } from "echarts/components";
|
||||
import { EffectScatterChart, LinesChart } from "echarts/charts";
|
||||
import { GeoComponent, TooltipComponent } from "echarts/components";
|
||||
import chinaMapGeoJson from "../assets/china.json";
|
||||
import worldMapGeoJson from "../assets/world.json";
|
||||
import { fetchIpLocations } from "../api/projectPermissions";
|
||||
import type { IpLocationsResponse, IpLocationStatItem } from "../types/api";
|
||||
import { normalizeProvinceName } from "./chinaProvinceMap";
|
||||
import { resolveSourceCoordinate } from "./worldMapSource";
|
||||
|
||||
use([CanvasRenderer, MapChart, TooltipComponent, VisualMapComponent]);
|
||||
use([CanvasRenderer, LinesChart, EffectScatterChart, GeoComponent, TooltipComponent]);
|
||||
registerMap("ctms-china", chinaMapGeoJson as any);
|
||||
registerMap("ctms-world", worldMapGeoJson as any);
|
||||
|
||||
const IconGlobe = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("circle", { cx: "12", cy: "12", r: "10" }),
|
||||
@@ -103,9 +165,22 @@ const IconShield = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke:
|
||||
]);
|
||||
|
||||
const loading = ref(false);
|
||||
const days = ref(7);
|
||||
const days = ref(90);
|
||||
const mapView = ref<"china" | "flow">("china");
|
||||
const items = ref<IpLocationStatItem[]>([]);
|
||||
const summary = ref<IpLocationsResponse["summary"] | null>(null);
|
||||
const getDevicePixelRatio = () => (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1);
|
||||
const chartInitOptions = { devicePixelRatio: Math.min(getDevicePixelRatio(), 2) };
|
||||
const fullscreenChartInitOptions = { devicePixelRatio: Math.min(getDevicePixelRatio() * 1.5, 3) };
|
||||
const chartUpdateOptions = { notMerge: true };
|
||||
const fullscreenVisible = ref(false);
|
||||
const sourceChartRef = ref<{ resize?: () => void } | null>(null);
|
||||
const fullscreenChartRef = ref<{ resize?: () => void } | null>(null);
|
||||
|
||||
const serverLocation = {
|
||||
name: "服务器所在地",
|
||||
coord: [121.4737, 31.2304] as [number, number],
|
||||
};
|
||||
|
||||
const periodLabels: Record<number, string> = {
|
||||
1: "近 24 小时",
|
||||
@@ -115,11 +190,31 @@ const periodLabels: Record<number, string> = {
|
||||
};
|
||||
|
||||
const currentPeriodLabel = computed(() => periodLabels[days.value] || `近 ${days.value} 天`);
|
||||
const mapViewOptions = [
|
||||
{ label: "中国", value: "china" },
|
||||
{ label: "全球", value: "flow" },
|
||||
];
|
||||
const fullscreenMapKey = computed(() => `fullscreen-${mapView.value}-${fullscreenVisible.value ? "open" : "closed"}`);
|
||||
|
||||
const openFullscreenPreview = () => {
|
||||
fullscreenVisible.value = true;
|
||||
};
|
||||
|
||||
const resizeFullscreenChart = async () => {
|
||||
await nextTick();
|
||||
fullscreenChartRef.value?.resize?.();
|
||||
};
|
||||
|
||||
const resizeSourceCharts = async () => {
|
||||
await nextTick();
|
||||
sourceChartRef.value?.resize?.();
|
||||
fullscreenChartRef.value?.resize?.();
|
||||
};
|
||||
|
||||
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
|
||||
|
||||
const formatLocation = (row: IpLocationStatItem) => {
|
||||
const parts = [row.province, row.city].filter(Boolean);
|
||||
const parts = [row.country && row.country !== "中国" ? row.country : "", row.province, row.city, row.isp].filter(Boolean);
|
||||
return parts.length ? parts.join(" / ") : row.location;
|
||||
};
|
||||
|
||||
@@ -166,95 +261,203 @@ const metricCards = computed(() => [
|
||||
},
|
||||
]);
|
||||
|
||||
const provinceData = computed(() => {
|
||||
const provinceMap = new Map<string, IpLocationStatItem & { name: string }>();
|
||||
items.value.forEach((item) => {
|
||||
const name = normalizeProvinceName(item.province || item.city);
|
||||
if (!name || item.country !== "中国") return;
|
||||
const current = provinceMap.get(name);
|
||||
if (!current) {
|
||||
provinceMap.set(name, { ...item, name });
|
||||
return;
|
||||
}
|
||||
current.total_count += item.total_count;
|
||||
current.allowed_count += item.allowed_count;
|
||||
current.denied_count += item.denied_count;
|
||||
current.unique_ip_count += item.unique_ip_count;
|
||||
current.unique_user_count += item.unique_user_count || 0;
|
||||
});
|
||||
return Array.from(provinceMap.values());
|
||||
});
|
||||
const rankedSourceRows = computed(() => [...items.value].sort((a, b) => b.total_count - a.total_count).slice(0, 10));
|
||||
|
||||
const maxValue = computed(() => {
|
||||
const values = provinceData.value.map((item) => item.total_count);
|
||||
return values.length ? Math.max(...values) : 100;
|
||||
});
|
||||
const isChinaSource = (item: IpLocationStatItem) => item.location === "局域网" || item.country === "中国";
|
||||
|
||||
const chinaMapOption = computed(() => ({
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
formatter: (params: any) => {
|
||||
const data = params.data;
|
||||
if (!data) return `${params.name}<br/>暂无访问数据`;
|
||||
return [
|
||||
`${params.name}`,
|
||||
`访问次数:${formatNumber(data.value)}`,
|
||||
`访问用户数:${formatNumber(data.unique_user_count || 0)}`,
|
||||
`来源 IP 数:${formatNumber(data.unique_ip_count || 0)}`,
|
||||
`拒绝次数:${formatNumber(data.denied_count || 0)}`,
|
||||
].join("<br/>");
|
||||
const chinaFlowSourceData = computed(() => rankedSourceRows.value.filter((item) => isChinaSource(item) && resolveSourceCoordinate(item)));
|
||||
|
||||
const flowSourceData = computed(() => rankedSourceRows.value.filter((item) => resolveSourceCoordinate(item)));
|
||||
|
||||
const buildFlowMapOption = (data: IpLocationStatItem[], mapName = "ctms-world", zoom = 1.2, seriesName = "全球访问链路") => {
|
||||
const points = data
|
||||
.map((item) => ({
|
||||
...item,
|
||||
name: formatLocation(item),
|
||||
coord: resolveSourceCoordinate(item),
|
||||
abnormal: Boolean(item.country && item.country !== "中国"),
|
||||
}))
|
||||
.filter((item): item is IpLocationStatItem & { name: string; coord: [number, number]; abnormal: boolean } => Boolean(item.coord));
|
||||
|
||||
const formatLineTooltip = (params: any) => {
|
||||
const row = params.data;
|
||||
if (!row) return "";
|
||||
return [
|
||||
`${row.fromName} → ${row.toName}`,
|
||||
`访问次数:${formatNumber(row.value || 0)}`,
|
||||
row.abnormal ? "事件类型:异常 IP" : "事件类型:正常访问",
|
||||
].join("<br/>");
|
||||
};
|
||||
|
||||
const formatPointTooltip = (params: any) => {
|
||||
const row = params.data;
|
||||
if (!row) return "";
|
||||
if (row.isServer) return "中国 / 上海";
|
||||
return [
|
||||
`${row.name}`,
|
||||
`访问次数:${formatNumber(row.total_count || 0)}`,
|
||||
`访问用户数:${formatNumber(row.unique_user_count || 0)}`,
|
||||
`来源 IP 数:${formatNumber(row.unique_ip_count || 0)}`,
|
||||
`拒绝次数:${formatNumber(row.denied_count || 0)}`,
|
||||
row.abnormal ? "事件类型:异常 IP" : "事件类型:正常访问",
|
||||
].join("<br/>");
|
||||
};
|
||||
|
||||
const formatGeoTooltip = (params: any) => {
|
||||
const name = String(params?.name || "").trim();
|
||||
return name || "暂无访问数据";
|
||||
};
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
confine: true,
|
||||
formatter: formatGeoTooltip,
|
||||
},
|
||||
},
|
||||
visualMap: {
|
||||
min: 0,
|
||||
max: maxValue.value,
|
||||
left: "left",
|
||||
bottom: "20",
|
||||
text: ["高", "低"],
|
||||
inRange: {
|
||||
color: ["#edf3ff", "#a5c4fd", "#6399f7", "#3b82f6", "#1d4ed8"],
|
||||
},
|
||||
show: true,
|
||||
calculable: false,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: "用户分布图",
|
||||
type: "map",
|
||||
map: "ctms-china",
|
||||
geo: {
|
||||
map: mapName,
|
||||
roam: false,
|
||||
zoom: 1.08,
|
||||
tooltip: {
|
||||
show: true,
|
||||
formatter: formatGeoTooltip,
|
||||
},
|
||||
zoom,
|
||||
label: { show: false },
|
||||
itemStyle: {
|
||||
areaColor: "#edf3ff",
|
||||
borderColor: "#ffffff",
|
||||
areaColor: "#e6edf8",
|
||||
borderColor: "#aebed2",
|
||||
borderWidth: 1,
|
||||
shadowColor: "rgba(37, 99, 235, 0.08)",
|
||||
shadowBlur: 10,
|
||||
shadowOffsetY: 2,
|
||||
},
|
||||
emphasis: {
|
||||
label: { show: true, color: "#0f172a", fontSize: 12, fontWeight: 700 },
|
||||
itemStyle: { areaColor: "#ffb86b", shadowBlur: 12, shadowColor: "rgba(245, 158, 11, 0.28)" },
|
||||
label: { show: false },
|
||||
itemStyle: { areaColor: "#dbeafe", borderColor: "#64748b", borderWidth: 1.3 },
|
||||
},
|
||||
data: provinceData.value.map((item) => ({
|
||||
name: item.name,
|
||||
value: item.total_count,
|
||||
unique_user_count: item.unique_user_count,
|
||||
unique_ip_count: item.unique_ip_count,
|
||||
denied_count: item.denied_count,
|
||||
})),
|
||||
},
|
||||
],
|
||||
}));
|
||||
series: [
|
||||
{
|
||||
name: seriesName,
|
||||
type: "lines",
|
||||
coordinateSystem: "geo",
|
||||
zlevel: 2,
|
||||
tooltip: {
|
||||
show: true,
|
||||
formatter: formatLineTooltip,
|
||||
},
|
||||
effect: {
|
||||
show: true,
|
||||
period: 4,
|
||||
trailLength: 0.18,
|
||||
symbol: "arrow",
|
||||
symbolSize: 6,
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
opacity: 0.28,
|
||||
curveness: 0.2,
|
||||
},
|
||||
data: points
|
||||
.filter((point) => point.name !== serverLocation.name)
|
||||
.map((point) => {
|
||||
const lineWidth = point.abnormal ? 1.1 : 0.8;
|
||||
return {
|
||||
fromName: point.name,
|
||||
toName: serverLocation.name,
|
||||
value: point.total_count,
|
||||
abnormal: point.abnormal,
|
||||
coords: [point.coord, serverLocation.coord],
|
||||
lineStyle: {
|
||||
color: point.abnormal ? "#f97316" : "#2563eb",
|
||||
width: lineWidth,
|
||||
opacity: point.abnormal ? 0.58 : 0.38,
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "访问来源",
|
||||
type: "effectScatter",
|
||||
coordinateSystem: "geo",
|
||||
zlevel: 3,
|
||||
tooltip: {
|
||||
show: true,
|
||||
formatter: formatPointTooltip,
|
||||
},
|
||||
rippleEffect: {
|
||||
brushType: "stroke",
|
||||
scale: 1.7,
|
||||
},
|
||||
symbolSize: (value: number[], params: any) => {
|
||||
const isDomestic = !params?.data?.abnormal;
|
||||
const maxSize = isDomestic ? 12 : 16;
|
||||
const minSize = isDomestic ? 5 : 7;
|
||||
return Math.min(maxSize, Math.max(minSize, Math.sqrt(value[2] || 1) * 2));
|
||||
},
|
||||
itemStyle: {
|
||||
color: (params: any) => (params.data.abnormal ? "#f97316" : "#2563eb"),
|
||||
shadowBlur: 6,
|
||||
shadowColor: "rgba(37, 99, 235, 0.2)",
|
||||
},
|
||||
data: points.map((point) => ({
|
||||
name: formatLocation(point),
|
||||
value: [...point.coord, point.total_count],
|
||||
total_count: point.total_count,
|
||||
unique_user_count: point.unique_user_count,
|
||||
unique_ip_count: point.unique_ip_count,
|
||||
denied_count: point.denied_count,
|
||||
abnormal: point.abnormal,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: serverLocation.name,
|
||||
type: "effectScatter",
|
||||
coordinateSystem: "geo",
|
||||
zlevel: 4,
|
||||
tooltip: {
|
||||
show: true,
|
||||
formatter: formatPointTooltip,
|
||||
},
|
||||
rippleEffect: {
|
||||
brushType: "fill",
|
||||
scale: 1.5,
|
||||
},
|
||||
symbolSize: 9,
|
||||
itemStyle: {
|
||||
color: "#16a34a",
|
||||
shadowBlur: 6,
|
||||
shadowColor: "rgba(22, 163, 74, 0.24)",
|
||||
},
|
||||
data: [
|
||||
{
|
||||
name: serverLocation.name,
|
||||
value: [...serverLocation.coord, 1],
|
||||
isServer: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const sourceMapOption = computed(() =>
|
||||
mapView.value === "flow"
|
||||
? buildFlowMapOption(flowSourceData.value)
|
||||
: buildFlowMapOption(chinaFlowSourceData.value, "ctms-china", 1.08, "中国访问链路"),
|
||||
);
|
||||
|
||||
const visibleRankRows = computed(() =>
|
||||
mapView.value === "china" ? chinaFlowSourceData.value : mapView.value === "flow" ? flowSourceData.value : rankedSourceRows.value,
|
||||
);
|
||||
|
||||
const topRegions = computed(() =>
|
||||
[...items.value]
|
||||
.sort((a, b) => b.total_count - a.total_count)
|
||||
.slice(0, 6)
|
||||
.map((item, index) => ({
|
||||
key: `${item.country}-${item.province}-${item.city}-${item.isp}`,
|
||||
rank: String(index + 1).padStart(2, "0"),
|
||||
name: formatLocation(item),
|
||||
total_count: formatNumber(item.total_count),
|
||||
})),
|
||||
visibleRankRows.value.map((item, index) => ({
|
||||
key: `${item.country}-${item.province}-${item.city}-${item.isp}`,
|
||||
rank: String(index + 1).padStart(2, "0"),
|
||||
name: formatLocation(item),
|
||||
total_count: formatNumber(item.total_count),
|
||||
})),
|
||||
);
|
||||
|
||||
const loadData = async () => {
|
||||
@@ -271,9 +474,12 @@ const loadData = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadData);
|
||||
onMounted(async () => {
|
||||
await loadData();
|
||||
await resizeSourceCharts();
|
||||
});
|
||||
|
||||
defineExpose({ refresh: loadData });
|
||||
defineExpose({ refresh: loadData, resize: resizeSourceCharts });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -291,6 +497,8 @@ defineExpose({ refresh: loadData });
|
||||
--ip-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@@ -305,7 +513,7 @@ defineExpose({ refresh: loadData });
|
||||
.ip-locations-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -330,7 +538,9 @@ defineExpose({ refresh: loadData });
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
@@ -357,8 +567,8 @@ defineExpose({ refresh: loadData });
|
||||
.metric-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -46px;
|
||||
bottom: -52px;
|
||||
right: -34px;
|
||||
bottom: -42px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 18px;
|
||||
@@ -432,7 +642,10 @@ defineExpose({ refresh: loadData });
|
||||
.distribution-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
background: var(--ip-card);
|
||||
border: 1px solid var(--ip-border);
|
||||
border-radius: var(--ip-radius);
|
||||
@@ -443,12 +656,20 @@ defineExpose({ refresh: loadData });
|
||||
.map-panel,
|
||||
.rank-panel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px solid #f1f5f9;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #fafcff 0%, #ffffff 100%);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.map-panel,
|
||||
.rank-panel,
|
||||
.source-map-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -461,6 +682,72 @@ defineExpose({ refresh: loadData });
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.map-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
max-width: 72%;
|
||||
}
|
||||
|
||||
.map-fullscreen-button svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.flow-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
color: var(--ip-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.map-legend {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
bottom: 14px;
|
||||
z-index: 5;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.92);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.08);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.legend-dot.server {
|
||||
background: #16a34a;
|
||||
box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.12);
|
||||
}
|
||||
|
||||
.legend-dot.normal {
|
||||
background: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.legend-dot.abnormal {
|
||||
background: #f97316;
|
||||
box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.14);
|
||||
}
|
||||
|
||||
.section-title-group h4 {
|
||||
margin: 0;
|
||||
color: var(--ip-ink);
|
||||
@@ -474,9 +761,89 @@ defineExpose({ refresh: loadData });
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.china-map {
|
||||
.source-map-wrap,
|
||||
.fullscreen-map-wrap {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.source-map {
|
||||
width: 100%;
|
||||
height: 430px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.rank-panel {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.region-list {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:global(.source-map-dialog) {
|
||||
--el-dialog-padding-primary: 0;
|
||||
}
|
||||
|
||||
:global(.source-map-dialog .el-dialog__header) {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:global(.source-map-dialog .el-dialog__body) {
|
||||
height: calc(100vh - 74px);
|
||||
padding: 0 22px 22px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.fullscreen-map-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
min-height: 74px;
|
||||
padding: 16px 22px;
|
||||
border-bottom: 1px solid var(--ip-border);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.fullscreen-title-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fullscreen-title-group h3 {
|
||||
margin: 0;
|
||||
color: var(--ip-ink);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fullscreen-title-group span {
|
||||
color: var(--ip-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.fullscreen-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fullscreen-source-map {
|
||||
width: 100%;
|
||||
height: calc(100vh - 96px);
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.region-list {
|
||||
@@ -486,12 +853,15 @@ defineExpose({ refresh: loadData });
|
||||
}
|
||||
|
||||
.region-row {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr) auto;
|
||||
grid-template-columns: 36px minmax(0, 1fr) minmax(72px, max-content);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
max-width: 100%;
|
||||
padding: 12px 10px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #f1f5f9;
|
||||
@@ -515,6 +885,7 @@ defineExpose({ refresh: loadData });
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rank.top {
|
||||
@@ -523,6 +894,7 @@ defineExpose({ refresh: loadData });
|
||||
}
|
||||
|
||||
.region-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
@@ -531,9 +903,14 @@ defineExpose({ refresh: loadData });
|
||||
}
|
||||
|
||||
.region-value {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--ip-blue);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
@@ -544,20 +921,72 @@ defineExpose({ refresh: loadData });
|
||||
.distribution-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.map-panel,
|
||||
.rank-panel {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.rank-panel {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.ip-locations-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.china-map {
|
||||
height: 320px;
|
||||
.source-map {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.map-head-actions {
|
||||
justify-content: flex-start;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
:global(.source-map-dialog .el-dialog__body) {
|
||||
height: calc(100vh - 126px);
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
.fullscreen-map-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 126px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.fullscreen-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.fullscreen-source-map {
|
||||
height: calc(100vh - 144px);
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.map-legend {
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -48,6 +48,7 @@ describe("PermissionMonitoring.vue", () => {
|
||||
PermissionTrendCharts: true,
|
||||
PermissionAccessLogs: true,
|
||||
PermissionIpLocations: true,
|
||||
SecurityCenter: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -64,6 +65,7 @@ describe("PermissionMonitoring.vue", () => {
|
||||
PermissionTrendCharts: true,
|
||||
PermissionAccessLogs: true,
|
||||
PermissionIpLocations: true,
|
||||
SecurityCenter: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -77,4 +79,53 @@ describe("PermissionMonitoring.vue", () => {
|
||||
expect(source).toContain("isAdmin?: boolean");
|
||||
expect(source).toContain(':show-security-log="props.isAdmin"');
|
||||
});
|
||||
|
||||
it("uses a full-height source-analysis tab without forcing scroll on other tabs", () => {
|
||||
const source = readFileSync(resolve(__dirname, "./PermissionMonitoring.vue"), "utf8");
|
||||
|
||||
expect(source).toContain(":class=\"{ 'is-source-tab': activeTab === 'ip-locations' }\"");
|
||||
expect(source).toContain('class="ip-locations-pane"');
|
||||
expect(source).toContain(".permission-monitoring.is-source-tab .soybean-monitoring-tabs :deep(.el-tabs__content)");
|
||||
expect(source).toContain("overflow: hidden");
|
||||
expect(source).toContain(".soybean-monitoring-tabs :deep(.el-tabs__content)");
|
||||
expect(source).toContain("overflow: auto");
|
||||
});
|
||||
|
||||
it("resizes the source-analysis chart when its tab becomes active", () => {
|
||||
const source = readFileSync(resolve(__dirname, "./PermissionMonitoring.vue"), "utf8");
|
||||
|
||||
expect(source).toContain('import { ref, computed, h, nextTick, onMounted } from "vue"');
|
||||
expect(source).toContain("type IpLocationsExpose");
|
||||
expect(source).toContain("resize: () => void | Promise<void>");
|
||||
expect(source).toContain('if (tab === "ip-locations")');
|
||||
expect(source).toContain("await nextTick()");
|
||||
expect(source).toContain("await ipLocationsRef.value?.resize()");
|
||||
});
|
||||
|
||||
it("uses system monitoring tab names and operation-oriented overview copy", () => {
|
||||
const source = readFileSync(resolve(__dirname, "./PermissionMonitoring.vue"), "utf8");
|
||||
|
||||
expect(source).toContain('label="运行概览"');
|
||||
expect(source).toContain('label="安全中心"');
|
||||
expect(source).toContain('label="性能趋势"');
|
||||
expect(source).toContain('label="访问审计"');
|
||||
expect(source).toContain('label="来源分析"');
|
||||
expect(source).toContain('<SecurityCenter v-if="props.isAdmin"');
|
||||
expect(source).toContain('name="security"');
|
||||
expect(source).toContain('label: "今日监测事件"');
|
||||
expect(source).toContain('label: "每分钟事件"');
|
||||
expect(source).toContain('label: "今日通过率"');
|
||||
expect(source).toContain('label: "历史事件总数"');
|
||||
expect(source).toContain("异常拒绝排行");
|
||||
|
||||
expect(source).not.toContain('label="实时概览"');
|
||||
expect(source).not.toContain('label="趋势分析"');
|
||||
expect(source).not.toContain('label="访问日志"');
|
||||
expect(source).not.toContain('label="IP属地"');
|
||||
expect(source).not.toContain('label: "今日总检查"');
|
||||
expect(source).not.toContain('label: "每分钟请求"');
|
||||
expect(source).not.toContain('label: "今日允许率"');
|
||||
expect(source).not.toContain('label: "历史总记录"');
|
||||
expect(source).not.toContain("被拒绝最多的权限");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<template>
|
||||
<div class="permission-monitoring">
|
||||
<el-tabs v-model="activeTab" @tab-change="onTabChange">
|
||||
<el-tab-pane label="实时概览" name="overview">
|
||||
<div class="permission-monitoring" :class="{ 'is-source-tab': activeTab === 'ip-locations' }">
|
||||
<el-tabs v-model="activeTab" class="soybean-monitoring-tabs" @tab-change="onTabChange">
|
||||
<el-tab-pane label="运行概览" name="overview">
|
||||
<template #label>
|
||||
<span class="soybean-tab-label">
|
||||
<component :is="IconCheck" />
|
||||
<span>运行概览</span>
|
||||
</span>
|
||||
</template>
|
||||
<div v-if="statsSummary" class="stats-summary">
|
||||
<div v-for="card in summaryCards" :key="card.label" class="summary-card" :class="card.tone">
|
||||
<div class="summary-icon">
|
||||
@@ -83,7 +89,7 @@
|
||||
|
||||
<div v-if="topDenied && topDenied.length > 0" class="denied-card">
|
||||
<div class="card-header">
|
||||
<h3>被拒绝最多的权限</h3>
|
||||
<h3>异常拒绝排行</h3>
|
||||
<el-tag effect="plain" size="small" type="info">近 7 天</el-tag>
|
||||
</div>
|
||||
<div class="denied-list">
|
||||
@@ -121,23 +127,53 @@
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="趋势分析" name="trends">
|
||||
<el-tab-pane label="性能趋势" name="trends">
|
||||
<template #label>
|
||||
<span class="soybean-tab-label">
|
||||
<component :is="IconActivity" />
|
||||
<span>性能趋势</span>
|
||||
</span>
|
||||
</template>
|
||||
<PermissionTrendCharts ref="trendChartsRef" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="访问日志" name="logs">
|
||||
<el-tab-pane v-if="props.isAdmin" label="安全中心" name="security">
|
||||
<template #label>
|
||||
<span class="soybean-tab-label">
|
||||
<component :is="IconShield" />
|
||||
<span>安全中心</span>
|
||||
</span>
|
||||
</template>
|
||||
<SecurityCenter v-if="props.isAdmin" ref="securityCenterRef" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="访问审计" name="logs">
|
||||
<template #label>
|
||||
<span class="soybean-tab-label">
|
||||
<component :is="IconDatabase" />
|
||||
<span>访问审计</span>
|
||||
</span>
|
||||
</template>
|
||||
<PermissionAccessLogs ref="accessLogsRef" :show-security-log="props.isAdmin" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="IP属地" name="ip-locations">
|
||||
<PermissionIpLocations ref="ipLocationsRef" />
|
||||
<el-tab-pane label="来源分析" name="ip-locations">
|
||||
<template #label>
|
||||
<span class="soybean-tab-label">
|
||||
<component :is="IconPercent" />
|
||||
<span>来源分析</span>
|
||||
</span>
|
||||
</template>
|
||||
<div class="ip-locations-pane">
|
||||
<PermissionIpLocations ref="ipLocationsRef" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h, onMounted } from "vue";
|
||||
import { ref, computed, h, nextTick, onMounted } from "vue";
|
||||
import type {
|
||||
PermissionMetricsResponse,
|
||||
AlertsResponse,
|
||||
@@ -155,6 +191,7 @@ import {
|
||||
import PermissionTrendCharts from "./PermissionTrendCharts.vue";
|
||||
import PermissionAccessLogs from "./PermissionAccessLogs.vue";
|
||||
import PermissionIpLocations from "./PermissionIpLocations.vue";
|
||||
import SecurityCenter from "./SecurityCenter.vue";
|
||||
|
||||
const IconCheck = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("polyline", { points: "20 6 9 17 4 12" }),
|
||||
@@ -176,6 +213,9 @@ const IconDatabase = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke
|
||||
h("path", { d: "M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" }),
|
||||
h("path", { d: "M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" }),
|
||||
]);
|
||||
const IconShield = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("path", { d: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" }),
|
||||
]);
|
||||
|
||||
const activeTab = ref("overview");
|
||||
const props = withDefaults(defineProps<{ isAdmin?: boolean }>(), {
|
||||
@@ -189,7 +229,13 @@ const statsSummary = ref<StatsSummaryResponse | null>(null);
|
||||
|
||||
const trendChartsRef = ref<InstanceType<typeof PermissionTrendCharts>>();
|
||||
const accessLogsRef = ref<InstanceType<typeof PermissionAccessLogs>>();
|
||||
const ipLocationsRef = ref<InstanceType<typeof PermissionIpLocations>>();
|
||||
type IpLocationsExpose = {
|
||||
refresh: () => void | Promise<void>;
|
||||
resize: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
const ipLocationsRef = ref<IpLocationsExpose>();
|
||||
const securityCenterRef = ref<InstanceType<typeof SecurityCenter>>();
|
||||
|
||||
const formatTime = (timestamp: number): string => {
|
||||
return new Date(timestamp * 1000).toLocaleString("zh-CN");
|
||||
@@ -226,19 +272,19 @@ const summaryCards = computed(() => {
|
||||
if (!statsSummary.value) return [];
|
||||
return [
|
||||
{
|
||||
label: "今日总检查",
|
||||
label: "今日监测事件",
|
||||
value: statsSummary.value.today.total_checks.toLocaleString(),
|
||||
tone: "blue",
|
||||
icon: IconCheck,
|
||||
},
|
||||
{
|
||||
label: "每分钟请求",
|
||||
label: "每分钟事件",
|
||||
value: statsSummary.value.last_hour.requests_per_minute,
|
||||
tone: "cyan",
|
||||
icon: IconActivity,
|
||||
},
|
||||
{
|
||||
label: "今日允许率",
|
||||
label: "今日通过率",
|
||||
value: `${statsSummary.value.today.allow_rate}%`,
|
||||
tone: statsSummary.value.today.allow_rate >= 80 ? "green" : "warning",
|
||||
icon: IconPercent,
|
||||
@@ -250,7 +296,7 @@ const summaryCards = computed(() => {
|
||||
icon: IconClock,
|
||||
},
|
||||
{
|
||||
label: "历史总记录",
|
||||
label: "历史事件总数",
|
||||
value: statsSummary.value.total_logs.toLocaleString(),
|
||||
tone: "violet",
|
||||
icon: IconDatabase,
|
||||
@@ -273,13 +319,18 @@ const loadOverviewData = async () => {
|
||||
if (summaryRes.status === "fulfilled") statsSummary.value = summaryRes.value.data;
|
||||
};
|
||||
|
||||
const onTabChange = (tab: string) => {
|
||||
const onTabChange = async (tab: string) => {
|
||||
if (tab === "overview") loadOverviewData();
|
||||
if (tab === "ip-locations") {
|
||||
await nextTick();
|
||||
await ipLocationsRef.value?.resize();
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
if (activeTab.value === "overview") loadOverviewData();
|
||||
else if (activeTab.value === "trends") trendChartsRef.value?.refresh();
|
||||
else if (activeTab.value === "security") securityCenterRef.value?.refresh();
|
||||
else if (activeTab.value === "logs") accessLogsRef.value?.refresh();
|
||||
else if (activeTab.value === "ip-locations") ipLocationsRef.value?.refresh();
|
||||
};
|
||||
@@ -294,11 +345,144 @@ defineExpose({ refresh });
|
||||
--mon-ink: #1a2332;
|
||||
--mon-muted: #64748b;
|
||||
--mon-border: #e2e8f0;
|
||||
--mon-tab-active: #6c63ff;
|
||||
--mon-tab-active-bg: #f0edff;
|
||||
--mon-radius: 16px;
|
||||
--mon-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.soybean-monitoring-tabs {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.soybean-monitoring-tabs :deep(.el-tabs__header) {
|
||||
height: 56px;
|
||||
margin: 0 0 16px;
|
||||
padding: 0 24px;
|
||||
background: #ffffff;
|
||||
border-top: 1px solid var(--mon-border);
|
||||
border-bottom: 1px solid var(--mon-border);
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.soybean-monitoring-tabs :deep(.el-tabs__nav-wrap) {
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.soybean-monitoring-tabs :deep(.el-tabs__nav-wrap::after),
|
||||
.soybean-monitoring-tabs :deep(.el-tabs__active-bar) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.soybean-monitoring-tabs :deep(.el-tabs__nav-scroll),
|
||||
.soybean-monitoring-tabs :deep(.el-tabs__nav) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.soybean-monitoring-tabs :deep(.el-tabs__nav) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.soybean-monitoring-tabs :deep(.el-tabs__item) {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
height: 36px;
|
||||
margin: 0 4px;
|
||||
padding: 0 18px;
|
||||
border-radius: 10px;
|
||||
color: #303133;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
transition: color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
:deep(.soybean-monitoring-tabs.el-tabs--top > .el-tabs__header .el-tabs__item:nth-child(2)) {
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
:deep(.soybean-monitoring-tabs.el-tabs--top > .el-tabs__header .el-tabs__item:last-child) {
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.soybean-monitoring-tabs :deep(.el-tabs__item:not(.is-active)::after) {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: -5px;
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: #dcdfe6;
|
||||
}
|
||||
|
||||
.soybean-monitoring-tabs :deep(.el-tabs__item:last-child::after),
|
||||
.soybean-monitoring-tabs :deep(.el-tabs__item.is-active + .el-tabs__item::after) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.soybean-monitoring-tabs :deep(.el-tabs__item:hover) {
|
||||
color: var(--mon-tab-active);
|
||||
}
|
||||
|
||||
.soybean-monitoring-tabs :deep(.el-tabs__item.is-active) {
|
||||
background: var(--mon-tab-active-bg);
|
||||
color: var(--mon-tab-active);
|
||||
}
|
||||
|
||||
.soybean-monitoring-tabs :deep(.el-tabs__content) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.permission-monitoring.is-source-tab .soybean-monitoring-tabs :deep(.el-tabs__content) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.soybean-monitoring-tabs :deep(.el-tab-pane) {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.soybean-monitoring-tabs :deep(.el-tab-pane[aria-hidden="false"]) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ip-locations-pane {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.soybean-tab-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.soybean-tab-label svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.stats-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./PermissionTemplateSelector.vue"), "utf8");
|
||||
|
||||
describe("PermissionTemplateSelector", () => {
|
||||
it("renders compact role permission cards without footer placeholder space", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("角色权限概览");
|
||||
expect(source).toContain("align-items: start");
|
||||
expect(source).toContain("padding: 12px 14px");
|
||||
expect(source).not.toContain("card-footer");
|
||||
expect(source).not.toContain("点击编辑权限");
|
||||
expect(source).not.toContain("inactive-label");
|
||||
expect(source).not.toContain("edit-hint");
|
||||
});
|
||||
|
||||
it("reads current project permissions by role key, not by role label", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('v-for="template in sortedTemplates"');
|
||||
expect(source).toContain('<span class="card-name">{{ template.name }}</span>');
|
||||
expect(source).toContain("refreshKey?: number;");
|
||||
expect(source).toContain("watch(() => props.refreshKey, loadTemplates);");
|
||||
expect(source).not.toContain("ROLE_LABELS");
|
||||
expect(source).toContain("template.category && props.currentPermissions[template.category]");
|
||||
expect(source).toContain("enabledPercent(template.category)");
|
||||
expect(source).toContain("countCurrentEnabled(template.category)");
|
||||
expect(source).toContain("countCurrentDisabled(template.category)");
|
||||
expect(source).toContain("const perms = props.currentPermissions?.[role];");
|
||||
expect(source).toContain("emit('edit-role', template.category!)");
|
||||
});
|
||||
|
||||
it("sorts role cards with the shared role template order", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("useRoleTemplateMeta");
|
||||
expect(source).toContain("compareRolesByTemplateOrder");
|
||||
expect(source).toContain("const sortedTemplates = computed");
|
||||
expect(source).toContain("compareRolesByTemplateOrder(a.category, b.category)");
|
||||
});
|
||||
|
||||
it("does not keep hard-coded preset role labels or icons", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).not.toContain('QA: "QA"');
|
||||
expect(source).not.toContain('CTA: "CTA"');
|
||||
expect(source).not.toContain('QA: "✅"');
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,7 @@
|
||||
<div class="trend-charts">
|
||||
<div class="trend-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="toolbar-title">数据趋势</span>
|
||||
<span class="toolbar-desc">查看权限系统各项指标的变化趋势</span>
|
||||
<span class="toolbar-title">系统性能趋势</span>
|
||||
</div>
|
||||
<el-radio-group v-model="period" size="small" @change="loadData">
|
||||
<el-radio-button value="24h">24小时</el-radio-button>
|
||||
@@ -18,7 +17,7 @@
|
||||
<div class="chart-icon blue">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
</div>
|
||||
<h4>检查量趋势</h4>
|
||||
<h4>监测事件趋势</h4>
|
||||
</div>
|
||||
<v-chart :option="checksChartOption" autoresize class="chart" />
|
||||
</div>
|
||||
@@ -45,7 +44,7 @@
|
||||
<div class="chart-icon danger">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
|
||||
</div>
|
||||
<h4>拒绝率趋势</h4>
|
||||
<h4>异常拒绝率趋势</h4>
|
||||
</div>
|
||||
<v-chart :option="denyRateOption" autoresize class="chart" />
|
||||
</div>
|
||||
@@ -181,7 +180,7 @@ const denyRateOption = computed(() => ({
|
||||
yAxis: { type: "value", max: 100, name: "%", splitLine: { lineStyle: { color: "#f1f5f9" } }, axisLabel: { color: "#64748b" } },
|
||||
series: [
|
||||
{
|
||||
name: "拒绝率",
|
||||
name: "异常拒绝率",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
symbol: "circle",
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readQuickActionsSource = () => readFileSync(resolve(__dirname, "./QuickActions.vue"), "utf8");
|
||||
|
||||
describe("QuickActions project permissions", () => {
|
||||
it("filters quick action entries with the same route permission contract as sidebar and router", () => {
|
||||
const source = readQuickActionsSource();
|
||||
|
||||
expect(source).toContain("useAuthStore");
|
||||
expect(source).toContain("useStudyStore");
|
||||
expect(source).toContain("getProjectRoutePermission");
|
||||
expect(source).toContain("hasProjectPermission");
|
||||
expect(source).toContain("isSystemAdmin");
|
||||
expect(source).toContain("visibleActions");
|
||||
expect(source).toContain("v-for=\"item in visibleActions\"");
|
||||
expect(source).not.toContain("v-for=\"item in actions\"");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./SecurityCenter.vue"), "utf8");
|
||||
|
||||
describe("SecurityCenter", () => {
|
||||
it("renders the security dashboard in the same audit-card style as access logs", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("audit-filters");
|
||||
expect(source).toContain("audit-metrics");
|
||||
expect(source).toContain("audit-grid");
|
||||
expect(source).toContain("metric-card");
|
||||
expect(source).toContain("ranking-card");
|
||||
expect(source).not.toContain("security-summary");
|
||||
expect(source).not.toContain("security-card");
|
||||
});
|
||||
|
||||
it("places filters inside the event details section instead of the page header", () => {
|
||||
const source = readSource();
|
||||
const tableIndex = source.indexOf('<section class="security-table-wrap">');
|
||||
const filtersIndex = source.indexOf('<section class="audit-filters detail-filters">');
|
||||
|
||||
expect(tableIndex).toBeGreaterThan(-1);
|
||||
expect(filtersIndex).toBeGreaterThan(tableIndex);
|
||||
expect(source).not.toContain('<section class="audit-filters">\n <el-select');
|
||||
});
|
||||
|
||||
it("surfaces abnormal IP location ranking, endpoint type distribution and risk level distribution", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("异常排行");
|
||||
expect(source).toContain("事件分布");
|
||||
expect(source).toContain("访问的接口类型");
|
||||
expect(source).toContain("风险等级");
|
||||
expect(source).toContain("event-distribution-card");
|
||||
expect(source).toContain("distribution-section");
|
||||
expect(source).toContain(".event-distribution-card > .section-head");
|
||||
expect(source).toContain("padding-top: 24px");
|
||||
expect(source).toContain("ipRiskStats");
|
||||
expect(source).toContain("endpointTypeStats");
|
||||
expect(source).toContain("riskLevelStats");
|
||||
expect(source).toContain("resolveEndpointType");
|
||||
expect(source).toContain("formatIpLocation");
|
||||
expect(source).toContain("fallbackIpLocation");
|
||||
expect(source).toContain("row.ip_province");
|
||||
expect(source).toContain("row.ip_city");
|
||||
expect(source).toContain("row.ip_isp");
|
||||
expect(source).toContain("item.location");
|
||||
expect(source).toContain("categoryText");
|
||||
expect(source).toContain("rank-main-line");
|
||||
expect(source).toContain("rank-meta-line");
|
||||
expect(source).toContain("lastSeenAt");
|
||||
expect(source).toContain("最后访问");
|
||||
expect(source).toContain("slice(0, 10)");
|
||||
expect(source).not.toContain("公网位置待解析");
|
||||
expect(source).not.toContain("异常IP(位置)排行");
|
||||
expect(source).not.toContain("risk-level-card");
|
||||
});
|
||||
|
||||
it("keeps detailed security events searchable and inspectable", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("detailFilteredEvents");
|
||||
expect(source).toContain("paginatedEvents");
|
||||
expect(source).toContain("openDetail");
|
||||
expect(source).toContain("安全事件明细");
|
||||
expect(source).toContain('label="IP"');
|
||||
expect(source).toContain('label="位置"');
|
||||
expect(source).not.toContain('label="来源 IP"');
|
||||
expect(source).not.toContain('label="IP来源"');
|
||||
expect(source).toContain("搜索 IP、位置、账号、路径或 UA");
|
||||
expect(source).toContain("loadSecurityEvents");
|
||||
});
|
||||
|
||||
it("labels abnormal IP events and paginates details like account management", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('{ label: "异常IP", value: "ABNORMAL_IP" }');
|
||||
expect(source).toContain('v-model:current-page="page"');
|
||||
expect(source).toContain('v-model:page-size="pageSize"');
|
||||
expect(source).toContain(':page-sizes="[10, 20, 50]"');
|
||||
expect(source).toContain('layout="prev, pager, next, sizes, total"');
|
||||
});
|
||||
|
||||
it("calculates dashboard statistics from all events instead of detail filters or current page", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("statsEvents");
|
||||
expect(source).toContain("detailFilteredEvents");
|
||||
expect(source).toContain("loadSecurityStats");
|
||||
expect(source).toContain("SECURITY_STATS_PAGE_SIZE");
|
||||
expect(source).toContain("statsEvents.value");
|
||||
expect(source).not.toContain("const uniqueIpCount = new Set(events.value");
|
||||
expect(source).not.toContain("filteredStatsEvents");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,971 @@
|
||||
<template>
|
||||
<div class="security-center">
|
||||
<section class="audit-metrics">
|
||||
<article v-for="card in metricCards" :key="card.label" class="metric-card" :class="card.tone">
|
||||
<div class="metric-icon">
|
||||
<component :is="card.icon" />
|
||||
</div>
|
||||
<div class="metric-body">
|
||||
<span class="metric-label">{{ card.label }}</span>
|
||||
<strong class="metric-value">{{ card.value }}</strong>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="audit-grid">
|
||||
<article class="ranking-card">
|
||||
<div class="section-head">
|
||||
<div class="section-title-group">
|
||||
<h4>异常排行</h4>
|
||||
</div>
|
||||
<el-tag effect="plain" size="small" type="info">TOP {{ ipRiskStats.length }}</el-tag>
|
||||
</div>
|
||||
<div v-if="ipRiskStats.length" class="risk-rank-list">
|
||||
<div v-for="(item, index) in ipRiskStats" :key="item.ip" class="risk-rank-row">
|
||||
<span class="rank-no" :class="{ 'rank-top': index < 3 }">{{ String(index + 1).padStart(2, "0") }}</span>
|
||||
<div class="rank-user">
|
||||
<div class="rank-main-line">
|
||||
<strong>{{ item.ip }}</strong>
|
||||
<small>{{ item.location }}</small>
|
||||
</div>
|
||||
<div class="rank-meta-line">
|
||||
<span>{{ item.categoryText }}</span>
|
||||
<span>最后访问 {{ formatTime(item.lastSeenAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rank-count">
|
||||
<strong>{{ item.total }}</strong>
|
||||
<small :class="{ 'denied-highlight': item.highRiskCount > 0 }">{{ item.highRiskCount }} 高危</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无异常数据" :image-size="72" />
|
||||
</article>
|
||||
|
||||
<article class="ranking-card event-distribution-card">
|
||||
<div class="section-head">
|
||||
<div class="section-title-group">
|
||||
<h4>事件分布</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="distribution-section">
|
||||
<div class="distribution-section-head">
|
||||
<strong>访问的接口类型</strong>
|
||||
</div>
|
||||
<div v-if="endpointTypeStats.length" class="distribution-list">
|
||||
<div v-for="item in endpointTypeStats" :key="item.type" class="distribution-row">
|
||||
<div class="distribution-info">
|
||||
<strong>{{ item.type }}</strong>
|
||||
<small>{{ item.samplePath }}</small>
|
||||
</div>
|
||||
<div class="distribution-meter">
|
||||
<span :style="{ width: `${item.percent}%` }" />
|
||||
</div>
|
||||
<strong class="distribution-count">{{ item.total }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无接口类型数据" :image-size="72" />
|
||||
</div>
|
||||
|
||||
<div class="distribution-section compact-risk-section">
|
||||
<div class="distribution-section-head">
|
||||
<strong>风险等级</strong>
|
||||
</div>
|
||||
<div class="risk-level-list">
|
||||
<div v-for="item in riskLevelStats" :key="item.value" class="risk-level-row">
|
||||
<span class="risk-dot" :class="item.tone" />
|
||||
<span>{{ item.label }}</span>
|
||||
<div class="risk-level-meter">
|
||||
<span :class="item.tone" :style="{ width: `${item.percent}%` }" />
|
||||
</div>
|
||||
<strong>{{ item.total }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="security-table-wrap">
|
||||
<div class="section-head table-head">
|
||||
<div class="section-title-group">
|
||||
<h4>安全事件明细</h4>
|
||||
</div>
|
||||
<section class="audit-filters detail-filters">
|
||||
<el-select v-model="severityFilter" placeholder="风险等级" clearable style="width: 130px" @change="onFilterChange">
|
||||
<el-option v-for="item in severityOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<el-select v-model="categoryFilter" placeholder="事件分类" clearable style="width: 150px" @change="onFilterChange">
|
||||
<el-option v-for="item in categoryOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<el-select v-model="authFilter" placeholder="认证状态" clearable style="width: 140px" @change="onFilterChange">
|
||||
<el-option label="匿名" value="ANONYMOUS" />
|
||||
<el-option label="无效令牌" value="INVALID_TOKEN" />
|
||||
<el-option label="已认证" value="AUTHENTICATED" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索 IP、位置、账号、路径或 UA"
|
||||
clearable
|
||||
class="security-search"
|
||||
:prefix-icon="SearchIcon"
|
||||
/>
|
||||
<el-button type="primary" :loading="loading" @click="loadSecurityEvents">刷新</el-button>
|
||||
</section>
|
||||
<el-tag effect="plain" size="small" type="info">{{ securityTotal }} 条</el-tag>
|
||||
</div>
|
||||
<el-table :data="paginatedEvents" v-loading="loading" class="security-table" table-layout="fixed">
|
||||
<el-table-column label="级别" width="96">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="severityTagType(row.severity)" effect="dark" size="small">{{ severityLabel(row.severity) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分类" width="130">
|
||||
<template #default="{ row }">
|
||||
<span class="category-pill">{{ categoryLabel(row.category) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时间" width="160">
|
||||
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="IP" width="132">
|
||||
<template #default="{ row }">{{ row.client_ip || "未知 IP" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="位置" width="180">
|
||||
<template #default="{ row }">{{ formatIpLocation(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="账号" width="140" prop="account_label" />
|
||||
<el-table-column label="请求" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<div class="request-cell">
|
||||
<strong>{{ row.method }} {{ row.status_code }} / {{ resolveEndpointType(row.path) }}</strong>
|
||||
<span>{{ row.path }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="耗时" width="86">
|
||||
<template #default="{ row }">{{ row.elapsed_ms.toFixed(1) }}ms</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="securityTotal > 0" class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="securityTotal"
|
||||
layout="prev, pager, next, sizes, total"
|
||||
small
|
||||
@size-change="onPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-drawer v-model="detailVisible" size="520px" direction="rtl" :show-close="false">
|
||||
<template #header>
|
||||
<div class="security-detail-head">
|
||||
<strong>安全事件详情</strong>
|
||||
<el-button size="small" @click="detailVisible = false">关闭</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="selectedEvent" class="security-detail">
|
||||
<div class="detail-grid">
|
||||
<div><span>级别</span><strong>{{ severityLabel(selectedEvent.severity) }}</strong></div>
|
||||
<div><span>分类</span><strong>{{ categoryLabel(selectedEvent.category) }}</strong></div>
|
||||
<div><span>状态码</span><strong>{{ selectedEvent.status_code }}</strong></div>
|
||||
<div><span>认证状态</span><strong>{{ authLabel(selectedEvent.auth_status) }}</strong></div>
|
||||
<div><span>IP</span><strong>{{ selectedEvent.client_ip || "未知" }}</strong></div>
|
||||
<div><span>位置</span><strong>{{ formatIpLocation(selectedEvent) }}</strong></div>
|
||||
<div><span>接口类型</span><strong>{{ resolveEndpointType(selectedEvent.path) }}</strong></div>
|
||||
<div><span>账号</span><strong>{{ selectedEvent.account_label }}</strong></div>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<span>请求路径</span>
|
||||
<code>{{ selectedEvent.method }} {{ selectedEvent.path }}</code>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<span>User Agent</span>
|
||||
<code>{{ selectedEvent.user_agent || "-" }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, ref, watch } from "vue";
|
||||
import { Search as SearchIcon } from "@element-plus/icons-vue";
|
||||
import { fetchSecurityAccessLogs } from "@/api/projectPermissions";
|
||||
import type { SecurityAccessLogItem } from "@/types/api";
|
||||
|
||||
type MetricTone = "blue" | "cyan" | "danger" | "indigo";
|
||||
|
||||
const MetricIconShield = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("path", { d: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" }),
|
||||
]);
|
||||
const MetricIconIp = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("circle", { cx: "12", cy: "10", r: "3" }),
|
||||
h("path", { d: "M12 21s7-5.2 7-11a7 7 0 1 0-14 0c0 5.8 7 11 7 11z" }),
|
||||
]);
|
||||
const MetricIconAlert = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("path", { d: "M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" }),
|
||||
h("line", { x1: "12", y1: "9", x2: "12", y2: "13" }),
|
||||
h("line", { x1: "12", y1: "17", x2: "12.01", y2: "17" }),
|
||||
]);
|
||||
const MetricIconServer = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("rect", { x: "3", y: "4", width: "18", height: "8", rx: "2" }),
|
||||
h("rect", { x: "3", y: "12", width: "18", height: "8", rx: "2" }),
|
||||
h("line", { x1: "7", y1: "8", x2: "7.01", y2: "8" }),
|
||||
h("line", { x1: "7", y1: "16", x2: "7.01", y2: "16" }),
|
||||
]);
|
||||
|
||||
const loading = ref(false);
|
||||
const statsEvents = ref<SecurityAccessLogItem[]>([]);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(10);
|
||||
const keyword = ref("");
|
||||
const severityFilter = ref("");
|
||||
const categoryFilter = ref("");
|
||||
const authFilter = ref("");
|
||||
const detailVisible = ref(false);
|
||||
const selectedEvent = ref<SecurityAccessLogItem | null>(null);
|
||||
|
||||
const severityOptions = [
|
||||
{ label: "严重", value: "CRITICAL", tone: "critical" },
|
||||
{ label: "高", value: "HIGH", tone: "high" },
|
||||
{ label: "中", value: "MEDIUM", tone: "medium" },
|
||||
{ label: "低", value: "LOW", tone: "low" },
|
||||
];
|
||||
|
||||
const categoryOptions = [
|
||||
{ label: "敏感路径探测", value: "PROBE" },
|
||||
{ label: "异常IP", value: "ABNORMAL_IP" },
|
||||
{ label: "无效令牌", value: "INVALID_TOKEN" },
|
||||
{ label: "服务异常", value: "SERVER_ERROR" },
|
||||
{ label: "匿名 API", value: "ANONYMOUS_API" },
|
||||
{ label: "普通 404", value: "NOT_FOUND_NOISE" },
|
||||
{ label: "其他", value: "OTHER" },
|
||||
];
|
||||
|
||||
const severityLabel = (value: string) => severityOptions.find((item) => item.value === value)?.label || value;
|
||||
const categoryLabel = (value: string) => categoryOptions.find((item) => item.value === value)?.label || value;
|
||||
|
||||
const authLabel = (value: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
ANONYMOUS: "匿名",
|
||||
INVALID_TOKEN: "无效令牌",
|
||||
AUTHENTICATED: "已认证",
|
||||
};
|
||||
return labels[value] || value;
|
||||
};
|
||||
|
||||
const severityTagType = (value: string) => {
|
||||
if (value === "CRITICAL" || value === "HIGH") return "danger";
|
||||
if (value === "MEDIUM") return "warning";
|
||||
return "info";
|
||||
};
|
||||
|
||||
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
|
||||
const formatTime = (value: string) => new Date(value).toLocaleString("zh-CN", { hour12: false });
|
||||
|
||||
const isHighRisk = (event: SecurityAccessLogItem) => event.severity === "CRITICAL" || event.severity === "HIGH";
|
||||
|
||||
const fallbackIpLocation = (ip: string | null) => {
|
||||
if (!ip) return "未知位置";
|
||||
if (ip === "127.0.0.1" || ip === "::1") return "本机访问";
|
||||
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[0-1])\.)/.test(ip)) return "内网地址";
|
||||
return "未知位置";
|
||||
};
|
||||
|
||||
const formatIpLocation = (row: SecurityAccessLogItem) => {
|
||||
const parts = [
|
||||
row.ip_country && row.ip_country !== "中国" ? row.ip_country : "",
|
||||
row.ip_province,
|
||||
row.ip_city,
|
||||
row.ip_isp,
|
||||
].filter(Boolean);
|
||||
if (parts.length) return parts.join(" / ");
|
||||
return row.ip_location || fallbackIpLocation(row.client_ip);
|
||||
};
|
||||
|
||||
const resolveEndpointType = (path: string) => {
|
||||
const normalizedPath = path.toLowerCase();
|
||||
if (normalizedPath.includes("/auth") || normalizedPath.includes("/login")) return "认证接口";
|
||||
if (normalizedPath.startsWith("/api/")) return "业务 API";
|
||||
if (normalizedPath.match(/\.(js|css|png|jpg|jpeg|svg|ico|map)$/)) return "静态资源";
|
||||
if ([".env", "wp-", "php", "admin", "shell", "config"].some((marker) => normalizedPath.includes(marker))) return "探测路径";
|
||||
return "其他请求";
|
||||
};
|
||||
|
||||
const detailFilteredEvents = computed(() => {
|
||||
const token = keyword.value.trim().toLowerCase();
|
||||
return statsEvents.value.filter((event) => {
|
||||
if (severityFilter.value && event.severity !== severityFilter.value) return false;
|
||||
if (categoryFilter.value && event.category !== categoryFilter.value) return false;
|
||||
if (authFilter.value && event.auth_status !== authFilter.value) return false;
|
||||
if (!token) return true;
|
||||
return [
|
||||
event.client_ip,
|
||||
formatIpLocation(event),
|
||||
event.account_label,
|
||||
event.path,
|
||||
resolveEndpointType(event.path),
|
||||
event.user_agent,
|
||||
event.auth_status,
|
||||
event.status_code,
|
||||
].some((value) => String(value || "").toLowerCase().includes(token));
|
||||
});
|
||||
});
|
||||
|
||||
const securityTotal = computed(() => detailFilteredEvents.value.length);
|
||||
|
||||
const paginatedEvents = computed(() => {
|
||||
const start = (page.value - 1) * pageSize.value;
|
||||
return detailFilteredEvents.value.slice(start, start + pageSize.value);
|
||||
});
|
||||
|
||||
const countByCategory = (category: string) => statsEvents.value.filter((event) => event.category === category).length;
|
||||
|
||||
const metricCards = computed<Array<{ label: string; value: string; tone: MetricTone; icon: any }>>(() => {
|
||||
const uniqueIpCount = new Set(statsEvents.value.map((event) => event.client_ip || "未知 IP")).size;
|
||||
const highRiskCount = statsEvents.value.filter(isHighRisk).length;
|
||||
const serverErrorCount = countByCategory("SERVER_ERROR");
|
||||
return [
|
||||
{ label: "安全事件", value: formatNumber(statsEvents.value.length), tone: "blue", icon: MetricIconShield },
|
||||
{ label: "异常 IP", value: formatNumber(uniqueIpCount), tone: "cyan", icon: MetricIconIp },
|
||||
{ label: "高危风险", value: formatNumber(highRiskCount), tone: highRiskCount > 0 ? "danger" : "indigo", icon: MetricIconAlert },
|
||||
{ label: "服务异常", value: formatNumber(serverErrorCount), tone: serverErrorCount > 0 ? "danger" : "indigo", icon: MetricIconServer },
|
||||
];
|
||||
});
|
||||
|
||||
const ipRiskStats = computed(() => {
|
||||
const stats = new Map<string, { ip: string; location: string; categoryText: string; total: number; highRiskCount: number; lastSeenAt: string }>();
|
||||
statsEvents.value.forEach((event) => {
|
||||
const ip = event.client_ip || "未知 IP";
|
||||
const current = stats.get(ip) || {
|
||||
ip,
|
||||
location: formatIpLocation(event),
|
||||
categoryText: categoryLabel(event.category),
|
||||
total: 0,
|
||||
highRiskCount: 0,
|
||||
lastSeenAt: event.created_at,
|
||||
};
|
||||
current.total += 1;
|
||||
if (isHighRisk(event)) current.highRiskCount += 1;
|
||||
if (new Date(event.created_at).getTime() > new Date(current.lastSeenAt).getTime()) {
|
||||
current.lastSeenAt = event.created_at;
|
||||
current.categoryText = categoryLabel(event.category);
|
||||
}
|
||||
stats.set(ip, current);
|
||||
});
|
||||
return [...stats.values()].sort((a, b) => b.highRiskCount - a.highRiskCount || b.total - a.total).slice(0, 10);
|
||||
});
|
||||
|
||||
const endpointTypeStats = computed(() => {
|
||||
const stats = new Map<string, { type: string; total: number; samplePath: string }>();
|
||||
statsEvents.value.forEach((event) => {
|
||||
const type = resolveEndpointType(event.path);
|
||||
const current = stats.get(type) || { type, total: 0, samplePath: event.path };
|
||||
current.total += 1;
|
||||
stats.set(type, current);
|
||||
});
|
||||
const maxTotal = Math.max(1, ...[...stats.values()].map((item) => item.total));
|
||||
return [...stats.values()]
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.map((item) => ({ ...item, percent: Math.max(8, Math.round((item.total / maxTotal) * 100)) }));
|
||||
});
|
||||
|
||||
const riskLevelStats = computed(() => {
|
||||
const filteredCounts = severityOptions.map((item) => ({
|
||||
...item,
|
||||
total: statsEvents.value.filter((event) => event.severity === item.value).length,
|
||||
}));
|
||||
const maxTotal = Math.max(1, ...filteredCounts.map((item) => item.total));
|
||||
return filteredCounts.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
percent: item.total > 0 ? Math.max(8, Math.round((item.total / maxTotal) * 100)) : 0,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const SECURITY_STATS_PAGE_SIZE = 200;
|
||||
|
||||
const loadSecurityEvents = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
await loadSecurityStats();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadSecurityStats = async () => {
|
||||
const first = await fetchSecurityAccessLogs({
|
||||
status_min: 400,
|
||||
auth_status: authFilter.value || undefined,
|
||||
page: 1,
|
||||
page_size: SECURITY_STATS_PAGE_SIZE,
|
||||
});
|
||||
const allItems = [...first.data.items];
|
||||
const total = first.data.total;
|
||||
const totalPages = Math.ceil(total / SECURITY_STATS_PAGE_SIZE);
|
||||
|
||||
for (let nextPage = 2; nextPage <= totalPages; nextPage += 1) {
|
||||
const res = await fetchSecurityAccessLogs({
|
||||
status_min: 400,
|
||||
auth_status: authFilter.value || undefined,
|
||||
page: nextPage,
|
||||
page_size: SECURITY_STATS_PAGE_SIZE,
|
||||
});
|
||||
allItems.push(...res.data.items);
|
||||
}
|
||||
statsEvents.value = allItems;
|
||||
};
|
||||
|
||||
const onFilterChange = () => {
|
||||
page.value = 1;
|
||||
loadSecurityEvents();
|
||||
};
|
||||
|
||||
const onPageSizeChange = (size: number) => {
|
||||
pageSize.value = size;
|
||||
page.value = 1;
|
||||
};
|
||||
|
||||
watch(keyword, () => {
|
||||
page.value = 1;
|
||||
});
|
||||
|
||||
const openDetail = (event: SecurityAccessLogItem) => {
|
||||
selectedEvent.value = event;
|
||||
detailVisible.value = true;
|
||||
};
|
||||
|
||||
onMounted(loadSecurityEvents);
|
||||
|
||||
defineExpose({ refresh: loadSecurityEvents });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.security-center {
|
||||
--audit-ink: #1a2332;
|
||||
--audit-muted: #64748b;
|
||||
--audit-border: #e2e8f0;
|
||||
--audit-blue: #3b82f6;
|
||||
--audit-cyan: #06b6d4;
|
||||
--audit-danger: #ef4444;
|
||||
--audit-indigo: #6366f1;
|
||||
--card-radius: 16px;
|
||||
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.audit-filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding: 14px 18px;
|
||||
background: #fff;
|
||||
border-radius: var(--card-radius);
|
||||
border: 1px solid var(--audit-border);
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.security-search {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.audit-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 64px;
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--card-radius);
|
||||
background: #fff;
|
||||
border: 1px solid var(--audit-border);
|
||||
box-shadow: var(--card-shadow);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.metric-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
border-radius: var(--card-radius) var(--card-radius) 0 0;
|
||||
}
|
||||
|
||||
.metric-card.blue::before { background: linear-gradient(90deg, #3b82f6, #60a5fa); }
|
||||
.metric-card.cyan::before { background: linear-gradient(90deg, #06b6d4, #22d3ee); }
|
||||
.metric-card.danger::before { background: linear-gradient(90deg, #ef4444, #f87171); }
|
||||
.metric-card.indigo::before { background: linear-gradient(90deg, #6366f1, #818cf8); }
|
||||
|
||||
.metric-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.metric-icon svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.metric-card.blue .metric-icon { background: #eff6ff; color: #3b82f6; }
|
||||
.metric-card.cyan .metric-icon { background: #ecfeff; color: #06b6d4; }
|
||||
.metric-card.danger .metric-icon { background: #fef2f2; color: #ef4444; }
|
||||
.metric-card.indigo .metric-icon { background: #eef2ff; color: #6366f1; }
|
||||
|
||||
.metric-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: var(--audit-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
color: var(--audit-ink);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.audit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 1fr) minmax(0, 1.35fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.ranking-card,
|
||||
.security-table-wrap {
|
||||
min-width: 0;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--audit-border);
|
||||
border-radius: var(--card-radius);
|
||||
background: #fff;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-head {
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.table-head .section-title-group {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.table-head .el-tag {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-filters {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.section-title-group h4 {
|
||||
margin: 0;
|
||||
color: var(--audit-ink);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-title-group p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--audit-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.risk-rank-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.risk-rank-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 12px 10px;
|
||||
border-radius: 10px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.risk-rank-row:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.rank-no {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rank-no.rank-top {
|
||||
background: linear-gradient(135deg, #ef4444, #6366f1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.rank-user,
|
||||
.rank-count {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rank-main-line,
|
||||
.rank-meta-line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rank-main-line strong {
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
color: var(--audit-ink);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rank-main-line small {
|
||||
overflow: hidden;
|
||||
color: var(--audit-muted);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rank-meta-line span {
|
||||
color: var(--audit-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rank-count {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.rank-count strong {
|
||||
color: var(--audit-ink);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rank-count small {
|
||||
color: var(--audit-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.rank-count .denied-highlight {
|
||||
color: var(--audit-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.distribution-list,
|
||||
.risk-level-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.event-distribution-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.distribution-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.distribution-section + .distribution-section {
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--audit-border);
|
||||
}
|
||||
|
||||
.event-distribution-card > .section-head {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.event-distribution-card .section-title-group h4 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.distribution-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.distribution-section-head strong {
|
||||
color: var(--audit-ink);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.distribution-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 0.9fr) minmax(120px, 1fr) 44px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.distribution-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.distribution-info strong {
|
||||
display: block;
|
||||
color: var(--audit-ink);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.distribution-info small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
margin-top: 3px;
|
||||
color: var(--audit-muted);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.distribution-meter,
|
||||
.risk-level-meter {
|
||||
overflow: hidden;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.distribution-meter span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #06b6d4, #3b82f6);
|
||||
}
|
||||
|
||||
.distribution-count {
|
||||
color: var(--audit-ink);
|
||||
font-size: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.risk-level-row {
|
||||
display: grid;
|
||||
grid-template-columns: 10px 52px minmax(0, 1fr) 44px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.risk-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.risk-dot.critical,
|
||||
.risk-level-meter span.critical { background: #991b1b; }
|
||||
.risk-dot.high,
|
||||
.risk-level-meter span.high { background: #ef4444; }
|
||||
.risk-dot.medium,
|
||||
.risk-level-meter span.medium { background: #f59e0b; }
|
||||
.risk-dot.low,
|
||||
.risk-level-meter span.low { background: #64748b; }
|
||||
|
||||
.risk-level-row > span:nth-child(2) {
|
||||
color: var(--audit-ink);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.risk-level-meter span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.risk-level-row strong {
|
||||
color: var(--audit-ink);
|
||||
font-size: 14px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.security-table-wrap {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
padding: 8px 16px 0;
|
||||
}
|
||||
|
||||
.category-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 112px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: #eef2f7;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.request-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.request-cell strong {
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.request-cell span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--audit-ink);
|
||||
}
|
||||
|
||||
.security-detail-head {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.security-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail-grid div,
|
||||
.detail-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--audit-border);
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.detail-grid span,
|
||||
.detail-section span {
|
||||
color: var(--audit-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-grid strong {
|
||||
color: var(--audit-ink);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.detail-section code {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: var(--audit-ink);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.audit-metrics,
|
||||
.audit-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.audit-metrics,
|
||||
.audit-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.security-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.distribution-row {
|
||||
grid-template-columns: 1fr 44px;
|
||||
}
|
||||
|
||||
.distribution-meter {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,117 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readAttachmentList = () => readFileSync(resolve(__dirname, "./AttachmentList.vue"), "utf8");
|
||||
|
||||
describe("AttachmentList permissions", () => {
|
||||
it("does not require project member list permission to render uploaders", () => {
|
||||
const source = readAttachmentList();
|
||||
|
||||
expect(source).not.toContain("listMembers");
|
||||
});
|
||||
|
||||
it("matches the contract-fee attachment table layout", () => {
|
||||
const source = readAttachmentList();
|
||||
|
||||
expect(source).toContain('prop="filename" :label="TEXT.common.labels.filename" min-width="360" show-overflow-tooltip');
|
||||
expect(source).toContain(':label="TEXT.common.labels.size" min-width="180"');
|
||||
expect(source).toContain(':label="TEXT.common.labels.uploader" min-width="180"');
|
||||
expect(source).toContain('prop="uploaded_at" :label="TEXT.common.labels.uploadedAt" min-width="180" show-overflow-tooltip');
|
||||
expect(source).toContain(':label="TEXT.common.labels.actions" min-width="180"');
|
||||
expect(source).not.toContain('width="160"');
|
||||
expect(source).toContain('class="attachment-actions"');
|
||||
expect(source).toContain("flex-wrap: nowrap;");
|
||||
expect(source).toContain("gap: 4px;");
|
||||
});
|
||||
|
||||
it("can aggregate multiple attachment entity types into one table with an attachment type column", () => {
|
||||
const source = readAttachmentList();
|
||||
|
||||
expect(source).toContain("entityGroups?: AttachmentEntityGroup[]");
|
||||
expect(source).toContain('v-if="hasEntityGroups"');
|
||||
expect(source).toContain('label="附件类型"');
|
||||
expect(source).toContain("scope.row.attachment_type_label");
|
||||
expect(source).toContain("fetchAttachments(props.studyId, group.entityType, props.entityId)");
|
||||
expect(source).toContain("attachment_type_label: group.label");
|
||||
expect(source).toContain("attachment_entity_type: group.entityType");
|
||||
});
|
||||
|
||||
it("can reload attachments when the parent refresh key changes", () => {
|
||||
const source = readAttachmentList();
|
||||
|
||||
expect(source).toContain("refreshKey?: number");
|
||||
expect(source).toContain("() => props.refreshKey");
|
||||
expect(source).toContain("load();");
|
||||
});
|
||||
|
||||
it("can hide the uploader while keeping the attachment table visible", () => {
|
||||
const source = readAttachmentList();
|
||||
|
||||
expect(source).toContain("hideUploader?: boolean");
|
||||
expect(source).toContain("canShowUploader");
|
||||
expect(source).toContain("!props.hideUploader && canCreate.value");
|
||||
});
|
||||
|
||||
it("hides the default attachment title row when uploader is hidden and no explicit title is provided", () => {
|
||||
const source = readAttachmentList();
|
||||
|
||||
expect(source).toContain("const showHeader = computed(() => !!props.title || canShowUploader.value)");
|
||||
expect(source).toContain('<div v-if="showHeader" class="header">');
|
||||
});
|
||||
|
||||
it("uses the shared upload implementation for table-mode immediate uploads", () => {
|
||||
const source = readAttachmentList();
|
||||
|
||||
expect(source).not.toContain("AttachmentUploader");
|
||||
expect(source).toContain(':http-request="uploadImmediate"');
|
||||
expect(source).toContain("const uploadImmediate = async");
|
||||
expect(source).toContain("await uploadFile(group, file, props.entityId)");
|
||||
expect(source).toContain("ElMessage.success(TEXT.common.messages.uploadSuccess)");
|
||||
expect(source).toContain("load();");
|
||||
});
|
||||
|
||||
it("supports upload-card mode without rendering the table header", () => {
|
||||
const source = readAttachmentList();
|
||||
|
||||
expect(source).toContain('mode?: "table" | "upload"');
|
||||
expect(source).toContain("displayMode");
|
||||
expect(source).toContain('v-if="displayMode === \'table\'"');
|
||||
expect(source).toContain("v-else");
|
||||
expect(source).toContain('class="attachment-upload-card"');
|
||||
expect(source).toContain(':auto-upload="false"');
|
||||
expect(source).toContain("queuePendingUpload(group.key, file)");
|
||||
expect(source).toContain("点击上传文件");
|
||||
});
|
||||
|
||||
it("allows create forms to choose attachments before the entity id exists", () => {
|
||||
const source = readAttachmentList();
|
||||
|
||||
expect(source).toContain("const pendingSnapshot = () =>");
|
||||
expect(source).toContain("const uploadPending = async");
|
||||
expect(source).toContain("const targetEntityId = entityId || props.entityId");
|
||||
expect(source).toContain("defineExpose({");
|
||||
expect(source).toContain("pendingSnapshot");
|
||||
expect(source).toContain("uploadPending");
|
||||
expect(source).toContain("pendingMap[group.key] = remainItems");
|
||||
expect(source).toContain("throw new Error(TEXT.common.messages.uploadFailed)");
|
||||
});
|
||||
|
||||
it("fails pending upload when selected files have no saved entity id", () => {
|
||||
const source = readAttachmentList();
|
||||
|
||||
expect(source).toContain("const hasPendingUploads = computed");
|
||||
expect(source).toContain("if (!targetEntityId && hasPendingUploads.value)");
|
||||
expect(source).toContain("throw new Error(TEXT.common.messages.uploadFailed)");
|
||||
});
|
||||
|
||||
it("uses entity groups as upload groups when an editor has multiple attachment types", () => {
|
||||
const source = readAttachmentList();
|
||||
|
||||
expect(source).toContain("const uploadGroups = computed");
|
||||
expect(source).toContain("props.entityGroups.map((group)");
|
||||
expect(source).toContain("key: group.entityType");
|
||||
expect(source).toContain("entityType: group.entityType");
|
||||
expect(source).toContain("uploadAttachment(props.studyId, group.entityType, targetEntityId, file");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
// 地图数据来源:https://unpkg.com/echarts@4.9.0/map/json/world.json
|
||||
// 组件直接导入本地缓存的 ECharts 世界地图 GeoJSON,避免运行时依赖外部网络。
|
||||
|
||||
import type { IpLocationStatItem } from "../types/api";
|
||||
|
||||
export const normalizeWorldCountryName = (value?: string | null) => {
|
||||
const country = String(value || "").trim();
|
||||
const aliases: Record<string, string> = {
|
||||
中国: "China",
|
||||
China: "China",
|
||||
"Mainland China": "China",
|
||||
中国香港: "China",
|
||||
中国澳门: "China",
|
||||
中国台湾: "China",
|
||||
美国: "United States",
|
||||
"United States": "United States",
|
||||
"United States of America": "United States",
|
||||
USA: "United States",
|
||||
Australia: "Australia",
|
||||
澳大利亚: "Australia",
|
||||
Japan: "Japan",
|
||||
日本: "Japan",
|
||||
Singapore: "Singapore",
|
||||
新加坡: "Singapore",
|
||||
Germany: "Germany",
|
||||
德国: "Germany",
|
||||
France: "France",
|
||||
法国: "France",
|
||||
"United Kingdom": "United Kingdom",
|
||||
英国: "United Kingdom",
|
||||
Canada: "Canada",
|
||||
加拿大: "Canada",
|
||||
India: "India",
|
||||
印度: "India",
|
||||
Russia: "Russia",
|
||||
俄罗斯: "Russia",
|
||||
};
|
||||
return aliases[country] || country;
|
||||
};
|
||||
|
||||
export const countryCoordinates: Record<string, [number, number]> = {
|
||||
China: [104.1954, 35.8617],
|
||||
"United States": [-95.7129, 37.0902],
|
||||
Netherlands: [5.2913, 52.1326],
|
||||
Türkiye: [35.2433, 38.9637],
|
||||
Turkey: [35.2433, 38.9637],
|
||||
Australia: [133.7751, -25.2744],
|
||||
Japan: [138.2529, 36.2048],
|
||||
Singapore: [103.8198, 1.3521],
|
||||
Germany: [10.4515, 51.1657],
|
||||
France: [2.2137, 46.2276],
|
||||
"United Kingdom": [-3.436, 55.3781],
|
||||
Canada: [-106.3468, 56.1304],
|
||||
India: [78.9629, 20.5937],
|
||||
Russia: [105.3188, 61.524],
|
||||
};
|
||||
|
||||
export const cityCoordinates: Record<string, [number, number]> = {
|
||||
"局域网": [121.86, 31.45],
|
||||
"北京市": [116.4074, 39.9042],
|
||||
"天津市": [117.2008, 39.0842],
|
||||
"河北省": [114.5025, 38.0455],
|
||||
"山西省": [112.5492, 37.857],
|
||||
"内蒙古自治区": [111.6708, 40.8183],
|
||||
"辽宁省": [123.4315, 41.8057],
|
||||
"吉林省": [125.3245, 43.8868],
|
||||
"黑龙江省": [126.6424, 45.7567],
|
||||
"上海市": [121.4737, 31.2304],
|
||||
"江苏省": [118.7633, 32.0617],
|
||||
"浙江省": [120.1551, 30.2741],
|
||||
"安徽省": [117.2272, 31.8206],
|
||||
"福建省": [119.2965, 26.0745],
|
||||
"江西省": [115.8582, 28.682],
|
||||
"山东省": [117.1201, 36.6512],
|
||||
"河南省": [113.6254, 34.7466],
|
||||
"湖北省": [114.3055, 30.5928],
|
||||
"湖南省": [112.9388, 28.2282],
|
||||
"广东省": [113.2644, 23.1291],
|
||||
"广西壮族自治区": [108.3669, 22.817],
|
||||
"海南省": [110.3312, 20.0311],
|
||||
"重庆": [106.5516, 29.563],
|
||||
"重庆市": [106.5516, 29.563],
|
||||
"四川省": [104.0665, 30.5723],
|
||||
"贵州省": [106.6302, 26.647],
|
||||
"云南省": [102.8329, 24.8801],
|
||||
"西藏自治区": [91.1322, 29.6604],
|
||||
"陕西省": [108.9398, 34.3416],
|
||||
"甘肃省": [103.8343, 36.0611],
|
||||
"青海省": [101.7782, 36.6171],
|
||||
"宁夏回族自治区": [106.2309, 38.4872],
|
||||
"新疆维吾尔自治区": [87.6168, 43.8256],
|
||||
"台湾省": [121.5654, 25.033],
|
||||
"香港特别行政区": [114.1694, 22.3193],
|
||||
"澳门特别行政区": [113.5439, 22.1987],
|
||||
"南京": [118.7969, 32.0603],
|
||||
"南京市": [118.7969, 32.0603],
|
||||
"San Jose": [-121.8863, 37.3382],
|
||||
"South Holland": [4.493, 52.0208],
|
||||
"Istanbul": [28.9784, 41.0082],
|
||||
};
|
||||
|
||||
export const resolveSourceCoordinate = (item: IpLocationStatItem): [number, number] | null => {
|
||||
if (item.location === "局域网") return cityCoordinates["局域网"];
|
||||
const city = String(item.city || "").trim();
|
||||
if (city && cityCoordinates[city]) return cityCoordinates[city];
|
||||
const province = String(item.province || "").trim();
|
||||
if (province && cityCoordinates[province]) return cityCoordinates[province];
|
||||
const country = normalizeWorldCountryName(item.country);
|
||||
return country ? countryCoordinates[country] || null : null;
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { useRoleTemplateMeta } from "./useRoleTemplateMeta";
|
||||
|
||||
vi.mock("@/api/projectPermissions", () => ({
|
||||
fetchPermissionTemplates: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ category: "PV", name: "PV" },
|
||||
{ category: "QA", name: "QA" },
|
||||
{ category: "CTA", name: "CTA" },
|
||||
{ category: "CRA", name: "CRA" },
|
||||
{ category: "PM", name: "PM" },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./useRoleTemplateMeta.ts"), "utf8");
|
||||
|
||||
describe("useRoleTemplateMeta fallback copy", () => {
|
||||
it("uses QA and CTA as first-class preset role keys", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('PM: { label: "PM"');
|
||||
expect(source).toContain("项目负责人,统筹项目全局,协调进度、资源与关键决策。");
|
||||
expect(source).toContain("负责各中心临床监查执行,跟进现场质量、数据和问题闭环。");
|
||||
expect(source).toContain("负责合同、药品及相关项目事务管理,保障执行支持与物资协同。");
|
||||
expect(source).toContain('QA: { label: "QA"');
|
||||
expect(source).toContain("负责医学审核与稽查,关注质量风险、合规性和医学一致性。");
|
||||
expect(source).toContain('CTA: { label: "CTA"');
|
||||
expect(source).toContain("负责药物警戒相关工作,跟踪安全性事件并支持风险评估。");
|
||||
});
|
||||
|
||||
it("pins PM first before applying the role template list order", async () => {
|
||||
const { compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTemplateMeta();
|
||||
await loadRoleTemplates();
|
||||
|
||||
expect(["CRA", "PV", "QA", "PM", "CTA"].sort(compareRolesByTemplateOrder)).toEqual([
|
||||
"PM",
|
||||
"PV",
|
||||
"QA",
|
||||
"CTA",
|
||||
"CRA",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,9 @@ export const TEXT = {
|
||||
common: {
|
||||
appName: "CTMS",
|
||||
fallback: "—",
|
||||
separators: {
|
||||
dot: "·",
|
||||
},
|
||||
loading: "加载中",
|
||||
actions: {
|
||||
add: "新增",
|
||||
@@ -88,6 +91,18 @@ export const TEXT = {
|
||||
userFallback: "用户",
|
||||
basicInfo: "基本信息",
|
||||
allSites: "所有中心",
|
||||
locked: "已锁定",
|
||||
phase: "分期",
|
||||
plannedSites: "中心",
|
||||
plannedEnrollment: "入组",
|
||||
dataUpdatedAt: "数据",
|
||||
projectReminders: "提醒",
|
||||
projectRemindersSubtitle: "逾期与风险提示",
|
||||
projectRemindersEmpty: "暂无逾期风险",
|
||||
overdueAes: "逾期 AE",
|
||||
overdueAesDesc: "需关注安全性事件处理时效",
|
||||
overdueMonitoringIssues: "逾期监查问题",
|
||||
overdueMonitoringIssuesDesc: "需跟进问题关闭与整改",
|
||||
yes: "是",
|
||||
no: "否",
|
||||
},
|
||||
@@ -262,6 +277,7 @@ export const TEXT = {
|
||||
projectManagement: "项目管理",
|
||||
auditLogs: "审计日志",
|
||||
permissionManagement: "权限管理",
|
||||
systemMonitoring: "系统监测",
|
||||
currentProject: "当前项目",
|
||||
projectOverview: "项目总览",
|
||||
projectMilestones: "项目里程碑",
|
||||
@@ -950,8 +966,7 @@ export const TEXT = {
|
||||
default: "当前角色无权执行该操作",
|
||||
},
|
||||
profile: {
|
||||
title: "个人设置",
|
||||
subtitle: "更新个人信息或修改登录密码",
|
||||
title: "个人中心",
|
||||
uploadAvatar: "上传头像",
|
||||
currentPassword: "当前密码",
|
||||
currentPasswordHint: "修改密码时必填",
|
||||
@@ -965,6 +980,7 @@ export const TEXT = {
|
||||
updateSuccess: "已更新",
|
||||
updateFailed: "更新失败",
|
||||
avatarUpdated: "头像已更新",
|
||||
avatarTypeInvalid: "头像仅支持图片格式",
|
||||
avatarUploadFailed: "头像上传失败",
|
||||
},
|
||||
},
|
||||
@@ -1135,7 +1151,7 @@ export const TEXT = {
|
||||
title: "权限管理",
|
||||
moduleLevel: "模块级权限",
|
||||
apiLevel: "角色权限",
|
||||
monitoring: "权限监控",
|
||||
monitoring: "系统监测",
|
||||
refreshMetrics: "刷新指标",
|
||||
save: "保存",
|
||||
search: "搜索端点",
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("admin project route permissions", () => {
|
||||
it("guards project permission configuration with the system-level project config permission", () => {
|
||||
const source = readRouter();
|
||||
const projectPermissionRouteStart = source.indexOf('name: "AdminPermissionsProject"');
|
||||
const projectPermissionRouteEnd = source.indexOf('name: "AdminPermissionsMonitoring"', projectPermissionRouteStart);
|
||||
const projectPermissionRouteEnd = source.indexOf('path: "system-monitoring"', projectPermissionRouteStart);
|
||||
const projectPermissionRoute = source.slice(projectPermissionRouteStart, projectPermissionRouteEnd);
|
||||
|
||||
expect(source).toContain('const SYSTEM_PERMISSION_PROJECT_CONFIG = "system:permissions:project_config";');
|
||||
@@ -61,6 +61,23 @@ describe("admin project route permissions", () => {
|
||||
expect(targetProjectBranch).not.toContain("ensureDefaultPmStudy");
|
||||
});
|
||||
|
||||
it("registers system monitoring as an admin-only peer admin module with legacy redirects", () => {
|
||||
const source = readRouter();
|
||||
const monitoringRouteStart = source.indexOf('name: "AdminSystemMonitoring"');
|
||||
const monitoringRouteEnd = source.indexOf("],", monitoringRouteStart);
|
||||
const monitoringRoute = source.slice(monitoringRouteStart, monitoringRouteEnd);
|
||||
|
||||
expect(source).toContain('import SystemMonitoringPage from "../views/admin/SystemMonitoringPage.vue";');
|
||||
expect(source).toContain('path: "system-monitoring"');
|
||||
expect(monitoringRoute).toContain("component: SystemMonitoringPage");
|
||||
expect(monitoringRoute).toContain("requiresAdmin: true");
|
||||
expect(source).toContain('path: "permission-monitoring"');
|
||||
expect(source).toContain('path: "permissions/monitoring"');
|
||||
expect(source).toContain('redirect: "/admin/system-monitoring"');
|
||||
expect(source).not.toContain('const SYSTEM_PERMISSION_MONITORING_METRICS = "system:monitoring:metrics";');
|
||||
expect(source).not.toContain("[SYSTEM_PERMISSION_MONITORING_METRICS]");
|
||||
});
|
||||
|
||||
it("keeps login landing independent from PM management backend access", () => {
|
||||
const source = readRouter();
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import AdminUserApproval from "../views/admin/AdminUserApproval.vue";
|
||||
import AdminProjects from "../views/admin/Projects.vue";
|
||||
import AdminSites from "../views/admin/Sites.vue";
|
||||
import PermissionManagement from "../views/admin/PermissionManagement.vue";
|
||||
import SystemMonitoringPage from "../views/admin/SystemMonitoringPage.vue";
|
||||
import ProjectDetail from "../views/admin/ProjectDetail.vue";
|
||||
import ProfileSettings from "../views/ProfileSettings.vue";
|
||||
import ProjectOverview from "../views/ia/ProjectOverview.vue";
|
||||
@@ -57,7 +58,6 @@ import { TEXT } from "../locales";
|
||||
|
||||
const SYSTEM_PERMISSION_READ = "system:permissions:read";
|
||||
const SYSTEM_PERMISSION_PROJECT_CONFIG = "system:permissions:project_config";
|
||||
const SYSTEM_PERMISSION_MONITORING_METRICS = "system:monitoring:metrics";
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
@@ -372,6 +372,20 @@ const routes: RouteRecordRaw[] = [
|
||||
component: AuditLogs,
|
||||
meta: { title: TEXT.menu.auditLogs, adminProjectPermission: { module: "audit_export", action: "read" } },
|
||||
},
|
||||
{
|
||||
path: "system-monitoring",
|
||||
name: "AdminSystemMonitoring",
|
||||
component: SystemMonitoringPage,
|
||||
meta: { title: TEXT.menu.systemMonitoring, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "permission-monitoring",
|
||||
redirect: "/admin/system-monitoring",
|
||||
},
|
||||
{
|
||||
path: "permissions/monitoring",
|
||||
redirect: "/admin/system-monitoring",
|
||||
},
|
||||
{
|
||||
path: "permissions",
|
||||
redirect: "/admin/permissions/system",
|
||||
@@ -388,12 +402,6 @@ const routes: RouteRecordRaw[] = [
|
||||
component: PermissionManagement,
|
||||
meta: { title: "项目权限配置", systemPermission: SYSTEM_PERMISSION_PROJECT_CONFIG },
|
||||
},
|
||||
{
|
||||
path: "permissions/monitoring",
|
||||
name: "AdminPermissionsMonitoring",
|
||||
component: PermissionManagement,
|
||||
meta: { title: "权限监控", systemPermission: SYSTEM_PERMISSION_MONITORING_METRICS },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -416,7 +424,6 @@ const ADMIN_PROJECT_OPERATION_KEYS: Record<string, Record<"read" | "write", stri
|
||||
const SYSTEM_PERMISSION_ROLES: Record<string, string[]> = {
|
||||
[SYSTEM_PERMISSION_READ]: ["ADMIN", "PM"],
|
||||
[SYSTEM_PERMISSION_PROJECT_CONFIG]: ["ADMIN", "PM"],
|
||||
[SYSTEM_PERMISSION_MONITORING_METRICS]: ["ADMIN", "PM"],
|
||||
};
|
||||
|
||||
const getTargetProjectId = (to: any) => {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const readMainCss = () => readFileSync(resolve(__dirname, "./main.css"), "utf8");
|
||||
|
||||
describe("global table styles", () => {
|
||||
it("keeps Element Plus table sizing on the root node without overriding internal layout", () => {
|
||||
const css = readMainCss();
|
||||
|
||||
expect(css).toContain(".el-table {\n color: var(--ctms-text-regular);\n width: 100%;\n max-width: 100%;\n}");
|
||||
expect(css).not.toContain(".el-table__header,\n.el-table__body,\n.el-table__footer");
|
||||
expect(css).not.toContain(".el-table__inner-wrapper,\n.el-table__header-wrapper");
|
||||
expect(css).not.toContain(".el-table colgroup col");
|
||||
});
|
||||
});
|
||||
@@ -218,7 +218,7 @@ export interface HealthResponse {
|
||||
cache_stats: CacheStatsResponse;
|
||||
}
|
||||
|
||||
// 权限监控 - 访问日志
|
||||
// 系统监测 - 访问日志
|
||||
export interface AccessLogItem {
|
||||
id: string;
|
||||
study_id: string;
|
||||
@@ -273,10 +273,17 @@ export interface SecurityAccessLogItem {
|
||||
status_code: number;
|
||||
elapsed_ms: number;
|
||||
client_ip: string | null;
|
||||
ip_location: string;
|
||||
ip_country: string;
|
||||
ip_province: string;
|
||||
ip_city: string;
|
||||
ip_isp: string;
|
||||
user_agent: string | null;
|
||||
auth_status: "ANONYMOUS" | "INVALID_TOKEN" | "AUTHENTICATED" | string;
|
||||
user_identifier: string | null;
|
||||
account_label: string;
|
||||
category: "PROBE" | "ABNORMAL_IP" | "INVALID_TOKEN" | "SERVER_ERROR" | "ANONYMOUS_API" | "NOT_FOUND_NOISE" | "OTHER" | string;
|
||||
severity: "CRITICAL" | "HIGH" | "MEDIUM" | "LOW" | string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -293,7 +300,7 @@ export interface SecurityAccessLogsResponse {
|
||||
items: SecurityAccessLogItem[];
|
||||
}
|
||||
|
||||
// 权限监控 - 趋势数据
|
||||
// 系统监测 - 趋势数据
|
||||
export interface TrendDataPoint {
|
||||
bucket_time: string;
|
||||
total_checks: number;
|
||||
@@ -312,7 +319,7 @@ export interface TrendsResponse {
|
||||
data_points: TrendDataPoint[];
|
||||
}
|
||||
|
||||
// 权限监控 - 被拒绝最多
|
||||
// 系统监测 - 被拒绝最多
|
||||
export interface TopDeniedItem {
|
||||
endpoint_key: string;
|
||||
role: string;
|
||||
@@ -349,7 +356,7 @@ export interface IpLocationsResponse {
|
||||
items: IpLocationStatItem[];
|
||||
}
|
||||
|
||||
// 权限监控 - 统计摘要
|
||||
// 系统监测 - 统计摘要
|
||||
export interface StatsSummaryResponse {
|
||||
total_logs: number;
|
||||
today: {
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getAttachmentPermissionKey } from "./attachmentPermissions";
|
||||
|
||||
describe("attachment permission mapping", () => {
|
||||
it("maps attachment entity types to module attachment permissions", () => {
|
||||
expect(getAttachmentPermissionKey("contract_fee_contract", "create")).toBe("fees_contracts:create");
|
||||
expect(getAttachmentPermissionKey("contract_fee_contract", "read")).toBe("fees_contracts:read");
|
||||
expect(getAttachmentPermissionKey("contract_fee_contract", "delete")).toBe("fees_contracts_attachments:delete");
|
||||
expect(getAttachmentPermissionKey("startup_feasibility", "create")).toBe("startup_initiation:create");
|
||||
expect(getAttachmentPermissionKey("startup_feasibility", "read")).toBe("startup_initiation:read");
|
||||
expect(getAttachmentPermissionKey("startup_feasibility", "delete")).toBe("startup_initiation_attachments:delete");
|
||||
expect(getAttachmentPermissionKey("startup_ethics", "create")).toBe("startup_ethics:create");
|
||||
expect(getAttachmentPermissionKey("startup_ethics", "read")).toBe("startup_ethics:read");
|
||||
expect(getAttachmentPermissionKey("startup_ethics", "delete")).toBe("startup_ethics_attachments:delete");
|
||||
expect(getAttachmentPermissionKey("startup_kickoff_ppt", "create")).toBe("startup_auth:create");
|
||||
expect(getAttachmentPermissionKey("startup_kickoff_ppt", "read")).toBe("startup_auth:read");
|
||||
expect(getAttachmentPermissionKey("training_authorization", "delete")).toBe("startup_auth_attachments:delete");
|
||||
expect(getAttachmentPermissionKey("drug_shipment", "create")).toBe("drug_shipments:create");
|
||||
expect(getAttachmentPermissionKey("drug_shipment", "read")).toBe("drug_shipments:read");
|
||||
expect(getAttachmentPermissionKey("drug_shipment", "delete")).toBe("drug_shipments_attachments:delete");
|
||||
expect(getAttachmentPermissionKey("material_equipment", "create")).toBe("material_equipments:update");
|
||||
expect(getAttachmentPermissionKey("material_equipment", "read")).toBe("material_equipments:read");
|
||||
expect(getAttachmentPermissionKey("material_equipment", "delete")).toBe("material_equipments_attachments:delete");
|
||||
expect(getAttachmentPermissionKey("precaution", "create")).toBe("precautions:create");
|
||||
expect(getAttachmentPermissionKey("precaution", "read")).toBe("precautions:read");
|
||||
expect(getAttachmentPermissionKey("precaution", "delete")).toBe("precautions_attachments:delete");
|
||||
expect(getAttachmentPermissionKey("faq_replies", "create")).toBe("faq_reply:create");
|
||||
expect(getAttachmentPermissionKey("faq_replies", "read")).toBe("faq:read");
|
||||
expect(getAttachmentPermissionKey("faq_replies", "delete")).toBe("faq_attachments:delete");
|
||||
});
|
||||
|
||||
it("does not return generic attachments permissions", () => {
|
||||
expect(getAttachmentPermissionKey("unknown", "read")).toBeNull();
|
||||
expect(getAttachmentPermissionKey("precaution", "read")).not.toBe("attachments:read");
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { reactive } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useDrawerDirtyGuard } from "./drawerDirtyGuard";
|
||||
|
||||
vi.mock("element-plus", () => ({
|
||||
ElMessage: {
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("useDrawerDirtyGuard", () => {
|
||||
it("allows drawer close when the form has no changes", () => {
|
||||
const form = reactive({ name: "设备A", status: "启用" });
|
||||
const guard = useDrawerDirtyGuard(() => form);
|
||||
const done = vi.fn();
|
||||
|
||||
guard.syncBaseline();
|
||||
guard.beforeClose(done);
|
||||
|
||||
expect(done).toHaveBeenCalledTimes(1);
|
||||
expect(ElMessage.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks drawer close when the form has unsaved changes", () => {
|
||||
const form = reactive({ name: "设备A", status: "启用" });
|
||||
const guard = useDrawerDirtyGuard(() => form);
|
||||
const done = vi.fn();
|
||||
|
||||
guard.syncBaseline();
|
||||
form.name = "设备B";
|
||||
guard.beforeClose(done);
|
||||
|
||||
expect(done).not.toHaveBeenCalled();
|
||||
expect(ElMessage.warning).toHaveBeenCalledWith("请先保存或取消编辑");
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ProjectPublishSnapshot } from "../types/setupConfig";
|
||||
import { buildProjectDiffRows } from "./setupPublishWorkflow";
|
||||
import { serializeDiffValue } from "./setupDiffRows";
|
||||
|
||||
const emptyProjectSnapshot = (): ProjectPublishSnapshot => ({
|
||||
code: "",
|
||||
name: "",
|
||||
project_full_name: "",
|
||||
sponsor: "",
|
||||
protocol_no: "",
|
||||
lead_unit: "",
|
||||
principal_investigator: "",
|
||||
main_pm: "",
|
||||
research_analysis: "",
|
||||
research_product: "",
|
||||
control_product: "",
|
||||
indication: "",
|
||||
research_population: "",
|
||||
research_design: "",
|
||||
plan_start_date: "",
|
||||
plan_end_date: "",
|
||||
planned_site_count: null,
|
||||
planned_enrollment_count: null,
|
||||
status: "",
|
||||
visit_schedule: [],
|
||||
});
|
||||
|
||||
describe("setup publish workflow project diff rows", () => {
|
||||
it("shows visit schedule changes under the summary module", () => {
|
||||
const current = emptyProjectSnapshot();
|
||||
current.visit_schedule = [
|
||||
{
|
||||
visit_code: "V1",
|
||||
baseline_offset_days: 0,
|
||||
window_before_days: 0,
|
||||
window_after_days: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const rows = buildProjectDiffRows(current, emptyProjectSnapshot(), serializeDiffValue);
|
||||
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
moduleLabel: "方案摘要",
|
||||
path: "访视计划",
|
||||
changeType: "修改",
|
||||
localValue: "已配置列表(1项)",
|
||||
serverValue: "已配置列表(0项)",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not treat equivalent visit schedule arrays as changed", () => {
|
||||
const current = emptyProjectSnapshot();
|
||||
const base = emptyProjectSnapshot();
|
||||
current.visit_schedule = [
|
||||
{
|
||||
visit_code: "V1",
|
||||
baseline_offset_days: 0,
|
||||
window_before_days: 0,
|
||||
window_after_days: 3,
|
||||
},
|
||||
];
|
||||
base.visit_schedule = [
|
||||
{
|
||||
visit_code: "V1",
|
||||
baseline_offset_days: 0,
|
||||
window_before_days: 0,
|
||||
window_after_days: 3,
|
||||
},
|
||||
];
|
||||
|
||||
expect(buildProjectDiffRows(current, base, serializeDiffValue)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const read = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8");
|
||||
|
||||
describe("FAQ project permissions", () => {
|
||||
it("maps FAQ, category, and reply actions to backend operation permissions", () => {
|
||||
const source = read("../utils/permission.ts");
|
||||
|
||||
expect(source).toContain('"faq.update": "faq:update"');
|
||||
expect(source).toContain('"faq.delete": "faq:delete"');
|
||||
expect(source).toContain('"faq.category.read": "faq_category:read"');
|
||||
expect(source).toContain('"faq.category.create": "faq_category:create"');
|
||||
expect(source).toContain('"faq.category.update": "faq_category:update"');
|
||||
expect(source).toContain('"faq.category.delete": "faq_category:delete"');
|
||||
expect(source).toContain('"faq.reply.delete": "faq_reply:delete"');
|
||||
});
|
||||
|
||||
it("passes item update and delete permissions separately to the FAQ list", () => {
|
||||
const source = read("./Faq.vue");
|
||||
|
||||
expect(source).toContain('const canReadCategories = computed(() => can("faq.category.read"))');
|
||||
expect(source).toContain('const canUpdateFaq = computed(() => can("faq.update"))');
|
||||
expect(source).toContain('const canDeleteFaq = computed(() => can("faq.delete"))');
|
||||
expect(source).toContain('v-if="canReadCategories"');
|
||||
expect(source).toContain('if (!canReadCategories.value)');
|
||||
expect(source).toContain(':can-update="canUpdateFaq"');
|
||||
expect(source).toContain(':can-delete="canDeleteFaq"');
|
||||
});
|
||||
|
||||
it("does not expose FAQ item delete through creator ownership when backend requires delete permission", () => {
|
||||
const source = read("../components/FaqList.vue");
|
||||
|
||||
expect(source).toContain("canDelete: boolean");
|
||||
expect(source).toContain('v-if="canDelete"');
|
||||
expect(source).not.toContain("row.created_by === auth.user?.id");
|
||||
});
|
||||
|
||||
it("opens FAQ detail from any row cell while preserving action button click isolation", () => {
|
||||
const source = read("../components/FaqList.vue");
|
||||
|
||||
expect(source).toContain('@row-click="onRowClick"');
|
||||
expect(source).toContain("const onRowClick = (row: FaqItem) =>");
|
||||
expect(source).not.toContain('column?.property !== "question"');
|
||||
expect(source).toContain('@click.stop="remove(scope.row)"');
|
||||
});
|
||||
|
||||
it("splits FAQ category create update and delete controls by backend operation permissions", () => {
|
||||
const source = read("../components/FaqCategoryPanel.vue");
|
||||
|
||||
expect(source).toContain('const canCreateCategory = computed(() => can("faq.category.create"))');
|
||||
expect(source).toContain('const canUpdateCategory = computed(() => can("faq.category.update"))');
|
||||
expect(source).toContain('const canDeleteCategory = computed(() => can("faq.category.delete"))');
|
||||
expect(source).toContain(':can-create="canCreateCategory"');
|
||||
expect(source).toContain(':can-update="canUpdateCategory"');
|
||||
expect(source).toContain(':can-delete="canDeleteCategory"');
|
||||
expect(source).not.toContain("const canDelete = computed(() => isAdmin.value)");
|
||||
});
|
||||
|
||||
it("uses FAQ update and reply delete permissions for detail actions that hit protected endpoints", () => {
|
||||
const source = read("./FaqDetail.vue");
|
||||
|
||||
expect(source).toContain('return can("faq.update")');
|
||||
expect(source).toContain('return can("faq.reply.delete")');
|
||||
expect(source).toContain('const canReadCategories = computed(() => can("faq.category.read"))');
|
||||
expect(source).toContain('const canReadMembers = computed(() => can("project.members.list"))');
|
||||
expect(source).toContain("if (canReadCategories.value)");
|
||||
expect(source).toContain("if (!canReadMembers.value)");
|
||||
expect(source).not.toContain("item.value.created_by === auth.user?.id");
|
||||
expect(source).not.toContain("reply.created_by === auth.user?.id");
|
||||
});
|
||||
});
|
||||
@@ -18,18 +18,19 @@ describe("Login protocol agreement", () => {
|
||||
expect(protocolGuardIndex).toBeLessThan(loginCallIndex);
|
||||
});
|
||||
|
||||
it("offers remember password through browser credentials without local password storage", () => {
|
||||
it("does not access browser password credentials on the login page", () => {
|
||||
const source = readLoginView();
|
||||
|
||||
expect(source).toContain('v-model="form.rememberPassword"');
|
||||
expect(source).toContain("记住密码");
|
||||
expect(source).toContain('autocomplete="username"');
|
||||
expect(source).toContain('name="username"');
|
||||
expect(source).toContain(":name=\"form.rememberPassword ? 'password' : 'ctms-login-password'\"");
|
||||
expect(source).toContain("tryLoadBrowserCredential");
|
||||
expect(source).toContain("tryStoreBrowserCredential");
|
||||
expect(source).toContain("navigator.credentials");
|
||||
expect(source).toContain("password: true");
|
||||
expect(source).toContain('name="ctms-login-password"');
|
||||
expect(source).toContain('autocomplete="new-password"');
|
||||
expect(source).not.toContain('v-model="form.rememberPassword"');
|
||||
expect(source).not.toContain("记住密码");
|
||||
expect(source).not.toContain("tryLoadBrowserCredential");
|
||||
expect(source).not.toContain("tryStoreBrowserCredential");
|
||||
expect(source).not.toContain("navigator.credentials");
|
||||
expect(source).not.toContain("PasswordCredential");
|
||||
expect(source).not.toContain('localStorage.setItem("ctms_saved_password"');
|
||||
expect(source).not.toContain("localStorage.setItem('ctms_saved_password'");
|
||||
});
|
||||
@@ -54,11 +55,11 @@ describe("Login protocol agreement", () => {
|
||||
expect(source).toContain("confirmProtocol");
|
||||
});
|
||||
|
||||
it("avoids browser password caching when remember password is off", () => {
|
||||
it("avoids browser password caching", () => {
|
||||
const source = readLoginView();
|
||||
|
||||
expect(source).toContain(":name=\"form.rememberPassword ? 'password' : 'ctms-login-password'\"");
|
||||
expect(source).toContain(":autocomplete=\"form.rememberPassword ? 'current-password' : 'new-password'\"");
|
||||
expect(source).toContain('name="ctms-login-password"');
|
||||
expect(source).toContain('autocomplete="new-password"');
|
||||
expect(source).not.toContain('autocomplete="current-password"');
|
||||
});
|
||||
|
||||
@@ -87,4 +88,11 @@ describe("Login protocol agreement", () => {
|
||||
expect(source).toContain("已安全退出");
|
||||
expect(source).toContain(".logout-notice");
|
||||
});
|
||||
|
||||
it("keeps the login card wide enough on desktop while remaining responsive", () => {
|
||||
const source = readLoginView();
|
||||
|
||||
expect(source).toContain("width: clamp(480px, 34vw, 560px);");
|
||||
expect(source).toContain("max-width: calc(100vw - 40px);");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,8 +80,8 @@
|
||||
placeholder="请输入密码"
|
||||
show-password
|
||||
size="large"
|
||||
:name="form.rememberPassword ? 'password' : 'ctms-login-password'"
|
||||
:autocomplete="form.rememberPassword ? 'current-password' : 'new-password'"
|
||||
name="ctms-login-password"
|
||||
autocomplete="new-password"
|
||||
class="login-input"
|
||||
>
|
||||
<template #prefix>
|
||||
@@ -94,9 +94,6 @@
|
||||
</el-form-item>
|
||||
|
||||
<div class="login-options">
|
||||
<el-checkbox v-model="form.rememberPassword" class="remember-checkbox">
|
||||
<span class="remember-text">记住密码</span>
|
||||
</el-checkbox>
|
||||
<el-checkbox v-model="form.agreeProtocol" class="protocol-checkbox">
|
||||
<span class="protocol-text">
|
||||
我已阅读并同意
|
||||
@@ -160,17 +157,12 @@ import { authProtocolSections } from "../content/authProtocol";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const REMEMBER_PASSWORD_KEY = "ctms_remember_password";
|
||||
const AGREE_PROTOCOL_KEY = "ctms_agree_protocol";
|
||||
|
||||
type PasswordCredentialConstructor = new (data: { id: string; password: string; name?: string }) => Credential;
|
||||
type StoredPasswordCredential = Credential & { id?: string; password?: string };
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const form = reactive({
|
||||
email: "",
|
||||
password: "",
|
||||
rememberPassword: false,
|
||||
agreeProtocol: false,
|
||||
});
|
||||
|
||||
@@ -210,9 +202,7 @@ onMounted(async () => {
|
||||
};
|
||||
}
|
||||
form.email = localStorage.getItem("ctms_last_login_email") || "";
|
||||
form.rememberPassword = localStorage.getItem(REMEMBER_PASSWORD_KEY) === "true";
|
||||
form.agreeProtocol = localStorage.getItem(AGREE_PROTOCOL_KEY) === "true";
|
||||
await tryLoadBrowserCredential();
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -222,40 +212,6 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
const tryLoadBrowserCredential = async () => {
|
||||
if (!form.rememberPassword || !navigator.credentials?.get) return;
|
||||
|
||||
try {
|
||||
const credential = (await navigator.credentials.get({
|
||||
password: true,
|
||||
mediation: "optional",
|
||||
} as CredentialRequestOptions)) as StoredPasswordCredential | null;
|
||||
if (!credential?.password) return;
|
||||
form.email = credential.id || form.email;
|
||||
form.password = credential.password;
|
||||
} catch {
|
||||
// 浏览器可能不支持读取密码凭据,保留用户手动输入流程。
|
||||
}
|
||||
};
|
||||
|
||||
const tryStoreBrowserCredential = async (email: string, password: string) => {
|
||||
if (!form.rememberPassword) {
|
||||
localStorage.removeItem(REMEMBER_PASSWORD_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(REMEMBER_PASSWORD_KEY, "true");
|
||||
const PasswordCredential = (window as typeof window & { PasswordCredential?: PasswordCredentialConstructor })
|
||||
.PasswordCredential;
|
||||
if (!navigator.credentials?.store || !PasswordCredential) return;
|
||||
|
||||
try {
|
||||
await navigator.credentials.store(new PasswordCredential({ id: email, password, name: email }));
|
||||
} catch {
|
||||
// 浏览器或用户可能拒绝保存凭据,不影响登录主流程。
|
||||
}
|
||||
};
|
||||
|
||||
const openProtocolDialog = () => {
|
||||
protocolDialogVisible.value = true;
|
||||
};
|
||||
@@ -276,7 +232,6 @@ const onSubmit = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
await auth.login(form.email, form.password);
|
||||
await tryStoreBrowserCredential(form.email, form.password);
|
||||
const studyStore = useStudyStore();
|
||||
const userKey = auth.user?.email || form.email;
|
||||
await studyStore.restoreStudyForUser(userKey, { preferActive: !!auth.user?.is_admin });
|
||||
@@ -350,9 +305,8 @@ const onSubmit = async () => {
|
||||
.login-container {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
padding: 0 20px;
|
||||
width: clamp(480px, 34vw, 560px);
|
||||
max-width: calc(100vw - 40px);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
@@ -511,7 +465,6 @@ const onSubmit = async () => {
|
||||
margin: -4px 0 26px;
|
||||
}
|
||||
|
||||
.remember-checkbox,
|
||||
.protocol-checkbox {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -519,12 +472,10 @@ const onSubmit = async () => {
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.remember-checkbox :deep(.el-checkbox__input),
|
||||
.protocol-checkbox :deep(.el-checkbox__input) {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.remember-checkbox :deep(.el-checkbox__label),
|
||||
.protocol-checkbox :deep(.el-checkbox__label) {
|
||||
display: block;
|
||||
padding-left: 10px;
|
||||
@@ -532,13 +483,6 @@ const onSubmit = async () => {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.remember-text {
|
||||
color: #52657b;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.protocol-text {
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readProfileView = () => readFileSync(resolve(__dirname, "./ProfileSettings.vue"), "utf8");
|
||||
|
||||
describe("profile settings autocomplete", () => {
|
||||
it("disables browser autofill for profile name, department, and current password", () => {
|
||||
const source = readProfileView();
|
||||
|
||||
expect(source).toContain('<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" class="form" autocomplete="off">');
|
||||
expect(source).toContain('autocomplete="off"');
|
||||
expect(source).toContain('autocomplete="new-password"');
|
||||
expect(source).toContain('name="ctms-profile-current-password"');
|
||||
});
|
||||
});
|
||||
@@ -1,70 +1,103 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card class="unified-shell">
|
||||
<h3 class="title">{{ TEXT.modules.profile.title }}</h3>
|
||||
<p class="subtitle">{{ TEXT.modules.profile.subtitle }}</p>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" class="form" autocomplete="off">
|
||||
<el-form-item :label="TEXT.common.fields.avatar">
|
||||
<div class="avatar-row">
|
||||
<el-avatar :size="64" :src="avatarPreview" :alt="form.full_name || form.email">
|
||||
{{ form.full_name?.charAt(0)?.toUpperCase() || form.email?.charAt(0)?.toUpperCase() }}
|
||||
</el-avatar>
|
||||
<el-upload
|
||||
class="avatar-uploader"
|
||||
:show-file-list="false"
|
||||
action="/api/v1/auth/me/avatar"
|
||||
name="file"
|
||||
:headers="uploadHeaders"
|
||||
:on-success="onAvatarUploaded"
|
||||
:on-error="onAvatarError"
|
||||
>
|
||||
<el-button>{{ TEXT.modules.profile.uploadAvatar }}</el-button>
|
||||
</el-upload>
|
||||
<div class="profile-layout">
|
||||
<aside class="profile-aside">
|
||||
<div class="avatar-panel">
|
||||
<el-avatar :size="88" :src="avatarPreview" :alt="form.full_name || form.email" class="profile-avatar">
|
||||
{{ profileInitial }}
|
||||
</el-avatar>
|
||||
<div class="avatar-meta">
|
||||
<div class="avatar-name">{{ form.full_name || TEXT.modules.profile.title }}</div>
|
||||
<div class="avatar-email">{{ form.email }}</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.email">
|
||||
<el-input v-model="form.email" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.name" prop="full_name">
|
||||
<el-input v-model="form.full_name" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.clinicalDepartment" prop="clinical_department">
|
||||
<el-input v-model="form.clinical_department" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.clinicalDepartment" />
|
||||
</el-form-item>
|
||||
<el-divider />
|
||||
<el-form-item :label="TEXT.modules.profile.currentPassword" prop="current_password">
|
||||
<el-input
|
||||
v-model="form.current_password"
|
||||
type="password"
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
name="ctms-profile-current-password"
|
||||
:placeholder="TEXT.modules.profile.currentPasswordHint"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.modules.profile.newPassword" prop="password">
|
||||
<el-input v-model="form.password" type="password" show-password :placeholder="TEXT.modules.profile.newPasswordHint" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.modules.profile.confirmPassword" prop="confirmPassword">
|
||||
<el-input v-model="form.confirmPassword" type="password" show-password :placeholder="TEXT.modules.profile.confirmPasswordHint" />
|
||||
</el-form-item>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-upload
|
||||
class="avatar-uploader"
|
||||
:show-file-list="false"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
action="/api/v1/auth/me/avatar"
|
||||
name="file"
|
||||
:headers="uploadHeaders"
|
||||
:before-upload="beforeAvatarUpload"
|
||||
:on-success="onAvatarUploaded"
|
||||
:on-error="onAvatarError"
|
||||
>
|
||||
<el-button :icon="Upload" class="upload-button">{{ TEXT.modules.profile.uploadAvatar }}</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</aside>
|
||||
|
||||
<main class="profile-main">
|
||||
<header class="profile-header">
|
||||
<div>
|
||||
<h3 class="title">{{ TEXT.modules.profile.title }}</h3>
|
||||
</div>
|
||||
<el-button class="dialog-close" text :icon="Close" aria-label="关闭个人中心" @click="emit('close-request')" />
|
||||
</header>
|
||||
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="112px" class="form" autocomplete="off">
|
||||
<section class="form-section">
|
||||
<div class="section-heading">
|
||||
<span class="section-kicker">Account</span>
|
||||
<h4>基本信息</h4>
|
||||
</div>
|
||||
<el-form-item :label="TEXT.common.fields.email">
|
||||
<el-input v-model="form.email" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.name" prop="full_name">
|
||||
<el-input v-model="form.full_name" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.clinicalDepartment" prop="clinical_department">
|
||||
<el-input v-model="form.clinical_department" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.clinicalDepartment" />
|
||||
</el-form-item>
|
||||
</section>
|
||||
|
||||
<section class="form-section form-section--password">
|
||||
<div class="section-heading">
|
||||
<span class="section-kicker">Security</span>
|
||||
<h4>修改密码</h4>
|
||||
</div>
|
||||
<el-form-item :label="TEXT.modules.profile.currentPassword" prop="current_password">
|
||||
<el-input
|
||||
v-model="form.current_password"
|
||||
type="password"
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
name="ctms-profile-current-password"
|
||||
:placeholder="TEXT.modules.profile.currentPasswordHint"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.modules.profile.newPassword" prop="password">
|
||||
<el-input v-model="form.password" type="password" show-password :placeholder="TEXT.modules.profile.newPasswordHint" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.modules.profile.confirmPassword" prop="confirmPassword">
|
||||
<el-input v-model="form.confirmPassword" type="password" show-password :placeholder="TEXT.modules.profile.confirmPasswordHint" />
|
||||
</el-form-item>
|
||||
</section>
|
||||
|
||||
<div class="actions">
|
||||
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import type { FormInstance, FormRules, UploadRawFile } from "element-plus";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Close, Upload } from "@element-plus/icons-vue";
|
||||
import { updateProfile, fetchMe } from "../api/auth";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { getToken } from "../utils/auth";
|
||||
import { TEXT, requiredMessage } from "../locales";
|
||||
|
||||
const emit = defineEmits<{
|
||||
"close-request": [];
|
||||
"dirty-change": [dirty: boolean];
|
||||
saved: [];
|
||||
}>();
|
||||
const auth = useAuthStore();
|
||||
const formRef = ref<FormInstance>();
|
||||
const submitting = ref(false);
|
||||
@@ -76,10 +109,22 @@ const form = reactive({
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
const savedProfile = ref({
|
||||
full_name: "",
|
||||
clinical_department: "",
|
||||
});
|
||||
const avatarPreview = ref<string | undefined>();
|
||||
const uploadHeaders = computed<Record<string, string>>(() => ({
|
||||
Authorization: `Bearer ${getToken() || ""}`,
|
||||
}));
|
||||
const profileInitial = computed(() => (form.full_name?.charAt(0) || form.email?.charAt(0) || "?").toUpperCase());
|
||||
const avatarAcceptedTypes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
||||
const hasUnsavedChanges = computed(
|
||||
() =>
|
||||
form.full_name !== savedProfile.value.full_name ||
|
||||
form.clinical_department !== savedProfile.value.clinical_department ||
|
||||
Boolean(form.current_password || form.password || form.confirmPassword)
|
||||
);
|
||||
|
||||
const rules: FormRules<typeof form> = {
|
||||
full_name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), trigger: "blur" }],
|
||||
@@ -128,6 +173,10 @@ const loadProfile = async () => {
|
||||
form.email = data.email;
|
||||
form.full_name = data.full_name;
|
||||
form.clinical_department = data.clinical_department;
|
||||
savedProfile.value = {
|
||||
full_name: data.full_name,
|
||||
clinical_department: data.clinical_department,
|
||||
};
|
||||
avatarPreview.value = data.avatar_url || undefined;
|
||||
};
|
||||
|
||||
@@ -148,7 +197,12 @@ const onSubmit = async () => {
|
||||
form.current_password = "";
|
||||
form.password = "";
|
||||
form.confirmPassword = "";
|
||||
savedProfile.value = {
|
||||
full_name: form.full_name,
|
||||
clinical_department: form.clinical_department,
|
||||
};
|
||||
avatarPreview.value = auth.user?.avatar_url || avatarPreview.value;
|
||||
emit("saved");
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.response?.data?.message || TEXT.modules.profile.updateFailed);
|
||||
} finally {
|
||||
@@ -163,6 +217,12 @@ const onAvatarUploaded = async () => {
|
||||
ElMessage.success(TEXT.modules.profile.avatarUpdated);
|
||||
};
|
||||
|
||||
const beforeAvatarUpload = (file: UploadRawFile) => {
|
||||
if (avatarAcceptedTypes.has(file.type)) return true;
|
||||
ElMessage.error(TEXT.modules.profile.avatarTypeInvalid);
|
||||
return false;
|
||||
};
|
||||
|
||||
const onAvatarError = (err: any) => {
|
||||
ElMessage.error(err?.response?.data?.message || TEXT.modules.profile.avatarUploadFailed);
|
||||
};
|
||||
@@ -170,24 +230,205 @@ const onAvatarError = (err: any) => {
|
||||
onMounted(() => {
|
||||
loadProfile();
|
||||
});
|
||||
|
||||
watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: true });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.profile-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
min-height: 620px;
|
||||
}
|
||||
|
||||
.profile-aside {
|
||||
padding: 48px 32px;
|
||||
border-right: 1px solid #e5ebf2;
|
||||
background: linear-gradient(180deg, #f8fbfd 0%, #f1f5f9 100%);
|
||||
}
|
||||
|
||||
.avatar-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
--el-avatar-bg-color: #64748b;
|
||||
color: #fff;
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
border: 4px solid #fff;
|
||||
box-shadow: 0 12px 30px rgba(51, 65, 85, 0.16);
|
||||
}
|
||||
|
||||
.avatar-meta {
|
||||
width: 100%;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.avatar-name {
|
||||
overflow: hidden;
|
||||
color: #172033;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.avatar-email {
|
||||
overflow: hidden;
|
||||
margin-top: 6px;
|
||||
color: #718096;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.avatar-uploader {
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.upload-button {
|
||||
border-color: #cbd5e1;
|
||||
color: #40566f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.profile-main {
|
||||
padding: 42px 48px 36px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.dialog-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-top: -4px;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
color: #172033;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.subtitle {
|
||||
margin: 6px 0 16px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.form {
|
||||
max-width: 520px;
|
||||
max-width: 620px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.form-section--password {
|
||||
margin-top: 26px;
|
||||
padding-top: 28px;
|
||||
border-top: 1px solid #e5ebf2;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
margin-bottom: 18px;
|
||||
padding-left: 112px;
|
||||
}
|
||||
|
||||
.section-kicker {
|
||||
display: block;
|
||||
color: #7f92ad;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.section-heading h4 {
|
||||
margin: 6px 0 0;
|
||||
color: #1f2a3d;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.form :deep(.el-form-item) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form :deep(.el-form-item__label) {
|
||||
color: #6f83a3;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form :deep(.el-input__wrapper) {
|
||||
min-height: 44px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 0 1px #dfe6ee inset;
|
||||
}
|
||||
|
||||
.form :deep(.el-input__wrapper.is-focus) {
|
||||
box-shadow: 0 0 0 1px #3f8f6b inset, 0 0 0 3px rgba(63, 143, 107, 0.12);
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 12px;
|
||||
margin-top: 28px;
|
||||
padding-left: 112px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.actions :deep(.el-button) {
|
||||
min-width: 96px;
|
||||
min-height: 40px;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.profile-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.profile-aside {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #e5ebf2;
|
||||
}
|
||||
|
||||
.profile-main {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.profile-main {
|
||||
padding: 24px 18px;
|
||||
}
|
||||
|
||||
.form {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.section-heading,
|
||||
.actions {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readRegisterView = () => readFileSync(resolve(__dirname, "./Register.vue"), "utf8");
|
||||
|
||||
describe("Register protocol agreement", () => {
|
||||
it("opens readable service terms and privacy policy dialogs from the agreement text", () => {
|
||||
const source = readRegisterView();
|
||||
|
||||
expect(source).toContain('@click.stop.prevent="openProtocolDialog(\'terms\')"');
|
||||
expect(source).toContain('@click.stop.prevent="openProtocolDialog(\'privacy\')"');
|
||||
expect(source).toContain('v-model="protocolDialogVisible"');
|
||||
expect(source).toContain("activeProtocolTitle");
|
||||
expect(source).toContain("activeProtocolSections");
|
||||
expect(source).toContain("closeProtocolDialog");
|
||||
expect(source).toContain("serviceTermsSections");
|
||||
expect(source).toContain("privacyPolicySections");
|
||||
});
|
||||
|
||||
it("shows an in-page pending admin approval notice after successful registration", () => {
|
||||
const source = readRegisterView();
|
||||
const successFlagIndex = source.indexOf("registrationSubmitted.value = true");
|
||||
const resetIndex = source.indexOf("Object.assign(form");
|
||||
|
||||
expect(source).toContain('v-if="registrationSubmitted"');
|
||||
expect(source).toContain("注册申请已提交");
|
||||
expect(source).toContain("待管理员审核通过后方可登录");
|
||||
expect(source).toContain("管理员审核通过前,请勿重复提交注册申请");
|
||||
expect(source).toContain("registrationSubmitted");
|
||||
expect(source).not.toContain("approval-login-link");
|
||||
expect(successFlagIndex).toBeGreaterThan(-1);
|
||||
expect(resetIndex).toBeGreaterThan(-1);
|
||||
expect(successFlagIndex).toBeLessThan(resetIndex);
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./StudyHome.vue"), "utf8");
|
||||
|
||||
describe("StudyHome role labels", () => {
|
||||
it("uses permission template names for the current project role label", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("useRoleTemplateMeta");
|
||||
expect(source).toContain("const { roleLabel: displayRoleLabel, loadRoleTemplates } = useRoleTemplateMeta();");
|
||||
expect(source).toContain("const roleLabel = computed(() => displayRoleLabel(projectRole.value));");
|
||||
expect(source).toContain("loadRoleTemplates();");
|
||||
expect(source).not.toContain("displayEnum(TEXT.enums.userRole, projectRole.value)");
|
||||
});
|
||||
|
||||
it("gates the finance KPI by the contract fee read permission", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('v-if="canReadFinanceContracts"');
|
||||
expect(source).toContain('const canReadFinanceContracts = computed');
|
||||
expect(source).toContain("isApiPermissionAllowed");
|
||||
expect(source).toContain('study.currentPermissions?.[role]?.["fees_contracts:read"]');
|
||||
expect(source).toContain('canReadFinanceContracts.value ? fetchFinanceSummary(studyId) : Promise.resolve(null)');
|
||||
});
|
||||
});
|
||||
@@ -59,7 +59,8 @@ describe("ApiPermissions.vue", () => {
|
||||
const source = readFileSync(resolve(__dirname, "./ApiPermissions.vue"), "utf8");
|
||||
|
||||
expect(source).toContain('label="角色权限"');
|
||||
expect(source).toContain('label="权限监控"');
|
||||
expect(source).not.toContain('label="权限监控"');
|
||||
expect(source).not.toContain("<PermissionMonitoring");
|
||||
});
|
||||
|
||||
it("has save button disabled when not dirty", () => {
|
||||
|
||||
@@ -19,10 +19,6 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="permission-actions">
|
||||
<el-button @click="resetMetrics" :loading="resetting">
|
||||
<el-icon><RefreshRight /></el-icon>
|
||||
刷新指标
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="!dirty" @click="save">
|
||||
<el-icon><Check /></el-icon>
|
||||
保存
|
||||
@@ -46,17 +42,6 @@
|
||||
@update="onApiMatrixUpdate"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 权限监控 -->
|
||||
<el-tab-pane label="权限监控" name="monitoring">
|
||||
<PermissionMonitoring
|
||||
:is-admin="isAdmin"
|
||||
:metrics="metrics"
|
||||
:health="health"
|
||||
:alerts="alerts"
|
||||
@refresh="loadMonitoringData"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,39 +51,24 @@
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Key, RefreshRight, Check } from "@element-plus/icons-vue";
|
||||
import { Key, Check } from "@element-plus/icons-vue";
|
||||
import type {
|
||||
ApiEndpointPermissionsResponse,
|
||||
PermissionMetricsResponse,
|
||||
CacheStatsResponse,
|
||||
AlertsResponse,
|
||||
HealthResponse,
|
||||
} from "@/types/api";
|
||||
import {
|
||||
fetchApiEndpointPermissions,
|
||||
updateApiEndpointPermissions,
|
||||
fetchPermissionMetrics,
|
||||
fetchCacheStats,
|
||||
fetchPermissionAlerts,
|
||||
fetchPermissionHealth,
|
||||
resetPermissionMetrics,
|
||||
} from "@/api/projectPermissions";
|
||||
import { useStudyStore } from "@/store/study";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { isSystemAdmin } from "@/utils/roles";
|
||||
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
|
||||
import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
|
||||
import PermissionTemplateSelector from "@/components/PermissionTemplateSelector.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const studyStore = useStudyStore();
|
||||
const auth = useAuthStore();
|
||||
const isAdmin = computed(() => isSystemAdmin(auth.user));
|
||||
|
||||
const activeTab = ref<"api" | "monitoring">("api");
|
||||
const activeTab = ref<"api">("api");
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const resetting = ref(false);
|
||||
|
||||
const project = computed(() => studyStore.currentStudy);
|
||||
const studyId = computed(() => {
|
||||
@@ -109,11 +79,6 @@ const studyId = computed(() => {
|
||||
// 权限数据
|
||||
const apiMatrix = ref<ApiEndpointPermissionsResponse | null>(null);
|
||||
|
||||
// 监控数据
|
||||
const metrics = ref<PermissionMetricsResponse | null>(null);
|
||||
const health = ref<HealthResponse | null>(null);
|
||||
const alerts = ref<AlertsResponse | null>(null);
|
||||
|
||||
// 脏值检测
|
||||
const dirty = ref(false);
|
||||
|
||||
@@ -133,25 +98,6 @@ const loadPermissionData = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadMonitoringData = async () => {
|
||||
if (!studyId.value) return;
|
||||
|
||||
try {
|
||||
const [metricsRes, healthRes, alertsRes] = await Promise.all([
|
||||
fetchPermissionMetrics(),
|
||||
fetchPermissionHealth(),
|
||||
fetchPermissionAlerts(undefined, 20),
|
||||
]);
|
||||
|
||||
metrics.value = metricsRes.data;
|
||||
health.value = healthRes.data;
|
||||
alerts.value = alertsRes.data;
|
||||
} catch (error) {
|
||||
ElMessage.error("加载监控数据失败");
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const onApiMatrixUpdate = (newMatrix: ApiEndpointPermissionsResponse) => {
|
||||
apiMatrix.value = newMatrix;
|
||||
dirty.value = true;
|
||||
@@ -209,20 +155,6 @@ const save = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const resetMetrics = async () => {
|
||||
resetting.value = true;
|
||||
try {
|
||||
await resetPermissionMetrics();
|
||||
await loadMonitoringData();
|
||||
ElMessage.success("指标已重置");
|
||||
} catch (error) {
|
||||
ElMessage.error("重置指标失败");
|
||||
console.error(error);
|
||||
} finally {
|
||||
resetting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadPermissionData();
|
||||
});
|
||||
|
||||
@@ -151,6 +151,13 @@ describe("permission management custom roles", () => {
|
||||
expect(source).not.toContain("project.members.manage");
|
||||
});
|
||||
|
||||
it("shows member names without avatar icons in the member management table", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).not.toContain("member-avatar");
|
||||
expect(source).not.toContain("memberInitial(row.full_name)");
|
||||
});
|
||||
|
||||
it("uses QA and CTA as preset project permission role keys", () => {
|
||||
const source = readSource();
|
||||
|
||||
@@ -344,7 +351,9 @@ describe("permission management custom roles", () => {
|
||||
it("does not render the duplicated monitoring header", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('<PermissionMonitoring :is-admin="isAdmin" />');
|
||||
expect(source).not.toContain("<PermissionMonitoring");
|
||||
expect(source).not.toContain('activeTab === "monitoring"');
|
||||
expect(source).not.toContain("/admin/permissions/monitoring");
|
||||
expect(source).not.toContain("实时监控权限使用情况、趋势分析与异常告警");
|
||||
expect(source).not.toContain("重置指标");
|
||||
expect(source).not.toContain("refreshMonitoring");
|
||||
|
||||
@@ -84,7 +84,6 @@
|
||||
<el-table-column prop="full_name" label="姓名" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<div class="member-name-cell">
|
||||
<span class="member-avatar">{{ (row.full_name || '?')[0] }}</span>
|
||||
<span class="member-name">{{ row.full_name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -216,18 +215,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ══════════════════════════════════════
|
||||
权限监控
|
||||
══════════════════════════════════════ -->
|
||||
<template v-else-if="activeTab === 'monitoring'">
|
||||
<div class="perm-body">
|
||||
<div class="perm-body-padded">
|
||||
<PermissionMonitoring :is-admin="isAdmin" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<!-- ══════════════════════════════════════
|
||||
角色管理抽屉
|
||||
══════════════════════════════════════ -->
|
||||
@@ -613,7 +600,6 @@ import {
|
||||
} from "@/utils/projectPermissionModules";
|
||||
import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
|
||||
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
|
||||
import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
|
||||
import PermissionTemplateSelector from "@/components/PermissionTemplateSelector.vue";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -658,16 +644,15 @@ const memberRoleLabels = (row: MemberRoleRow) => {
|
||||
};
|
||||
|
||||
// ── 顶层标签 ──
|
||||
const tabFromPath = (): "project" | "system" | "monitoring" => {
|
||||
const tabFromPath = (): "project" | "system" => {
|
||||
const p = route.path;
|
||||
if (p.endsWith("/system")) return "system";
|
||||
if (p.endsWith("/monitoring")) return "monitoring";
|
||||
return "project";
|
||||
};
|
||||
const activeTab = computed({
|
||||
get: () => tabFromPath(),
|
||||
set: (tab: "project" | "system" | "monitoring") => {
|
||||
const pathMap = { system: "/admin/permissions/system", project: "/admin/permissions/project", monitoring: "/admin/permissions/monitoring" };
|
||||
set: (tab: "project" | "system") => {
|
||||
const pathMap = { system: "/admin/permissions/system", project: "/admin/permissions/project" };
|
||||
router.push({ path: pathMap[tab], query: route.query });
|
||||
},
|
||||
});
|
||||
@@ -1782,21 +1767,6 @@ onMounted(async () => {
|
||||
.member-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.member-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readProjectMembers = () => readFileSync(resolve(__dirname, "./ProjectMembers.vue"), "utf8");
|
||||
|
||||
describe("ProjectMembers user directory access", () => {
|
||||
it("does not load the admin-only global user directory", () => {
|
||||
const source = readProjectMembers();
|
||||
|
||||
expect(source).not.toContain("fetchUsers");
|
||||
expect(source).not.toContain("../../api/users");
|
||||
expect(source).toContain('const canListMembers = computed(() => permission.can("project.members.list"));');
|
||||
expect(source).toContain('const canListMemberCandidates = computed(() => permission.can("project.members.candidates"));');
|
||||
expect(source).toContain('const canCreateMember = computed(() => permission.can("project.members.create"));');
|
||||
expect(source).toContain('const canUpdateMember = computed(() => permission.can("project.members.update"));');
|
||||
expect(source).toContain('const canDeleteMember = computed(() => permission.can("project.members.delete"));');
|
||||
expect(source).toContain("if (!canListMembers.value");
|
||||
expect(source).toContain("if (!canListMemberCandidates.value");
|
||||
expect(source).toContain("return;");
|
||||
expect(source).toContain('v-if="canAddMember"');
|
||||
expect(source).toContain("listMemberCandidates(projectId.value, { limit: 500 })");
|
||||
expect(source).not.toContain("project.members.manage");
|
||||
});
|
||||
|
||||
it("uses the member API embedded user data when the global directory is unavailable", () => {
|
||||
const source = readProjectMembers();
|
||||
|
||||
expect(source).toContain("users.value = members.value");
|
||||
expect(source).toContain(".map((member) => member.user)");
|
||||
expect(source).toContain("users.value.find((u) => u.id === m.user_id) || m.user");
|
||||
});
|
||||
|
||||
it("prevents project managers from editing themselves or assigning higher roles", () => {
|
||||
const source = readProjectMembers();
|
||||
|
||||
expect(source).toContain("const roleRank: Record<string, number>");
|
||||
expect(source).toContain("if (row.user_id === auth.user?.id) return false;");
|
||||
expect(source).toContain("if (row.user?.is_admin) return false;");
|
||||
expect(source).toContain("const canAssignRole = (role: string)");
|
||||
expect(source).toContain(":disabled=\"!canAssignRole(role.value)\"");
|
||||
expect(source).toContain(':disabled="!canEditMember(scope.row)');
|
||||
expect(source).toContain(':disabled="!canDeleteProjectMember(scope.row)"');
|
||||
});
|
||||
|
||||
it("uses permission template names for project role display options", () => {
|
||||
const source = readProjectMembers();
|
||||
|
||||
expect(source).toContain("useRoleTemplateMeta");
|
||||
expect(source).toContain("const { roleLabel, roleOptionsFor, loadRoleTemplates } = useRoleTemplateMeta();");
|
||||
expect(source).toContain("const roleOptions = computed(() => roleOptionsFor(ROLE_KEYS));");
|
||||
expect(source).toContain("{{ roleLabel(scope.row.role_in_study) }}");
|
||||
expect(source).not.toContain("displayEnum(TEXT.enums.userRole, scope.row.role_in_study)");
|
||||
expect(source).not.toContain(':label="TEXT.enums.userRole.PM"');
|
||||
});
|
||||
|
||||
it("offers QA and CTA as project member role presets", () => {
|
||||
const source = readProjectMembers();
|
||||
|
||||
expect(source).toContain('"QA"');
|
||||
expect(source).toContain('"CTA"');
|
||||
expect(source).toContain("QA: 60");
|
||||
expect(source).toContain("CTA: 40");
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSiteForm = () => readFileSync(resolve(__dirname, "./SiteForm.vue"), "utf8");
|
||||
const readSiteTypes = () => readFileSync(resolve(__dirname, "../../types/api.ts"), "utf8");
|
||||
|
||||
describe("Admin site form phone field", () => {
|
||||
it("hydrates and submits the phone field", () => {
|
||||
const formSource = readSiteForm();
|
||||
const typeSource = readSiteTypes();
|
||||
|
||||
expect(formSource).toContain("form.phone = props.site.phone || (props.site as any)?.contact_phone || \"\"");
|
||||
expect(formSource).toContain("phone: form.phone");
|
||||
expect(formSource).toContain('v-model="form.phone"');
|
||||
expect(typeSource).toContain("phone?: string | null;");
|
||||
expect(typeSource).toContain("contact_phone?: string | null;");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Admin site PM user loading", () => {
|
||||
it("uses project member embedded user data instead of the global user directory", () => {
|
||||
const sitesSource = readFileSync(resolve(__dirname, "./Sites.vue"), "utf8");
|
||||
const formSource = readSiteForm();
|
||||
|
||||
expect(sitesSource).not.toContain("../../api/users");
|
||||
expect(sitesSource).not.toContain("fetchUsers");
|
||||
expect(sitesSource).toContain(".map((member) => member.user)");
|
||||
expect(formSource).toContain("m.user?.full_name");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div class="system-monitoring-page">
|
||||
<PermissionMonitoring :is-admin="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.system-monitoring-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 48px);
|
||||
height: calc(100dvh - 48px);
|
||||
min-height: 0;
|
||||
margin: -6px -8px;
|
||||
overflow: hidden;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
</style>
|
||||
@@ -1,17 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readResetForm = () => readFileSync(resolve(__dirname, "./UserResetPassword.vue"), "utf8");
|
||||
|
||||
describe("Admin reset password autofill", () => {
|
||||
it("disables browser autofill for confirm username and manual password", () => {
|
||||
const source = readResetForm();
|
||||
|
||||
expect(source).toContain('autocomplete="off" name="ctms-reset-confirm-username"');
|
||||
expect(source).toContain('autocomplete="new-password"');
|
||||
expect(source).toContain('name="ctms-reset-temp-password"');
|
||||
expect(source).toContain('name="ctms-reset-manual-password"');
|
||||
expect(source).toContain(":prop=\"form.mode === 'auto' ? 'tempPassword' : 'manualPassword'\"");
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readUsersView = () => readFileSync(resolve(__dirname, "./Users.vue"), "utf8");
|
||||
const readUserForm = () => readFileSync(resolve(__dirname, "./UserForm.vue"), "utf8");
|
||||
const readLocale = () => readFileSync(resolve(__dirname, "../../locales/zh-CN.ts"), "utf8");
|
||||
|
||||
describe("Admin users table labels", () => {
|
||||
it("shows clinical_department as 科室 in the account management table", () => {
|
||||
const viewSource = readUsersView();
|
||||
const localeSource = readLocale();
|
||||
|
||||
expect(viewSource).toContain(':label="TEXT.modules.adminUsers.clinicalDepartmentLabel"');
|
||||
expect(viewSource).not.toContain(':label="TEXT.common.fields.clinicalDepartment"');
|
||||
expect(localeSource).toContain('clinicalDepartmentLabel: "科室"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Admin users table layout", () => {
|
||||
it("uses a wider name column, even remaining columns, and compact icon actions", () => {
|
||||
const viewSource = readUsersView();
|
||||
|
||||
expect(viewSource).toContain('table-layout="fixed"');
|
||||
expect(viewSource).toContain(':label="TEXT.common.fields.name" width="360"');
|
||||
expect(viewSource).not.toContain('width="20%"');
|
||||
expect(viewSource).not.toContain('width="180"');
|
||||
expect(viewSource).not.toContain('width="160"');
|
||||
expect(viewSource).not.toContain('width="100"');
|
||||
expect(viewSource).not.toContain('fixed="right"');
|
||||
expect(viewSource).toContain('class="action-btn"');
|
||||
expect(viewSource).toContain("width: 28px;");
|
||||
expect(viewSource).toContain("gap: 4px;");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Admin users summary header", () => {
|
||||
it("keeps the management summary header compact", () => {
|
||||
const viewSource = readUsersView();
|
||||
|
||||
expect(viewSource).toContain("padding: 10px 16px;");
|
||||
expect(viewSource).toContain("padding: 9px 12px;");
|
||||
expect(viewSource).toContain("width: 32px;");
|
||||
expect(viewSource).toContain("height: 32px;");
|
||||
expect(viewSource).toContain("font-size: 20px;");
|
||||
expect(viewSource).toContain("font-size: 11px;");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Admin users filters", () => {
|
||||
it("applies keyword automatically and status filters from the first page", () => {
|
||||
const viewSource = readUsersView();
|
||||
|
||||
expect(viewSource).toContain("@keyup.enter=\"applyFilters\"");
|
||||
expect(viewSource).toContain("@change=\"applyFilters\"");
|
||||
expect(viewSource).not.toContain("@click=\"applyFilters\"");
|
||||
expect(viewSource).not.toContain(":icon=\"Refresh\"");
|
||||
expect(viewSource).not.toContain("Refresh } from");
|
||||
expect(viewSource).toContain("watch(searchKeyword, () => {");
|
||||
expect(viewSource).toContain("scheduleKeywordSearch();");
|
||||
expect(viewSource).toContain("keywordSearchTimer = setTimeout(() => {");
|
||||
expect(viewSource).toContain("}, 300);");
|
||||
expect(viewSource).toContain("page.value = 1");
|
||||
expect(viewSource).toContain("keyword: searchKeyword.value || undefined");
|
||||
expect(viewSource).toContain("status: statusFilter.value || undefined");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Admin user form autocomplete", () => {
|
||||
it("prevents browser credential autofill when creating a user", () => {
|
||||
const formSource = readUserForm();
|
||||
|
||||
expect(formSource).toContain('autocomplete="off"');
|
||||
expect(formSource).toContain('name="ctms-create-initial-password"');
|
||||
expect(formSource).toContain('autocomplete="new-password"');
|
||||
expect(formSource).not.toContain("current_password");
|
||||
expect(formSource).not.toContain("confirmPassword");
|
||||
expect(formSource).not.toContain("TEXT.modules.profile.currentPassword");
|
||||
expect(formSource).not.toContain("TEXT.modules.profile.confirmPassword");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Admin user create form password", () => {
|
||||
it("requires an initial password before creating an account", () => {
|
||||
const formSource = readUserForm();
|
||||
|
||||
expect(formSource).toContain('v-if="!user" :label="TEXT.modules.adminUsers.passwordInitial" prop="password"');
|
||||
expect(formSource).toContain("TEXT.modules.adminUsers.passwordInitialRequired");
|
||||
expect(formSource).toContain("/^(?=.*[A-Za-z])(?=.*\\d).{8,}$/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Admin user edit form defaults", () => {
|
||||
it("hydrates the selected user immediately when the dialog is mounted open", () => {
|
||||
const formSource = readUserForm();
|
||||
|
||||
expect(formSource).toContain("immediate: true");
|
||||
expect(formSource).not.toContain("current_password");
|
||||
expect(formSource).not.toContain("confirmPassword");
|
||||
expect(formSource).not.toContain("TEXT.modules.profile.currentPassword");
|
||||
expect(formSource).not.toContain("TEXT.modules.profile.confirmPassword");
|
||||
});
|
||||
});
|
||||
@@ -87,9 +87,6 @@
|
||||
<el-table-column :label="TEXT.common.fields.name" width="360">
|
||||
<template #default="scope">
|
||||
<div class="user-cell">
|
||||
<div class="user-avatar" :class="'avatar--' + (scope.row.status || '').toLowerCase()">
|
||||
{{ getInitials(scope.row.full_name) }}
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<span class="user-name">{{ scope.row.full_name }}</span>
|
||||
<span class="user-email">{{ scope.row.email }}</span>
|
||||
@@ -195,13 +192,6 @@ const activeCount = computed(() => allUsers.value.filter(u => u.status === 'ACTI
|
||||
const pendingCount = computed(() => allUsers.value.filter(u => u.status === 'PENDING').length);
|
||||
const disabledCount = computed(() => allUsers.value.filter(u => u.status === 'DISABLED').length);
|
||||
|
||||
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 statusType = (status: string) => {
|
||||
switch (status) {
|
||||
case "ACTIVE": return "success";
|
||||
@@ -487,37 +477,6 @@ onBeforeUnmount(() => {
|
||||
.user-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
background: var(--ctms-primary);
|
||||
}
|
||||
|
||||
.user-avatar.avatar--active {
|
||||
background: linear-gradient(135deg, #3f8f6b, #2d7a5a);
|
||||
}
|
||||
|
||||
.user-avatar.avatar--pending {
|
||||
background: linear-gradient(135deg, #c58b2a, #a87420);
|
||||
}
|
||||
|
||||
.user-avatar.avatar--disabled {
|
||||
background: linear-gradient(135deg, #94a3b8, #64748b);
|
||||
}
|
||||
|
||||
.user-avatar.avatar--rejected {
|
||||
background: linear-gradient(135deg, #c24b4b, #a83a3a);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const readView = (path: string) => readFileSync(resolve(__dirname, path), "utf8");
|
||||
|
||||
describe("detail breadcrumb contexts", () => {
|
||||
it("uses contract numbers for contract fee detail breadcrumbs", () => {
|
||||
const source = readView("./fees/ContractFeeDetail.vue");
|
||||
|
||||
expect(source).toContain("study.setViewContext({");
|
||||
expect(source).toContain("siteName");
|
||||
expect(source).toContain("pageTitle: detail.contract_no || TEXT.menu.feeContracts");
|
||||
});
|
||||
|
||||
it("uses shipment tracking or batch numbers for drug shipment detail breadcrumbs", () => {
|
||||
const source = readView("./drug/ShipmentDetail.vue");
|
||||
|
||||
expect(source).toContain("study.setViewContext({");
|
||||
expect(source).toContain("siteName: detail.site_name || TEXT.common.fallback");
|
||||
expect(source).toContain("pageTitle: detail.tracking_no || detail.batch_no || TEXT.modules.drugShipments.detailTitle");
|
||||
});
|
||||
|
||||
it("uses equipment names for material equipment detail breadcrumbs", () => {
|
||||
const source = readView("./materials/MaterialEquipmentDetail.vue");
|
||||
|
||||
expect(source).toContain("study.setViewContext({");
|
||||
expect(source).toContain("pageTitle: detail.name || TEXT.modules.materialEquipment.detailTitle");
|
||||
});
|
||||
|
||||
it("uses subject numbers and centers for subject detail breadcrumbs", () => {
|
||||
const source = readView("./subjects/SubjectDetail.vue");
|
||||
|
||||
expect(source).toContain("study.setViewContext({");
|
||||
expect(source).toContain("siteName: siteMap.value[detail.site_id] || TEXT.common.fallback");
|
||||
expect(source).toContain("pageTitle: detail.subject_no || TEXT.modules.subjectManagement.screeningNo");
|
||||
});
|
||||
|
||||
it("uses startup record identifiers for startup detail breadcrumbs", () => {
|
||||
const feasibility = readView("./startup/FeasibilityDetail.vue");
|
||||
const ethics = readView("./startup/EthicsDetail.vue");
|
||||
const kickoff = readView("./startup/KickoffDetail.vue");
|
||||
const training = readView("./startup/TrainingDetail.vue");
|
||||
|
||||
expect(feasibility).toContain("pageTitle: detail.project_no ||");
|
||||
expect(ethics).toContain("pageTitle: detail.approval_no ||");
|
||||
expect(kickoff).toContain("pageTitle: siteInfo.name ? `${siteInfo.name} ${TEXT.modules.startupMeetingAuth.kickoffTab}`");
|
||||
expect(training).toContain("pageTitle: detail.name || TEXT.modules.startupMeetingAuth.trainingDetailTitle");
|
||||
});
|
||||
|
||||
it("uses knowledge item titles for knowledge detail breadcrumbs", () => {
|
||||
const precaution = readView("./knowledge/PrecautionDetail.vue");
|
||||
const faq = readView("./FaqDetail.vue");
|
||||
|
||||
expect(precaution).toContain("pageTitle: detail.title || TEXT.modules.knowledgeNotes.detailTitle");
|
||||
expect(faq).toContain("const questionTitle = String(faqData.question || \"\").trim()");
|
||||
expect(faq).toContain("pageTitle: questionTitle ? questionTitle.slice(0, 24) : TEXT.modules.knowledgeMedicalConsult.detailTitle");
|
||||
});
|
||||
|
||||
it("uses project names for admin project detail breadcrumbs", () => {
|
||||
const source = readView("./admin/ProjectDetail.vue");
|
||||
|
||||
expect(source).toContain("studyStore.setViewContext({");
|
||||
expect(source).toContain("pageTitle: project.value.name || TEXT.modules.adminProjects.detailTitle");
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const readView = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8");
|
||||
|
||||
const detailViews = [
|
||||
"./fees/ContractFeeDetail.vue",
|
||||
"./drug/ShipmentDetail.vue",
|
||||
"./materials/MaterialEquipmentDetail.vue",
|
||||
"./documents/DocumentDetail.vue",
|
||||
"./subjects/SubjectDetail.vue",
|
||||
"./knowledge/PrecautionDetail.vue",
|
||||
"./startup/FeasibilityDetail.vue",
|
||||
"./startup/EthicsDetail.vue",
|
||||
"./startup/KickoffDetail.vue",
|
||||
"./startup/TrainingDetail.vue",
|
||||
];
|
||||
|
||||
describe("detail page navigation", () => {
|
||||
it("uses header breadcrumbs instead of page-local back actions", () => {
|
||||
for (const viewPath of detailViews) {
|
||||
const source = readView(viewPath);
|
||||
|
||||
expect(source, viewPath).not.toContain("TEXT.common.actions.back");
|
||||
expect(source, viewPath).not.toContain('@click="goBack"');
|
||||
expect(source, viewPath).not.toContain("const goBack =");
|
||||
expect(source, viewPath).not.toContain("function goBack");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./DocumentList.vue"), "utf8");
|
||||
|
||||
describe("DocumentList project permissions", () => {
|
||||
it("hides document create/update/delete controls and guards direct mutation calls by backend permissions", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('v-if="canCreate"');
|
||||
expect(source).toContain('v-if="canUpdate"');
|
||||
expect(source).toContain('v-if="canDelete"');
|
||||
expect(source).toContain("if (!canCreate.value)");
|
||||
expect(source).toContain("if (!canUpdate.value)");
|
||||
expect(source).toContain("if (!canDelete.value)");
|
||||
expect(source).toContain("if (isInactiveSite(row.site_id))");
|
||||
expect(source).toContain('@click.stop="openEdit(row)"');
|
||||
expect(source).not.toContain(':disabled="!canCreate"');
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./DrugShipmentEditorDrawer.vue"), "utf8");
|
||||
|
||||
describe("DrugShipmentEditorDrawer attachments", () => {
|
||||
it("uses a compact upload card instead of the full attachment table", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("AttachmentList");
|
||||
expect(source).toContain('ref="attachmentPanelRef"');
|
||||
expect(source).toContain('entity-type="drug_shipment"');
|
||||
expect(source).toContain(':mode="\'upload\'"');
|
||||
expect(source).toContain(':center-upload-card-content="true"');
|
||||
expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []");
|
||||
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(props.shipmentId)");
|
||||
expect(source).toContain("uploadPending");
|
||||
expect(source).not.toContain("uploadAttachment");
|
||||
expect(source).not.toContain("pendingFiles");
|
||||
expect(source).not.toContain("upload-card-label");
|
||||
expect(source).not.toContain("<el-table");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DrugShipmentEditorDrawer shipment validation", () => {
|
||||
it("keeps shipment execution fields optional while status is pending", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("requiresShipmentDetails");
|
||||
expect(source).toContain('status !== "PENDING"');
|
||||
expect(source).not.toContain('ship_date: [{ required: true');
|
||||
expect(source).not.toContain('batch_no: [{ required: true');
|
||||
expect(source).not.toContain('carrier: [{ required: true');
|
||||
expect(source).not.toContain('tracking_no: [{ required: true');
|
||||
});
|
||||
|
||||
it("does not expose the removed returned status", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).not.toContain("RETURNED");
|
||||
expect(source).not.toContain("已回收");
|
||||
});
|
||||
|
||||
it("requires receive date when shipment is signed", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("requiresReceiveDate");
|
||||
expect(source).toContain('status === "SIGNED"');
|
||||
expect(source).not.toContain('receive_date: [{ required: true');
|
||||
});
|
||||
|
||||
it("keeps remark optional unless shipment is exceptional", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("requiresRemark");
|
||||
expect(source).toContain('status === "EXCEPTION"');
|
||||
expect(source).not.toContain('remark: [{ required: true');
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./ShipmentDetail.vue"), "utf8");
|
||||
|
||||
describe("ShipmentDetail edit drawer", () => {
|
||||
it("shows an edit action and opens the shipment editor drawer on the detail page", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("<DrugShipmentEditorDrawer");
|
||||
expect(source).toContain('v-if="canUpdateShipment"');
|
||||
expect(source).toContain("editorVisible.value = true");
|
||||
expect(source).toContain("const handleEditorSaved = () =>");
|
||||
expect(source).toContain('"drug_shipments:update"');
|
||||
expect(source).toContain(":readonly=\"isReadOnly\"");
|
||||
});
|
||||
|
||||
it("hides the upload button in the detail attachment table", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("<AttachmentList");
|
||||
expect(source).toContain(":hide-uploader=\"true\"");
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readDetail = () => readFileSync(resolve(__dirname, "./ContractFeeDetail.vue"), "utf8");
|
||||
|
||||
describe("ContractFeeDetail permissions", () => {
|
||||
it("suppresses optional site lookup permission errors on the detail page", () => {
|
||||
const source = readDetail();
|
||||
|
||||
expect(source).toContain('fetchSites(studyId.value, { limit: 500 }, { suppressErrorMessage: true })');
|
||||
});
|
||||
|
||||
it("opens the edit drawer on the detail page without navigating back to the list", () => {
|
||||
const source = readDetail();
|
||||
|
||||
expect(source).toContain("<ContractFeeEditorDrawer");
|
||||
expect(source).toContain("editorVisible.value = true");
|
||||
expect(source).toContain("const handleEditorSaved = () =>");
|
||||
expect(source).not.toContain('router.push({ path: "/fees/contracts"');
|
||||
expect(source).not.toContain("editContractId");
|
||||
});
|
||||
|
||||
it("renders contract fee attachments as one aggregated table with attachment type labels", () => {
|
||||
const source = readDetail();
|
||||
|
||||
expect(source).toContain("<AttachmentList");
|
||||
expect(source).toContain(":entity-groups=\"attachmentGroups\"");
|
||||
expect(source).toContain(":refresh-key=\"attachmentRefreshKey\"");
|
||||
expect(source).toContain("attachmentRefreshKey.value += 1");
|
||||
expect(source).not.toContain("<FeeAttachmentPanel");
|
||||
expect(source).toContain('entityType: "contract_fee_contract"');
|
||||
expect(source).toContain('entityType: "contract_fee_voucher"');
|
||||
expect(source).toContain('entityType: "contract_fee_invoice"');
|
||||
});
|
||||
|
||||
it("does not show attachment group descriptions on the detail page", () => {
|
||||
const source = readDetail();
|
||||
|
||||
expect(source).not.toContain("attachmentContractDesc");
|
||||
expect(source).not.toContain("attachmentVoucherDesc");
|
||||
expect(source).not.toContain("attachmentInvoiceDesc");
|
||||
});
|
||||
|
||||
it("keeps payment table columns evenly distributed with clear labels", () => {
|
||||
const source = readDetail();
|
||||
|
||||
expect(source).toContain('v-for="(payment, index) in detail.payments"');
|
||||
expect(source).toContain("TEXT.modules.feeContracts.paymentSeq");
|
||||
expect(source).toContain("TEXT.modules.feeContracts.paidFlag");
|
||||
expect(source).toContain("TEXT.modules.feeContracts.verifiedFlag");
|
||||
expect(source).toContain("tl-panel-amount");
|
||||
expect(source).not.toContain('width="20%"');
|
||||
expect(source).not.toContain(".payment-table :deep(col:nth-child(-n + 5))");
|
||||
});
|
||||
|
||||
it("uses ten-thousand yuan as the only amount unit and hides currency", () => {
|
||||
const source = readDetail();
|
||||
|
||||
expect(source).toContain("formatAmountWan(detail.contract_amount)");
|
||||
expect(source).toContain("formatAmountWan(payment.amount)");
|
||||
expect(source).toContain('<span class="stat-tile-unit">万</span>');
|
||||
expect(source).toContain('<span class="amount-unit-sm">万</span>');
|
||||
expect(source).not.toContain("TEXT.common.fields.currency");
|
||||
expect(source).not.toContain("detail.currency");
|
||||
expect(source).not.toContain("currencyDefault");
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readDrawer = () => readFileSync(resolve(__dirname, "./ContractFeeEditorDrawer.vue"), "utf8");
|
||||
const readLocale = () => readFileSync(resolve(__dirname, "../../locales/zh-CN.ts"), "utf8");
|
||||
|
||||
describe("ContractFeeEditorDrawer attachments", () => {
|
||||
it("uses upload-card mode for attachments instead of the detail table", () => {
|
||||
const source = readDrawer();
|
||||
|
||||
expect(source).toContain('ref="attachmentPanelRef"');
|
||||
expect(source).toContain("<AttachmentList");
|
||||
expect(source).toContain(":entity-groups=\"attachmentGroups\"");
|
||||
expect(source).not.toContain("PendingAttachmentUpload");
|
||||
expect(source).not.toContain("FeeAttachmentPanel");
|
||||
expect(source).not.toContain("attachmentContractDesc");
|
||||
expect(source).not.toContain("attachmentVoucherDesc");
|
||||
expect(source).not.toContain("attachmentInvoiceDesc");
|
||||
});
|
||||
|
||||
it("uploads selected attachments only after the form save succeeds", () => {
|
||||
const source = readDrawer();
|
||||
|
||||
expect(source).toContain("const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null)");
|
||||
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(savedId)");
|
||||
expect(source).toContain("attachments: attachmentPanelRef.value?.pendingSnapshot() || []");
|
||||
expect(source.indexOf("await attachmentPanelRef.value?.uploadPending(savedId)")).toBeLessThan(
|
||||
source.indexOf('emit("update:modelValue", false)')
|
||||
);
|
||||
});
|
||||
|
||||
it("shows attachment upload cards while creating a new contract fee", () => {
|
||||
const source = readDrawer();
|
||||
|
||||
expect(source).toContain('class="form-section"');
|
||||
expect(source).toContain(':entity-id="form.id"');
|
||||
expect(source).toContain("<AttachmentList");
|
||||
expect(source).toContain(':center-upload-card-content="true"');
|
||||
expect(source).toContain(':upload-card-columns="3"');
|
||||
expect(source).not.toContain(':hide-upload-card-labels="true"');
|
||||
expect(source).not.toContain('<div v-if="form.id" class="form-group">');
|
||||
});
|
||||
|
||||
it("uses centered three-column upload cards with contract-specific copy", () => {
|
||||
const source = readDrawer();
|
||||
|
||||
expect(source).toContain('uploadText: "点击上传合同"');
|
||||
expect(source).toContain('uploadText: "点击上传凭证"');
|
||||
expect(source).toContain('uploadText: "点击上传发票"');
|
||||
expect(source).toContain(':center-upload-card-content="true"');
|
||||
expect(source).toContain(':upload-card-columns="3"');
|
||||
expect(source).not.toContain(':hide-upload-card-labels="true"');
|
||||
});
|
||||
|
||||
it("labels the section as attachments instead of contract attachments", () => {
|
||||
const source = readLocale();
|
||||
|
||||
expect(source).toContain('attachmentTitle: "附件"');
|
||||
expect(source).not.toContain('attachmentTitle: "合同附件"');
|
||||
});
|
||||
|
||||
it("uses a payment grid that keeps date pickers inside their columns", () => {
|
||||
const source = readDrawer();
|
||||
|
||||
expect(source).toContain('class="field-grid field-grid--3"');
|
||||
expect(source).toContain('class="status-row"');
|
||||
expect(source).toContain('class="status-datepicker"');
|
||||
expect(source).toContain("grid-template-columns: 1fr 1fr 1fr;");
|
||||
expect(source).toContain("min-width: 0;");
|
||||
expect(source).toContain(".status-datepicker :deep(.el-date-editor.el-input)");
|
||||
});
|
||||
|
||||
it("uses ten-thousand yuan as the only amount unit and removes currency selection", () => {
|
||||
const source = readDrawer();
|
||||
|
||||
expect(source).toContain(':label="`${TEXT.modules.feeContracts.contractAmount}(万元)`"');
|
||||
expect(source).toContain(':label="`${TEXT.common.fields.amount}(万元)`"');
|
||||
expect(source).toContain("contract_amount: Number(detail.contract_amount || 0) / 10000");
|
||||
expect(source).toContain("amount: Number(payment.amount || 0) / 10000");
|
||||
expect(source).toContain("contract_amount: Number(form.contract_amount || 0) * 10000");
|
||||
expect(source).toContain("amount: Number(payment.amount || 0) * 10000");
|
||||
expect(source).not.toContain("prop=\"currency\"");
|
||||
expect(source).not.toContain("form.currency");
|
||||
expect(source).not.toContain("payload.currency");
|
||||
expect(source).not.toContain("currency:");
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
describe("contract fee attachment labels", () => {
|
||||
it("uses a concise attachment section title", () => {
|
||||
expect(TEXT.modules.feeContracts.attachmentTitle).toBe("附件");
|
||||
});
|
||||
|
||||
it("uses a clear payment sequence column label", () => {
|
||||
expect(TEXT.modules.feeContracts.paymentSeqColumn).toBe("付款期次");
|
||||
expect(TEXT.modules.feeContracts.paymentSeq).toBe("第");
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const read = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8");
|
||||
|
||||
describe("Contract fee project permissions", () => {
|
||||
it("hides list create/delete controls when the matching backend operation is not allowed", () => {
|
||||
const source = read("./ContractFees.vue");
|
||||
|
||||
expect(source).toContain('v-if="canCreate"');
|
||||
expect(source).toContain('v-if="canDelete"');
|
||||
expect(source).toContain("if (!canCreate.value)");
|
||||
expect(source).toContain("if (!canDelete.value)");
|
||||
expect(source).not.toContain(':disabled="!canCreate"');
|
||||
expect(source).not.toContain(':disabled="!canDelete || isInactiveSite(scope.row.center_id)"');
|
||||
});
|
||||
|
||||
it("hides detail edit controls and blocks direct navigation without update permission", () => {
|
||||
const source = read("./ContractFeeDetail.vue");
|
||||
|
||||
expect(source).toContain('v-if="canWrite"');
|
||||
expect(source).toContain("if (!canWrite.value)");
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./EtmfPlaceholder.vue"), "utf8");
|
||||
|
||||
describe("EtmfPlaceholder project permissions", () => {
|
||||
it("hides eTMF create controls and guards dialog submissions with document create permission", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('v-if="canCreate"');
|
||||
expect(source).toContain('v-if="canCreate && selectedNode"');
|
||||
expect(source).toContain("if (!canCreate.value)");
|
||||
expect(source).not.toContain(':disabled="!canCreate"');
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./MaterialEquipment.vue"), "utf8");
|
||||
|
||||
describe("MaterialEquipment project permissions", () => {
|
||||
it("projects material equipment create update and delete permissions into all write actions", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("useAuthStore");
|
||||
expect(source).toContain("isSystemAdmin");
|
||||
expect(source).toContain("isApiPermissionAllowed");
|
||||
expect(source).toContain('["material_equipments:create"]');
|
||||
expect(source).toContain('["material_equipments:update"]');
|
||||
expect(source).toContain('["material_equipments:delete"]');
|
||||
expect(source).toContain('v-if="canCreate"');
|
||||
expect(source).toContain('v-if="canUpdate"');
|
||||
expect(source).toContain('v-if="canDelete"');
|
||||
expect(source).toContain("if (!canDelete.value)");
|
||||
});
|
||||
|
||||
it("keeps the visible table columns evenly distributed", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('class="equipment-table"');
|
||||
expect(source).toContain('style="width: 100%"');
|
||||
expect(source).toContain('table-layout="fixed"');
|
||||
expect(source).toContain('label="操作"');
|
||||
expect(source).not.toMatch(/<el-table-column[^>]+prop="(?:name|specModel|unit|brand)"[^>]+(?:width|min-width)=/);
|
||||
});
|
||||
|
||||
it("opens the detail page from row clicks instead of a detail action button", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('@row-click="onRowClick"');
|
||||
expect(source).toContain(':row-class-name="equipmentRowClass"');
|
||||
expect(source).toContain('name: "MaterialEquipmentDetail"');
|
||||
expect(source).toContain("equipmentId: row.id");
|
||||
expect(source).not.toContain(">详情</el-button>");
|
||||
expect(source).toContain('@click.stop="openEdit(row)"');
|
||||
expect(source).toContain('@click.stop="removeRow(row)"');
|
||||
});
|
||||
|
||||
it("uploads qualification files as equipment attachments after saving the drawer form", () => {
|
||||
const source = readSource();
|
||||
const calibrationIndex = source.indexOf("校准设置");
|
||||
const qualificationIndex = source.indexOf("附件");
|
||||
|
||||
expect(calibrationIndex).toBeGreaterThan(-1);
|
||||
expect(qualificationIndex).toBeGreaterThan(calibrationIndex);
|
||||
expect(source).toContain("AttachmentList");
|
||||
expect(source).toContain('ref="attachmentPanelRef"');
|
||||
expect(source).toContain('entity-type="material_equipment"');
|
||||
expect(source).toContain(':entity-id="editingId"');
|
||||
expect(source).toContain(':mode="\'upload\'"');
|
||||
expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []");
|
||||
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(equipmentId)");
|
||||
expect(source).toContain("uploadPending");
|
||||
expect(source).toContain("material_equipment");
|
||||
expect(source).not.toContain("uploadAttachment");
|
||||
expect(source).not.toContain("pendingFiles");
|
||||
expect(source).not.toContain("qualificationRows");
|
||||
expect(source).not.toContain("material_equipment_production_permit");
|
||||
expect(source).not.toContain("material_equipment_tech_index");
|
||||
expect(source).not.toContain("资质文件");
|
||||
expect(source).not.toContain("生产许可证");
|
||||
expect(source).not.toContain("技术指标");
|
||||
expect(source).not.toContain("handleUploadChange");
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8");
|
||||
|
||||
describe("precautions project permissions", () => {
|
||||
it("splits list create and delete controls by backend operation permissions", () => {
|
||||
const source = readSource("./Precautions.vue");
|
||||
|
||||
expect(source).toContain('const canCreatePrecaution = computed(() => can("precautions.create"))');
|
||||
expect(source).toContain('const canDeletePrecaution = computed(() => can("precautions.delete"))');
|
||||
expect(source).toContain('v-if="canCreatePrecaution"');
|
||||
expect(source).toContain('v-if="canDeletePrecaution"');
|
||||
expect(source).toContain("if (!canDeletePrecaution.value)");
|
||||
expect(source).not.toContain("canWritePrecautions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("precautions drawer editor", () => {
|
||||
it("opens create form in a drawer instead of routing to the form page", () => {
|
||||
const source = readSource("./Precautions.vue");
|
||||
|
||||
expect(source).toContain("PrecautionEditorDrawer");
|
||||
expect(source).toContain("precautionDrawerVisible");
|
||||
expect(source).not.toContain('router.push("/knowledge/precautions/new")');
|
||||
});
|
||||
|
||||
it("uses selects for site and level fields in the create drawer", () => {
|
||||
const source = readSource("../knowledge/PrecautionEditorDrawer.vue");
|
||||
|
||||
expect(source).toContain('<el-select v-model="form.site_name"');
|
||||
expect(source).toContain('v-for="site in sites"');
|
||||
expect(source).toContain(':value="site.name"');
|
||||
expect(source).toContain('<el-select v-model="form.level"');
|
||||
expect(source).toContain("const levelOptions = precautionLevelOptions");
|
||||
expect(source).toContain('v-for="level in levelOptions"');
|
||||
expect(source).not.toContain('<el-input v-model="form.site_name"');
|
||||
expect(source).not.toContain('<el-input v-model="form.level"');
|
||||
});
|
||||
|
||||
it("moves precaution attachment upload into the editor drawer", () => {
|
||||
const drawer = readSource("../knowledge/PrecautionEditorDrawer.vue");
|
||||
const detail = readSource("../knowledge/PrecautionDetail.vue");
|
||||
|
||||
expect(drawer).toContain("AttachmentList");
|
||||
expect(drawer).toContain('ref="attachmentPanelRef"');
|
||||
expect(drawer).toContain(':entity-type="\'precaution\'"');
|
||||
expect(drawer).toContain(':mode="\'upload\'"');
|
||||
expect(drawer).toContain("attachments: attachmentPanelRef.value?.pendingSnapshot() || []");
|
||||
expect(drawer).toContain("await attachmentPanelRef.value?.uploadPending(id)");
|
||||
expect(detail).toContain(":hide-uploader=\"true\"");
|
||||
expect(detail).toContain(":refresh-key=\"attachmentRefreshKey\"");
|
||||
expect(detail).toContain("attachmentRefreshKey.value += 1");
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./ProjectMilestones.vue"), "utf8");
|
||||
|
||||
describe("ProjectMilestones project permissions", () => {
|
||||
it("hides edit controls and blocks saves without project milestone update permission", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('const canUpdateMilestones = computed(() => can("project.milestones.update"))');
|
||||
expect(source).toContain('v-if="canUpdateMilestones"');
|
||||
expect(source).toContain("if (!canUpdateMilestones.value)");
|
||||
});
|
||||
});
|
||||
@@ -2,24 +2,6 @@
|
||||
<div class="page ctms-page-shell page--flush">
|
||||
<div v-if="study.currentStudy" class="page-body">
|
||||
<div class="overview-container">
|
||||
<div class="overview-top-bar">
|
||||
<div class="overview-top-left">
|
||||
<span class="overview-top-icon">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
||||
</span>
|
||||
<span class="overview-top-title">项目总览</span>
|
||||
</div>
|
||||
<div class="overview-top-right">
|
||||
<span class="overview-live-clock">{{ liveClock }}</span>
|
||||
<el-button size="small" @click="loadOverview" class="refresh-btn">
|
||||
<template #icon>
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</template>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="overview-card">
|
||||
<div class="card-header">
|
||||
<div class="card-header-left">
|
||||
@@ -28,11 +10,19 @@
|
||||
</span>
|
||||
<div class="card-title">中心整体进度</div>
|
||||
</div>
|
||||
<div class="progress-legend">
|
||||
<span class="legend-item"><span class="legend-dot completed"></span>已完成</span>
|
||||
<span class="legend-item"><span class="legend-dot active"></span>进行中</span>
|
||||
<span class="legend-item"><span class="legend-dot pending"></span>未开始</span>
|
||||
<span class="legend-item"><span class="legend-dot blocked"></span>阻塞/延期</span>
|
||||
<div class="card-header-right">
|
||||
<el-button size="small" @click="loadOverview" class="refresh-btn">
|
||||
<template #icon>
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</template>
|
||||
刷新
|
||||
</el-button>
|
||||
<div class="progress-legend">
|
||||
<span class="legend-item"><span class="legend-dot completed"></span>已完成</span>
|
||||
<span class="legend-item"><span class="legend-dot active"></span>进行中</span>
|
||||
<span class="legend-item"><span class="legend-dot pending"></span>未开始</span>
|
||||
<span class="legend-item"><span class="legend-dot blocked"></span>阻塞/延期</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StateLoading v-if="loading" :rows="6" />
|
||||
@@ -84,11 +74,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { Refresh } from "@element-plus/icons-vue";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { TEXT } from "../../locales";
|
||||
import { displayDateTime } from "../../utils/display";
|
||||
import { fetchProjectOverview } from "../../api/overview";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
@@ -101,8 +90,6 @@ const study = useStudyStore();
|
||||
const loading = ref(false);
|
||||
const overview = ref<ProjectOverviewViewModel | null>(null);
|
||||
const chartMode = ref<"center" | "month">("center");
|
||||
const liveClock = ref(displayDateTime(new Date()));
|
||||
let clockTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const centers = computed(() => overview.value?.centers || []);
|
||||
|
||||
@@ -202,16 +189,6 @@ const reset = () => {
|
||||
|
||||
onMounted(() => {
|
||||
loadOverview();
|
||||
clockTimer = setInterval(() => {
|
||||
liveClock.value = displayDateTime(new Date());
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (clockTimer) {
|
||||
clearInterval(clockTimer);
|
||||
clockTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -235,58 +212,18 @@ watch(
|
||||
}
|
||||
|
||||
.overview-container {
|
||||
padding: 24px 28px 32px;
|
||||
padding: 8px 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.overview-top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.overview-top-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.overview-top-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, var(--ctms-primary), var(--ctms-primary-hover));
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.overview-top-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--ctms-text-main);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.overview-top-right {
|
||||
.card-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.overview-live-clock {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ctms-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: rgba(63, 93, 117, 0.06);
|
||||
padding: 2px 10px;
|
||||
border-radius: 6px;
|
||||
letter-spacing: 0.02em;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
@@ -296,8 +233,8 @@ watch(
|
||||
.overview-card {
|
||||
background: var(--ctms-bg-card);
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
border-radius: var(--ctms-radius-lg);
|
||||
padding: 20px 24px;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
transition: var(--ctms-transition);
|
||||
box-shadow: var(--ctms-shadow-sm);
|
||||
}
|
||||
@@ -311,9 +248,9 @@ watch(
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.card-header-left {
|
||||
@@ -382,8 +319,17 @@ watch(
|
||||
}
|
||||
|
||||
.legend-dot.active {
|
||||
border-color: var(--ctms-primary);
|
||||
box-shadow: 0 0 0 2px rgba(63, 93, 117, 0.18);
|
||||
position: relative;
|
||||
border: 3px solid #2f5f86;
|
||||
box-shadow: 0 0 0 3px rgba(47, 95, 134, 0.14);
|
||||
}
|
||||
|
||||
.legend-dot.active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
border-radius: inherit;
|
||||
background: #2f5f86;
|
||||
}
|
||||
|
||||
.legend-dot.pending {
|
||||
@@ -402,12 +348,12 @@ watch(
|
||||
}
|
||||
|
||||
.overview-empty-panel {
|
||||
min-height: 168px;
|
||||
min-height: 132px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 24px;
|
||||
border-radius: 12px;
|
||||
padding: 20px 16px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, #f8fafc, #f1f5f9);
|
||||
}
|
||||
|
||||
@@ -445,11 +391,11 @@ watch(
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.overview-container {
|
||||
padding: 16px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
padding: 16px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@@ -457,10 +403,8 @@ watch(
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.overview-top-bar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
.card-header-right {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readRiskIssueMonitoringVisits = () => readFileSync(resolve(__dirname, "./RiskIssueMonitoringVisits.vue"), "utf8");
|
||||
|
||||
describe("RiskIssueMonitoringVisits permissions", () => {
|
||||
it("gates monitoring visit issue actions by concrete API permissions", () => {
|
||||
const source = readRiskIssueMonitoringVisits();
|
||||
|
||||
expect(source).toContain("useAuthStore");
|
||||
expect(source).toContain("isSystemAdmin");
|
||||
expect(source).toContain("getProjectRole");
|
||||
expect(source).toContain("isApiPermissionAllowed");
|
||||
[
|
||||
"monitoring_issues:read",
|
||||
"monitoring_issues:read",
|
||||
"monitoring_issues:create",
|
||||
"monitoring_issues:update",
|
||||
"monitoring_issues:delete",
|
||||
].forEach((operationKey) => {
|
||||
expect(source).toContain(operationKey);
|
||||
});
|
||||
expect(source).toContain('v-if="canCreateIssue"');
|
||||
expect(source).toContain('v-if="canListIssues"');
|
||||
expect(source).toContain('v-if="canReadIssue"');
|
||||
expect(source).toContain('v-if="canUpdateIssue"');
|
||||
expect(source).toContain('v-if="canDeleteIssue"');
|
||||
expect(source).toContain('v-if="canSaveIssue"');
|
||||
expect(source).not.toContain(':disabled="!canSaveIssue"');
|
||||
expect(source).toContain("const canSaveIssue = computed(() => (formMode.value === \"edit\" ? canUpdateIssue.value : canCreateIssue.value));");
|
||||
expect(source).toContain('ElMessage.warning("权限不足")');
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readRiskIssueSae = () => readFileSync(resolve(__dirname, "./RiskIssueSae.vue"), "utf8");
|
||||
|
||||
describe("RiskIssueSae status", () => {
|
||||
it("only exposes follow-up and closed statuses in the risk issue status column", () => {
|
||||
const source = readRiskIssueSae();
|
||||
|
||||
expect(source).toContain("const aeStatusOptions = [");
|
||||
expect(source).toContain('{ value: "FOLLOW_UP", label: TEXT.enums.aeStatus.FOLLOW_UP }');
|
||||
expect(source).toContain('{ value: "CLOSED", label: TEXT.enums.aeStatus.CLOSED }');
|
||||
expect(source).toContain("normalizeAeStatus");
|
||||
expect(source).not.toContain("Object.entries(TEXT.enums.aeStatus)");
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./StartupFeasibilityEthics.vue"), "utf8");
|
||||
|
||||
describe("StartupFeasibilityEthics project permissions", () => {
|
||||
it("hides initiation and ethics create/delete actions by their backend operation permissions", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('canUseApiPermission("startup_initiation:create")');
|
||||
expect(source).toContain('canUseApiPermission("startup_initiation:read")');
|
||||
expect(source).toContain('canUseApiPermission("startup_initiation:update")');
|
||||
expect(source).toContain('canUseApiPermission("startup_initiation:delete")');
|
||||
expect(source).toContain('canUseApiPermission("startup_ethics:read")');
|
||||
expect(source).toContain('canUseApiPermission("startup_ethics:create")');
|
||||
expect(source).toContain('canUseApiPermission("startup_ethics:update")');
|
||||
expect(source).toContain('canUseApiPermission("startup_ethics:delete")');
|
||||
expect(source).toContain('v-if="canReadFeasibility"');
|
||||
expect(source).toContain('v-if="canReadEthics"');
|
||||
expect(source).toContain("if (!canReadFeasibility.value)");
|
||||
expect(source).toContain("if (!canReadEthics.value)");
|
||||
expect(source).toContain('v-if="activeTab === \'feasibility\' && canCreateFeasibility"');
|
||||
expect(source).toContain('v-if="canUpdateFeasibility"');
|
||||
expect(source).toContain('v-if="canDeleteFeasibility"');
|
||||
expect(source).toContain('v-if="activeTab === \'ethics\' && canCreateEthics"');
|
||||
expect(source).toContain('v-if="canUpdateEthics"');
|
||||
expect(source).toContain('v-if="canDeleteEthics"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("StartupFeasibilityEthics drawer editors", () => {
|
||||
it("opens initiation and ethics create forms in drawers instead of routing to form pages", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("FeasibilityEditorDrawer");
|
||||
expect(source).toContain("EthicsEditorDrawer");
|
||||
expect(source).toContain("feasibilityDrawerVisible");
|
||||
expect(source).toContain("ethicsDrawerVisible");
|
||||
expect(source).not.toContain('router.push("/startup/feasibility/new")');
|
||||
expect(source).not.toContain('router.push("/startup/ethics/new")');
|
||||
});
|
||||
|
||||
it("opens initiation and ethics edit drawers from table action columns", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain(':record-id="editingFeasibilityId"');
|
||||
expect(source).toContain(':record-id="editingEthicsId"');
|
||||
expect(source).toContain("const editingFeasibilityId = ref<string | undefined>();");
|
||||
expect(source).toContain("const editingEthicsId = ref<string | undefined>();");
|
||||
expect(source).toContain("@click.stop=\"openFeasibilityEditor(scope.row)\"");
|
||||
expect(source).toContain("@click.stop=\"openEthicsEditor(scope.row)\"");
|
||||
expect(source).toContain("editingFeasibilityId.value = row?.id;");
|
||||
expect(source).toContain("editingEthicsId.value = row?.id;");
|
||||
});
|
||||
});
|
||||
@@ -55,10 +55,10 @@ const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase(
|
||||
.center-row {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
gap: 16px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
margin: 2px 0;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
margin: 0;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase(
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
padding: 6px 0 2px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -125,7 +125,7 @@ const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase(
|
||||
height: 2px;
|
||||
background-color: var(--ctms-border-color);
|
||||
border-radius: 999px;
|
||||
margin-top: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.stage-connector::after {
|
||||
@@ -145,7 +145,9 @@ const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase(
|
||||
}
|
||||
|
||||
.connector-in_progress {
|
||||
background: linear-gradient(90deg, var(--ctms-primary), rgba(63, 93, 117, 0.3));
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #2f5f86 0%, rgba(47, 95, 134, 0.42) 68%, rgba(47, 95, 134, 0.14) 100%);
|
||||
box-shadow: 0 2px 8px rgba(47, 95, 134, 0.18);
|
||||
}
|
||||
|
||||
.connector-blocked {
|
||||
@@ -158,8 +160,10 @@ const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase(
|
||||
}
|
||||
|
||||
.connector-in_progress::after {
|
||||
border-top-color: var(--ctms-primary);
|
||||
border-right-color: var(--ctms-primary);
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-top-color: #2f5f86;
|
||||
border-right-color: #2f5f86;
|
||||
}
|
||||
|
||||
.connector-blocked::after {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user