Compare commits

10 Commits

Author SHA1 Message Date
Cheng Zhou efc325568d 美化终端脚本 UI 并修复对齐与闪屏
- 配色体系:256 色语义色板(primary/accent/success/warn/danger)与统一图标常量
- 修复右边框对齐:ctms_text_width 用 perl 精确计算显示宽度,剥离 sgr0 的 \e(B 序列
- 修复菜单闪屏:改用光标归位原地覆盖重绘,去掉每帧清屏
- 美化各面板:install.sh banner/确认/成功框、根菜单、status.sh 资源表格,标签列对齐
- 新增 ctms_pad_right 按显示宽度填充含中文标签

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:24:22 +08:00
Cheng Zhou c351ca59c4 整理测试代码并清理文档引用 2026-06-23 09:21:57 +08:00
Cheng Zhou 6056c8364a 修复开发容器启动并加固部署配置 2026-06-22 15:35:00 +08:00
Cheng Zhou 360a2ba2b1 添加前端设计技能配置 2026-06-17 17:26:22 +08:00
Cheng Zhou e507ab98cb 增强开发部署构建配置 2026-06-17 17:23:13 +08:00
Cheng Zhou 9b2b8e90f6 压缩项目总览展示密度 2026-06-17 17:22:53 +08:00
Cheng Zhou 53249daf8d 优化顶栏导航与登录请求体验 2026-06-17 17:22:22 +08:00
Cheng Zhou 061792c73f 完善权限监控与安全中心 2026-06-17 17:21:48 +08:00
Cheng Zhou 1886765db8 拆分系统监测模块并统一运维化文案 2026-06-11 11:06:59 +08:00
Cheng Zhou b7484c8e01 优化个人中心与界面交互 2026-06-11 09:09:17 +08:00
122 changed files with 6988 additions and 4853 deletions
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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
+1
View File
@@ -5,6 +5,7 @@ Thumbs.db
# IDE/editor # IDE/editor
.idea/ .idea/
.vscode/ .vscode/
.claude/
*.swp *.swp
*.swo *.swo
+5
View File
@@ -1,5 +1,10 @@
# CTMS 项目快速上手 # CTMS 项目快速上手
## 交互安装入口
- 推荐执行 `./install.sh`,通过键盘 `↑/↓` 选择安装、更新、卸载、资源状态等操作;该入口不接受命令行参数。
- 菜单顶部会常驻显示当前 CTMS 容器部署状态,包括已检测到的环境、Compose 项目和容器运行数量。
- 底层脚本位于 `scripts/`:`install.sh`、`update.sh`、`uninstall.sh`、`status.sh`;状态脚本使用 `docker compose stats --no-stream` 采集资源快照并美化展示。
## 生产部署 ## 生产部署
- 生产入口:`docker-compose.yaml` - 生产入口:`docker-compose.yaml`
- 初始化方式:`docker compose run --rm backend-init` - 初始化方式:`docker compose run --rm backend-init`
+12 -1
View File
@@ -42,6 +42,12 @@ class ExtendResponse(BaseModel):
router = APIRouter() router = APIRouter()
AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avatars" AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avatars"
AVATAR_ROOT.mkdir(parents=True, exist_ok=True) 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: def issue_user_token(db_user) -> Token:
@@ -215,10 +221,15 @@ async def upload_avatar(
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
) -> UserRead: ) -> 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) AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
user_dir = AVATAR_ROOT / str(current_user.id) user_dir = AVATAR_ROOT / str(current_user.id)
user_dir.mkdir(parents=True, exist_ok=True) user_dir.mkdir(parents=True, exist_ok=True)
ext = Path(file.filename).suffix or ".png"
filename = f"{uuid.uuid4()}{ext}" filename = f"{uuid.uuid4()}{ext}"
dest = user_dir / filename dest = user_dir / filename
content = await file.read() content = await file.read()
+102 -18
View File
@@ -14,13 +14,13 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select, desc from sqlalchemy import func, select, desc
from sqlalchemy.ext.asyncio import AsyncSession 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.core.permission_monitor import get_permission_monitor
from app.models.permission_access_log import PermissionAccessLog from app.models.permission_access_log import PermissionAccessLog
from app.models.permission_metric_snapshot import PermissionMetricSnapshot from app.models.permission_metric_snapshot import PermissionMetricSnapshot
from app.models.security_access_log import SecurityAccessLog from app.models.security_access_log import SecurityAccessLog
from app.models.user import User 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"]) router = APIRouter(prefix="/permission-monitoring", tags=["permission-monitoring"])
@@ -39,10 +39,7 @@ class MonitoringScope:
async def resolve_monitoring_scope(db: AsyncSession, current_user) -> MonitoringScope: async def resolve_monitoring_scope(db: AsyncSession, current_user) -> MonitoringScope:
if is_system_admin(current_user): if is_system_admin(current_user):
return MonitoringScope(is_admin=True, study_ids=set()) return MonitoringScope(is_admin=True, study_ids=set())
return MonitoringScope( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
is_admin=False,
study_ids=await list_active_pm_study_ids(db, current_user.id),
)
def _apply_monitoring_scope_to_log_query(query, scope: MonitoringScope): def _apply_monitoring_scope_to_log_query(query, scope: MonitoringScope):
@@ -269,17 +266,13 @@ async def get_access_logs(
).select_from(PermissionAccessLog) ).select_from(PermissionAccessLog)
user_stats_query = ( user_stats_query = (
select( select(
PermissionAccessLog.user_id,
User.full_name,
PermissionAccessLog.role,
PermissionAccessLog.ip_address.label("sample_ip_address"), PermissionAccessLog.ip_address.label("sample_ip_address"),
func.count().label("total_count"), func.count().label("total_count"),
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied_count"), func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied_count"),
func.count(func.distinct(PermissionAccessLog.ip_address)).label("unique_ip_count"), func.count(func.distinct(PermissionAccessLog.ip_address)).label("unique_ip_count"),
func.max(PermissionAccessLog.created_at).label("last_seen_at"), func.max(PermissionAccessLog.created_at).label("last_seen_at"),
) )
.outerjoin(User, PermissionAccessLog.user_id == User.id) .group_by(PermissionAccessLog.ip_address)
.group_by(PermissionAccessLog.ip_address, PermissionAccessLog.user_id, User.full_name, PermissionAccessLog.role)
.order_by(desc("total_count"), desc("last_seen_at")) .order_by(desc("total_count"), desc("last_seen_at"))
.limit(10) .limit(10)
) )
@@ -295,6 +288,26 @@ async def get_access_logs(
summary_row = summary_result.one() summary_row = summary_result.one()
user_stats_result = await db.execute(user_stats_query) user_stats_result = await db.execute(user_stats_query)
user_stats_rows = user_stats_result.all() 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.order_by(desc(PermissionAccessLog.created_at))
query = query.offset((page - 1) * page_size).limit(page_size) query = query.offset((page - 1) * page_size).limit(page_size)
@@ -326,11 +339,12 @@ async def get_access_logs(
user_stats = [] user_stats = []
for stat in user_stats_rows: for stat in user_stats_rows:
sample_location = resolve_ip_location(stat.sample_ip_address) sample_location = resolve_ip_location(stat.sample_ip_address)
latest_user = latest_user_by_ip.get(stat.sample_ip_address)
user_stats.append( user_stats.append(
{ {
"user_id": str(stat.user_id), "user_id": str(latest_user.user_id) if latest_user else "",
"user_name": stat.full_name or "未知用户", "user_name": latest_user.full_name if latest_user and latest_user.full_name else "未知用户",
"role": stat.role, "role": latest_user.role if latest_user else "",
"total_count": stat.total_count, "total_count": stat.total_count,
"denied_count": stat.denied_count, "denied_count": stat.denied_count,
"unique_ip_count": stat.unique_ip_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 "未知账号" 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) @router.get("/security-logs", status_code=status.HTTP_200_OK)
async def get_security_access_logs( async def get_security_access_logs(
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
@@ -421,6 +472,7 @@ async def get_security_access_logs(
items = [] items = []
for log in logs: for log in logs:
ip_location = resolve_ip_location(log.client_ip)
items.append( items.append(
{ {
"id": str(log.id), "id": str(log.id),
@@ -429,10 +481,16 @@ async def get_security_access_logs(
"status_code": log.status_code, "status_code": log.status_code,
"elapsed_ms": round(log.elapsed_ms, 2), "elapsed_ms": round(log.elapsed_ms, 2),
"client_ip": log.client_ip, "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, "user_agent": log.user_agent,
"auth_status": log.auth_status, "auth_status": log.auth_status,
"user_identifier": log.user_identifier, "user_identifier": log.user_identifier,
"account_label": _security_account_label(log.auth_status, log.user_identifier, user_names), "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(), "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)) 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] = {} buckets: dict[tuple[str, str, str], dict] = {}
all_ip_addresses: set[str] = set() all_ip_addresses: set[str] = set()
all_user_ids: set[uuid.UUID] = set() all_user_ids: set[str] = set()
total_count = 0 total_count = 0
allowed_count = 0 allowed_count = 0
denied_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) ip_info = resolve_ip_location(ip_address)
key = (ip_info.country, ip_info.province, ip_info.city) 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 "未知" 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["total_count"] += 1
bucket["allowed_count" if allowed else "denied_count"] += 1 bucket["allowed_count" if allowed else "denied_count"] += 1
bucket["ip_addresses"].add(ip_address) 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 total_count += 1
if allowed: if allowed:
allowed_count += 1 allowed_count += 1
else: else:
denied_count += 1 denied_count += 1
all_ip_addresses.add(ip_address) 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] items = sorted(buckets.values(), key=lambda item: item["total_count"], reverse=True)[:limit]
return { return {
+12 -12
View File
@@ -969,54 +969,54 @@ SYSTEM_PERMISSIONS: dict[str, dict] = {
"description": "管理权限模板", "description": "管理权限模板",
"roles": ["ADMIN"], "roles": ["ADMIN"],
}, },
# 权限监控 # 系统监测
"system:monitoring:metrics": { "system:monitoring:metrics": {
"module": "system_monitoring", "module": "system_monitoring",
"action": "read", "action": "read",
"description": "查看权限监控指标", "description": "查看系统监测指标",
"roles": ["ADMIN", "PM"], "roles": ["ADMIN"],
}, },
"system:monitoring:cache_stats": { "system:monitoring:cache_stats": {
"module": "system_monitoring", "module": "system_monitoring",
"action": "read", "action": "read",
"description": "查看缓存统计", "description": "查看缓存统计",
"roles": ["ADMIN", "PM"], "roles": ["ADMIN"],
}, },
"system:monitoring:alerts": { "system:monitoring:alerts": {
"module": "system_monitoring", "module": "system_monitoring",
"action": "read", "action": "read",
"description": "查看权限告警", "description": "查看权限告警",
"roles": ["ADMIN", "PM"], "roles": ["ADMIN"],
}, },
"system:monitoring:health": { "system:monitoring:health": {
"module": "system_monitoring", "module": "system_monitoring",
"action": "read", "action": "read",
"description": "查看权限系统健康状态", "description": "查看权限系统健康状态",
"roles": ["ADMIN", "PM"], "roles": ["ADMIN"],
}, },
"system:monitoring:access_logs": { "system:monitoring:access_logs": {
"module": "system_monitoring", "module": "system_monitoring",
"action": "read", "action": "read",
"description": "查看权限访问日志", "description": "查看权限访问日志",
"roles": ["ADMIN", "PM"], "roles": ["ADMIN"],
}, },
"system:monitoring:trends": { "system:monitoring:trends": {
"module": "system_monitoring", "module": "system_monitoring",
"action": "read", "action": "read",
"description": "查看权限趋势数据", "description": "查看权限趋势数据",
"roles": ["ADMIN", "PM"], "roles": ["ADMIN"],
}, },
"system:monitoring:reset_metrics": { "system:monitoring:reset_metrics": {
"module": "system_monitoring", "module": "system_monitoring",
"action": "update", "action": "update",
"description": "重置监控指标", "description": "重置系统监测指标",
"roles": ["ADMIN", "PM"], "roles": ["ADMIN"],
}, },
"system:monitoring:clear_alerts": { "system:monitoring:clear_alerts": {
"module": "system_monitoring", "module": "system_monitoring",
"action": "update", "action": "update",
"description": "清除告警", "description": "清除告警",
"roles": ["ADMIN", "PM"], "roles": ["ADMIN"],
}, },
"system:monitoring:security_logs": { "system:monitoring:security_logs": {
"module": "system_monitoring", "module": "system_monitoring",
@@ -1044,6 +1044,6 @@ SYSTEM_MODULE_LABELS: dict[str, str] = {
"system_users": "账号管理", "system_users": "账号管理",
"system_projects": "项目管理", "system_projects": "项目管理",
"system_permissions": "权限管理", "system_permissions": "权限管理",
"system_monitoring": "权限监控", "system_monitoring": "系统监测",
"system_audit": "审计日志", "system_audit": "审计日志",
} }
-11
View File
@@ -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}&centerId={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)
-39
View File
@@ -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
-37
View File
@@ -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
-107
View File
@@ -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 == []
+401 -4
View File
@@ -1,27 +1,59 @@
"""监控API测试:权限系统监控API端点验证。""" """监控API测试:权限系统监控API端点验证。"""
import inspect
import uuid import uuid
import pytest import pytest
from fastapi import HTTPException
from sqlalchemy import text from sqlalchemy import text
from app.core.permission_monitor import set_permission_monitor, PermissionMonitor from app.core.permission_monitor import set_permission_monitor, PermissionMonitor
from app.api.v1 import permission_monitoring from app.api.v1 import permission_monitoring
from app.api.v1.system_permissions import list_system_permissions
class FakeIpInfo: class FakeIpInfo:
def __init__(self, province: str, city: str, isp: str = "电信") -> None: def __init__(self, province: str, city: str, isp: str = "电信", country: str = "中国") -> None:
self.country = "中国" self.country = country
self.province = province self.province = province
self.city = city self.city = city
self.isp = isp self.isp = isp
self.location = f"中国 / {province} / {city} / {isp}" self.location = " / ".join(part for part in [country, province, city, isp] if part)
class AdminUserStub: class AdminUserStub:
is_admin = True 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: async def _seed_permission_log(db_session, study_id: uuid.UUID, user_id: uuid.UUID, *, allowed: bool, elapsed_ms: float) -> None:
study_exists = ( study_exists = (
await db_session.execute(text("SELECT id FROM studies WHERE id = :id"), {"id": str(study_id)}) await db_session.execute(text("SELECT id FROM studies WHERE id = :id"), {"id": str(study_id)})
@@ -495,6 +527,117 @@ async def test_ip_locations_keeps_private_network_location_label(db_session, mon
assert result["items"][0]["city"] == "" 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 @pytest.mark.asyncio
async def test_access_logs_include_user_behavior_summary(db_session): 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"} 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 @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")) await db_session.execute(text("DELETE FROM security_access_logs"))
monkeypatch.setattr(
permission_monitoring,
"resolve_ip_location",
lambda ip: FakeIpInfo("上海市", "上海市", "联通"),
)
await db_session.execute( await db_session.execute(
text( 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"]["anonymous_count"] == 1
assert result["summary"]["error_count"] == 1 assert result["summary"]["error_count"] == 1
assert result["items"][0]["client_ip"] == "203.0.113.10" 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]["account_label"] == "未知账号"
assert result["items"][0]["status_code"] == 401 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"
+21 -1
View File
@@ -274,6 +274,27 @@ async def test_dev_login_allows_plaintext_only_in_development(client_and_db):
assert resp.json()["access_token"] 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 @pytest.mark.asyncio
async def test_dev_login_is_disabled_outside_development(client_and_db): async def test_dev_login_is_disabled_outside_development(client_and_db):
client, _ = 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 first.status_code == 200
assert second.status_code == 401 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
View File
@@ -19,10 +19,28 @@ services:
container_name: ctms_frontend_dev container_name: ctms_frontend_dev
restart: always restart: always
working_dir: /app 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: environment:
NPM_CONFIG_REGISTRY: ${NPM_CONFIG_REGISTRY:-https://registry.npmmirror.com}
VITE_RUNTIME_ENV: ${ENV:-development} VITE_RUNTIME_ENV: ${ENV:-development}
VITE_ALLOW_INSECURE_DEV_LOGIN: ${VITE_ALLOW_INSECURE_DEV_LOGIN:-true} 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: volumes:
- ./frontend:/app - ./frontend:/app
- frontend_node_modules:/app/node_modules - frontend_node_modules:/app/node_modules
@@ -42,8 +60,10 @@ services:
- ./nginx/certs:/etc/nginx/certs:ro - ./nginx/certs:/etc/nginx/certs:ro
- ./frontend/public/favicon.ico:/usr/share/nginx/html/favicon.ico:ro - ./frontend/public/favicon.ico:/usr/share/nginx/html/favicon.ico:ro
depends_on: depends_on:
- backend backend:
- frontend-dev condition: service_started
frontend-dev:
condition: service_healthy
networks: networks:
- ctms_net - ctms_net
+5 -4
View File
@@ -1,6 +1,7 @@
services: services:
db: db:
image: postgres:15-alpine image: postgres:15-alpine
pull_policy: if_not_present
container_name: ctms_db container_name: ctms_db
restart: always restart: always
environment: environment:
@@ -10,8 +11,6 @@ services:
volumes: volumes:
# 持久化数据到 Linux 本地,防止重启丢失 # 持久化数据到 Linux 本地,防止重启丢失
- ./pg_data:/var/lib/postgresql/data - ./pg_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ctms_user -d ctms_db"] test: ["CMD-SHELL", "pg_isready -U ctms_user -d ctms_db"]
interval: 5s interval: 5s
@@ -24,10 +23,9 @@ services:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
pull: false
container_name: ctms_backend container_name: ctms_backend
restart: always restart: always
ports:
- "8000:8000"
environment: environment:
DATABASE_URL: postgresql+asyncpg://ctms_user:secret_password@db/ctms_db DATABASE_URL: postgresql+asyncpg://ctms_user:secret_password@db/ctms_db
ENV: ${ENV:-development} ENV: ${ENV:-development}
@@ -44,6 +42,7 @@ services:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
pull: false
command: python scripts/init_production.py command: python scripts/init_production.py
environment: environment:
DATABASE_URL: postgresql+asyncpg://ctms_user:secret_password@db/ctms_db DATABASE_URL: postgresql+asyncpg://ctms_user:secret_password@db/ctms_db
@@ -62,7 +61,9 @@ services:
build: build:
context: . context: .
dockerfile: nginx/Dockerfile dockerfile: nginx/Dockerfile
pull: false
args: args:
NPM_CONFIG_REGISTRY: ${NPM_CONFIG_REGISTRY:-https://registry.npmmirror.com}
VITE_RUNTIME_ENV: ${ENV:-production} VITE_RUNTIME_ENV: ${ENV:-production}
VITE_ALLOW_INSECURE_DEV_LOGIN: ${VITE_ALLOW_INSECURE_DEV_LOGIN:-false} VITE_ALLOW_INSECURE_DEV_LOGIN: ${VITE_ALLOW_INSECURE_DEV_LOGIN:-false}
container_name: ctms_nginx container_name: ctms_nginx
+32 -6
View File
@@ -94,18 +94,44 @@ grep -n "^\\.env\\.\\*$" .gitignore
## 推荐脚本安装 ## 推荐脚本安装
安装脚本入口: 推荐使用根目录交互入口:
```bash ```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
bash scripts/install-ctms.sh dev bash scripts/install.sh dev
bash scripts/install-ctms.sh main --base-url http://127.0.0.1:8888 bash scripts/install.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 release --base-url https://ctms.example.com
``` ```
可选参数: 可选参数:
@@ -143,7 +169,7 @@ docker compose down -v
如果只想查看参数说明: 如果只想查看参数说明:
```bash ```bash
bash scripts/install-ctms.sh --help bash scripts/install.sh --help
``` ```
## 手工安装/排障:dev 分支 ## 手工安装/排障:dev 分支
-65
View File
@@ -84,71 +84,6 @@ async def test_admin_always_allowed(db_session, study_id):
assert result is True 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测试 ### 1. 权限管理API测试
@@ -37,7 +37,6 @@ Run: `npm run test:unit -- src/views/Login.test.ts`
**Files:** **Files:**
- Modify: `frontend/src/views/Register.vue` - Modify: `frontend/src/views/Register.vue`
- Test: `frontend/src/views/Register.test.ts`
**Step 1: Write failing test** **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** **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** **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** **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 ### Task 3: Final verification
Run: 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` - `npm run type-check`
不执行 git commit,遵循用户指令。 不执行 git commit,遵循用户指令。
+2 -3
View File
@@ -255,7 +255,6 @@ Expected: pass.
- Replace: `frontend/src/views/ia/EtmfPlaceholder.vue` - Replace: `frontend/src/views/ia/EtmfPlaceholder.vue`
- Modify: `frontend/src/locales/zh-CN.ts` - Modify: `frontend/src/locales/zh-CN.ts`
- Test: `frontend/src/views/ia/EtmfPlaceholder.test.ts`
**Step 1: Write failing page test** **Step 1: Write failing page test**
@@ -297,7 +296,7 @@ Run:
```bash ```bash
cd frontend cd frontend
npm test -- src/views/ia/EtmfPlaceholder.test.ts npm test -- src/api/etmf.test.ts
``` ```
Expected: pass. Expected: pass.
@@ -354,7 +353,7 @@ Expected: pass.
```bash ```bash
cd frontend 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. Expected: pass.
@@ -16,7 +16,6 @@
- Modify: `frontend/src/components/QuickActions.vue` - Modify: `frontend/src/components/QuickActions.vue`
- Modify: `frontend/src/views/ia/SubjectManagement.vue` - Modify: `frontend/src/views/ia/SubjectManagement.vue`
- Modify: `frontend/src/utils/permission.ts` - Modify: `frontend/src/utils/permission.ts`
- Test: `frontend/src/components/QuickActions.test.ts`
- Test: `frontend/src/views/ia/SubjectManagement.test.ts` - Test: `frontend/src/views/ia/SubjectManagement.test.ts`
- Test: `frontend/src/utils/permission.test.ts` - Test: `frontend/src/utils/permission.test.ts`
@@ -13,9 +13,7 @@
### Task 1: Route And Navigation Tests ### Task 1: Route And Navigation Tests
**Files:** **Files:**
- Modify: `frontend/src/views/detailNavigation.test.ts` - Modify: `frontend/src/router.test.ts`
- Modify: `frontend/src/views/detailBreadcrumbContext.test.ts`
- Modify: `frontend/src/views/ia/MaterialEquipment.test.ts`
**Steps:** **Steps:**
1. Add the future equipment detail view to detail-navigation expectations. 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.py` - 权限检查测试
- `tests/test_api_permissions_endpoints.py` - 权限管理 API 测试 - `tests/test_api_permissions_endpoints.py` - 权限管理 API 测试
- `tests/test_api_permissions_config.py` - 权限配置测试
- `tests/test_migrated_endpoints.py` - 已迁移端点测试(第1批) - `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.py` - 监控测试
- `tests/test_permission_monitoring_api.py` - 监控 API 测试 - `tests/test_permission_monitoring_api.py` - 监控 API 测试
+11 -1
View File
@@ -2,8 +2,18 @@ FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/
ENV NPM_CONFIG_REGISTRY=$NPM_CONFIG_REGISTRY
COPY package*.json ./ 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 . . COPY . .
RUN npm run build RUN npm run build
-32
View File
@@ -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" } })
);
});
});
+70
View File
@@ -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
View File
@@ -1,20 +1,41 @@
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from "axios"; import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from "axios";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import router from "../router";
import { getToken } from "../utils/auth"; import { getToken } from "../utils/auth";
import type { ApiError } from "../types/api"; import type { ApiError } from "../types/api";
import { useStudyStore } from "../store/study";
import { TEXT } from "../locales"; import { TEXT } from "../locales";
import { extendAccessToken, forceLogout, LOGOUT_REASON_AUTH_EXPIRED } from "../session/sessionManager";
const instance: AxiosInstance = axios.create({ const instance: AxiosInstance = axios.create({
baseURL: "/", baseURL: "/",
timeout: 15000, timeout: 15000,
}); });
const NETWORK_RETRY_LIMIT = 10;
const NETWORK_RETRY_DELAY_MS = 30000;
export type ApiRequestConfig = AxiosRequestConfig & { export type ApiRequestConfig = AxiosRequestConfig & {
suppressErrorMessage?: boolean; suppressErrorMessage?: boolean;
_retry?: 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) => { instance.interceptors.request.use((config: InternalAxiosRequestConfig & ApiRequestConfig) => {
@@ -40,19 +61,23 @@ instance.interceptors.response.use(
// 认证相关的错误由具体页面自行处理,避免重复提示 // 认证相关的错误由具体页面自行处理,避免重复提示
return Promise.reject(error); 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")) { if (status === 404 && typeof data?.message === "string" && data.message.toLowerCase().includes("study")) {
try { await clearInvalidStudyAndNavigate();
const studyStore = useStudyStore();
studyStore.clearCurrentStudy();
} catch {
/* ignore */
}
router.push("/admin/projects");
} }
if (status === 401) { if (status === 401) {
const config = error.config as ApiRequestConfig; const config = error.config as ApiRequestConfig;
if (config && !config._retry) { if (config && !config._retry) {
const { extendAccessToken } = await import("../session/sessionManager");
const result = await extendAccessToken("response-401"); const result = await extendAccessToken("response-401");
if (result.token) { if (result.token) {
config._retry = true; config._retry = true;
@@ -61,11 +86,11 @@ instance.interceptors.response.use(
return instance(config); return instance(config);
} }
if (result.authFailed) { if (result.authFailed) {
forceLogout(LOGOUT_REASON_AUTH_EXPIRED); await forceAuthExpiredLogout();
} }
} }
} else if (status === 403 && (data as any)?.detail === "账号已停用") { } else if (status === 403 && (data as any)?.detail === "账号已停用") {
forceLogout(LOGOUT_REASON_AUTH_EXPIRED); await forceAuthExpiredLogout();
} else { } else {
const suppressErrorMessage = (error.config as ApiRequestConfig | undefined)?.suppressErrorMessage; const suppressErrorMessage = (error.config as ApiRequestConfig | undefined)?.suppressErrorMessage;
if (!suppressErrorMessage) { if (!suppressErrorMessage) {
-89
View File
@@ -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" },
});
});
});
-23
View File
@@ -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");
});
});
+2 -2
View File
@@ -34,7 +34,7 @@ export const updateApiEndpointPermissions = (
export const fetchApiOperations = () => export const fetchApiOperations = () =>
apiGet<{ operations: ApiOperation[] }>(`/api/v1/api-permissions/operations`); apiGet<{ operations: ApiOperation[] }>(`/api/v1/api-permissions/operations`);
// 权限系统监控API // 系统监测API
export const fetchPermissionMetrics = () => export const fetchPermissionMetrics = () =>
apiGet<PermissionMetricsResponse>(`/api/v1/permission-monitoring/metrics`); apiGet<PermissionMetricsResponse>(`/api/v1/permission-monitoring/metrics`);
@@ -55,7 +55,7 @@ export const resetPermissionMetrics = () =>
export const clearPermissionAlerts = () => export const clearPermissionAlerts = () =>
apiPost<void>(`/api/v1/permission-monitoring/clear-alerts`); apiPost<void>(`/api/v1/permission-monitoring/clear-alerts`);
// 权限监控 - 新增API // 系统监测 - 新增API
export const fetchAccessLogs = (params: { export const fetchAccessLogs = (params: {
study_id?: string; study_id?: string;
user_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"');
});
});
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("筛选范围内行为总量"); 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", () => { it("removes the duplicated audit hero copy", () => {
const source = readSource(); 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("用户行为审计");
expect(source).not.toContain("按用户行为聚合访问记录,接口访问细节保留在终端式流水中"); expect(source).not.toContain("按用户行为聚合访问记录,接口访问细节保留在终端式流水中");
expect(source).not.toContain("查看详细的接口访问与安全事件记录");
}); });
it("streams terminal logs from top to bottom and refreshes in realtime", () => { 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("ip=${ip}");
expect(source).toContain("user.sample_ip_address || \"未知 IP\""); expect(source).toContain("user.sample_ip_address || \"未知 IP\"");
expect(source).toContain("ipRankingItems");
expect(source).toContain("IP访问排行"); expect(source).toContain("IP访问排行");
expect(source).toContain("账号"); expect(source).toContain("账号");
expect(source).toContain("rank-location"); expect(source).toContain("rank-location");
expect(source).not.toContain("TOP {{ userStats.length }}");
expect(source).not.toContain("rank-ip-line"); expect(source).not.toContain("rank-ip-line");
expect(source).not.toContain("来源IP"); expect(source).not.toContain("来源IP");
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", () => { it("renders low-level security access logs for anonymous and invalid requests", () => {
const source = readSource(); const source = readSource();
@@ -111,6 +164,16 @@ describe("PermissionAccessLogs", () => {
expect(source).not.toContain("记录匿名、无效令牌、拒绝和异常状态请求,用于排查未知 IP 攻击"); 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", () => { it("opens both audit logs in realtime downloadable dialogs", () => {
const source = readSource(); const source = readSource();
+139 -22
View File
@@ -44,21 +44,23 @@
<div class="section-head"> <div class="section-head">
<div class="section-title-group"> <div class="section-title-group">
<h4>IP访问排行</h4> <h4>IP访问排行</h4>
<p>按来源 IP 排序,账号作为辅助定位</p>
</div> </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>
<div v-if="userStats.length" class="user-rank-list"> <div v-if="ipRankingItems.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-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> <span class="rank-no" :class="{ 'rank-top': index < 3 }">{{ String(index + 1).padStart(2, "0") }}</span>
<div class="rank-user"> <div class="rank-user">
<strong>{{ user.sample_ip_address || "未知 IP" }}</strong> <strong>
<small>{{ user.user_name }} / {{ roleLabel(user.role) }}</small> {{ item.ip }}
<span class="rank-location">{{ user.primary_location || "未知属地" }}</span> <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>
<div class="rank-count"> <div class="rank-count">
<strong>{{ user.total_count }}</strong> <strong>{{ item.total }}</strong>
<small :class="{ 'denied-highlight': user.denied_count > 0 }">{{ user.denied_count }} 拒绝</small> <small :class="{ 'denied-highlight': item.riskCount > 0 }">{{ item.riskCount }} 异常/拒绝</small>
</div> </div>
</div> </div>
</div> </div>
@@ -69,7 +71,6 @@
<div class="section-head"> <div class="section-head">
<div class="section-title-group"> <div class="section-title-group">
<h4>日志审计</h4> <h4>日志审计</h4>
<p>查看详细的接口访问与安全事件记录</p>
</div> </div>
</div> </div>
<div class="log-actions"> <div class="log-actions">
@@ -81,7 +82,7 @@
<strong>接口访问审计日志</strong> <strong>接口访问审计日志</strong>
<span>记录所有 API 接口的访问行为</span> <span>记录所有 API 接口的访问行为</span>
</div> </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>
<div v-if="showSecurityLog" class="log-action-item log-action-security" @click="openSecurityLogDialog"> <div v-if="showSecurityLog" class="log-action-item log-action-security" @click="openSecurityLogDialog">
<div class="log-action-icon security"> <div class="log-action-icon security">
@@ -103,7 +104,7 @@
<div class="dialog-head"> <div class="dialog-head">
<strong>接口访问审计日志</strong> <strong>接口访问审计日志</strong>
<div class="dialog-actions"> <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> <el-button size="small" type="primary" @click="downloadInterfaceLog">下载日志</el-button>
</div> </div>
</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 REALTIME_POLL_INTERVAL_MS = 3000;
const IP_RANKING_LIMIT = 10;
const SECURITY_RANKING_PAGE_SIZE = 200;
const emptySummary: AccessLogsSummary = { const emptySummary: AccessLogsSummary = {
total_count: 0, total_count: 0,
@@ -228,6 +231,26 @@ const SECURITY_AUTH_LABELS: Record<string, string> = {
AUTHENTICATED: "已认证", 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 formatTerminalTime = (iso: string) => {
const date = new Date(iso); const date = new Date(iso);
const pad = (value: number) => String(value).padStart(2, "0"); 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; 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 formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
const metricCards = computed(() => [ const metricCards = computed(() => [
@@ -304,6 +334,77 @@ const securityTerminalLines = computed(() => {
return lines.filter((line) => line.toLowerCase().includes(token)); 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 = () => { const onFilterChange = () => {
page.value = 1; page.value = 1;
loadData(); loadData();
@@ -363,9 +464,15 @@ const loadSecurityData = async (options: { silent?: boolean } = {}) => {
if (!showSecurityLog.value) return; if (!showSecurityLog.value) return;
if (!options.silent) securityLoading.value = true; if (!options.silent) securityLoading.value = true;
try { try {
const res = await fetchSecurityAccessLogs({ status_min: 400, page: 1, page_size: 80 }); const first = await fetchSecurityAccessLogs({ status_min: 400, page: 1, page_size: SECURITY_RANKING_PAGE_SIZE });
securityLogs.value = res.data.items; const allItems = [...first.data.items];
securitySummary.value = res.data.summary || emptySecuritySummary; 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(); if (securityLogDialogVisible.value) scrollSecurityTerminalToBottom();
} catch { } catch {
if (options.silent) return; if (options.silent) return;
@@ -551,7 +658,7 @@ defineExpose({ refresh });
.audit-grid { .audit-grid {
display: grid; display: grid;
grid-template-columns: 380px minmax(0, 1fr); grid-template-columns: minmax(320px, 1fr) minmax(0, 1.35fr);
gap: 14px; gap: 14px;
} }
@@ -632,6 +739,10 @@ defineExpose({ refresh });
} }
.rank-user strong { .rank-user strong {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
color: var(--audit-ink); color: var(--audit-ink);
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
@@ -667,6 +778,14 @@ defineExpose({ refresh });
font-weight: 600; font-weight: 600;
} }
.abnormal-rank-row {
background: #fff7f7;
}
.rank-risk-tag {
flex-shrink: 0;
}
.log-actions { .log-actions {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -798,17 +917,15 @@ defineExpose({ refresh });
} }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.audit-metrics { .audit-metrics,
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.audit-grid { .audit-grid {
grid-template-columns: 1fr; grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.audit-metrics { .audit-metrics,
.audit-grid {
grid-template-columns: 1fr; 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");
});
});
+534 -105
View File
@@ -3,8 +3,7 @@
<section class="ip-hero"> <section class="ip-hero">
<div class="ip-locations-toolbar"> <div class="ip-locations-toolbar">
<div class="toolbar-left"> <div class="toolbar-left">
<span class="toolbar-title">IP 属地分析</span> <span class="toolbar-title">访问来源分析</span>
<span class="toolbar-desc">查看访问来源的地理分布</span>
</div> </div>
<div class="toolbar-actions"> <div class="toolbar-actions">
<el-radio-group v-model="days" size="small" @change="loadData"> <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="30">30天</el-radio-button>
<el-radio-button :value="90">90天</el-radio-button> <el-radio-button :value="90">90天</el-radio-button>
</el-radio-group> </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> <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> </el-button>
@@ -33,23 +32,44 @@
</article> </article>
</section> </section>
<section v-loading="loading" class="distribution-card"> <section class="distribution-card">
<div class="map-panel"> <div class="map-panel">
<div class="section-head"> <div class="section-head">
<div class="section-title-group"> <div class="section-title-group">
<h4>中国地图</h4> <h4>来源分布</h4>
<p>访问来源地理分布热力图</p> </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> </div>
<el-tag type="info" effect="plain" round size="small">{{ currentPeriodLabel }}</el-tag>
</div> </div>
<v-chart :option="chinaMapOption" autoresize class="china-map" />
</div> </div>
<aside class="rank-panel"> <aside class="rank-panel">
<div class="section-head compact"> <div class="section-head compact">
<div class="section-title-group"> <div class="section-title-group">
<h4>热点属地</h4> <h4>热点来源地</h4>
<p>按访问次数排序</p>
</div> </div>
</div> </div>
<div v-if="topRegions.length" class="region-list"> <div v-if="topRegions.length" class="region-list">
@@ -63,23 +83,65 @@
</aside> </aside>
</section> </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> </div>
</template> </template>
<script setup lang="ts"> <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 VChart from "vue-echarts";
import { registerMap, use } from "echarts/core"; import { registerMap, use } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers"; import { CanvasRenderer } from "echarts/renderers";
import { MapChart } from "echarts/charts"; import { EffectScatterChart, LinesChart } from "echarts/charts";
import { TooltipComponent, VisualMapComponent } from "echarts/components"; import { GeoComponent, TooltipComponent } from "echarts/components";
import chinaMapGeoJson from "../assets/china.json"; import chinaMapGeoJson from "../assets/china.json";
import worldMapGeoJson from "../assets/world.json";
import { fetchIpLocations } from "../api/projectPermissions"; import { fetchIpLocations } from "../api/projectPermissions";
import type { IpLocationsResponse, IpLocationStatItem } from "../types/api"; 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-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" }, [ const IconGlobe = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("circle", { cx: "12", cy: "12", r: "10" }), h("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 loading = ref(false);
const days = ref(7); const days = ref(90);
const mapView = ref<"china" | "flow">("china");
const items = ref<IpLocationStatItem[]>([]); const items = ref<IpLocationStatItem[]>([]);
const summary = ref<IpLocationsResponse["summary"] | null>(null); 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> = { const periodLabels: Record<number, string> = {
1: "近 24 小时", 1: "近 24 小时",
@@ -115,11 +190,31 @@ const periodLabels: Record<number, string> = {
}; };
const currentPeriodLabel = computed(() => periodLabels[days.value] || `近 ${days.value} 天`); 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 formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
const formatLocation = (row: IpLocationStatItem) => { 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; return parts.length ? parts.join(" / ") : row.location;
}; };
@@ -166,95 +261,203 @@ const metricCards = computed(() => [
}, },
]); ]);
const provinceData = computed(() => { const rankedSourceRows = computed(() => [...items.value].sort((a, b) => b.total_count - a.total_count).slice(0, 10));
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 maxValue = computed(() => { const isChinaSource = (item: IpLocationStatItem) => item.location === "局域网" || item.country === "中国";
const values = provinceData.value.map((item) => item.total_count);
return values.length ? Math.max(...values) : 100;
});
const chinaMapOption = computed(() => ({ const chinaFlowSourceData = computed(() => rankedSourceRows.value.filter((item) => isChinaSource(item) && resolveSourceCoordinate(item)));
tooltip: {
trigger: "item", const flowSourceData = computed(() => rankedSourceRows.value.filter((item) => resolveSourceCoordinate(item)));
formatter: (params: any) => {
const data = params.data; const buildFlowMapOption = (data: IpLocationStatItem[], mapName = "ctms-world", zoom = 1.2, seriesName = "全球访问链路") => {
if (!data) return `${params.name}<br/>暂无访问数据`; const points = data
return [ .map((item) => ({
`${params.name}`, ...item,
`访问次数:${formatNumber(data.value)}`, name: formatLocation(item),
`访问用户数:${formatNumber(data.unique_user_count || 0)}`, coord: resolveSourceCoordinate(item),
`来源 IP 数:${formatNumber(data.unique_ip_count || 0)}`, abnormal: Boolean(item.country && item.country !== "中国"),
`拒绝次数:${formatNumber(data.denied_count || 0)}`, }))
].join("<br/>"); .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,
}, },
}, geo: {
visualMap: { map: mapName,
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",
roam: false, roam: false,
zoom: 1.08, tooltip: {
show: true,
formatter: formatGeoTooltip,
},
zoom,
label: { show: false }, label: { show: false },
itemStyle: { itemStyle: {
areaColor: "#edf3ff", areaColor: "#e6edf8",
borderColor: "#ffffff", borderColor: "#aebed2",
borderWidth: 1, borderWidth: 1,
shadowColor: "rgba(37, 99, 235, 0.08)",
shadowBlur: 10,
shadowOffsetY: 2,
}, },
emphasis: { emphasis: {
label: { show: true, color: "#0f172a", fontSize: 12, fontWeight: 700 }, label: { show: false },
itemStyle: { areaColor: "#ffb86b", shadowBlur: 12, shadowColor: "rgba(245, 158, 11, 0.28)" }, 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(() => const topRegions = computed(() =>
[...items.value] visibleRankRows.value.map((item, index) => ({
.sort((a, b) => b.total_count - a.total_count) key: `${item.country}-${item.province}-${item.city}-${item.isp}`,
.slice(0, 6) rank: String(index + 1).padStart(2, "0"),
.map((item, index) => ({ name: formatLocation(item),
key: `${item.country}-${item.province}-${item.city}-${item.isp}`, total_count: formatNumber(item.total_count),
rank: String(index + 1).padStart(2, "0"), })),
name: formatLocation(item),
total_count: formatNumber(item.total_count),
})),
); );
const loadData = async () => { 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> </script>
<style scoped> <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); --ip-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%;
min-height: 0;
gap: 14px; gap: 14px;
} }
@@ -305,7 +513,7 @@ defineExpose({ refresh: loadData });
.ip-locations-toolbar { .ip-locations-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: space-between;
gap: 12px; gap: 12px;
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -330,7 +538,9 @@ defineExpose({ refresh: loadData });
.toolbar-actions { .toolbar-actions {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end;
gap: 10px; gap: 10px;
margin-left: auto;
} }
.metric-grid { .metric-grid {
@@ -357,8 +567,8 @@ defineExpose({ refresh: loadData });
.metric-card::after { .metric-card::after {
content: ""; content: "";
position: absolute; position: absolute;
right: -46px; right: -34px;
bottom: -52px; bottom: -42px;
width: 120px; width: 120px;
height: 120px; height: 120px;
border-radius: 18px; border-radius: 18px;
@@ -432,7 +642,10 @@ defineExpose({ refresh: loadData });
.distribution-card { .distribution-card {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 320px; grid-template-columns: minmax(0, 1fr) 320px;
grid-template-rows: minmax(0, 1fr);
gap: 14px; gap: 14px;
flex: 1 1 auto;
min-height: 0;
background: var(--ip-card); background: var(--ip-card);
border: 1px solid var(--ip-border); border: 1px solid var(--ip-border);
border-radius: var(--ip-radius); border-radius: var(--ip-radius);
@@ -443,12 +656,20 @@ defineExpose({ refresh: loadData });
.map-panel, .map-panel,
.rank-panel { .rank-panel {
min-width: 0; min-width: 0;
min-height: 0;
border: 1px solid #f1f5f9; border: 1px solid #f1f5f9;
border-radius: 14px; border-radius: 14px;
background: linear-gradient(180deg, #fafcff 0%, #ffffff 100%); background: linear-gradient(180deg, #fafcff 0%, #ffffff 100%);
padding: 16px; padding: 16px;
} }
.map-panel,
.rank-panel,
.source-map-wrap {
display: flex;
flex-direction: column;
}
.section-head { .section-head {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -461,6 +682,72 @@ defineExpose({ refresh: loadData });
margin-bottom: 16px; 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 { .section-title-group h4 {
margin: 0; margin: 0;
color: var(--ip-ink); color: var(--ip-ink);
@@ -474,9 +761,89 @@ defineExpose({ refresh: loadData });
font-size: 12px; 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%; 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 { .region-list {
@@ -486,12 +853,15 @@ defineExpose({ refresh: loadData });
} }
.region-row { .region-row {
box-sizing: border-box;
display: grid; display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto; grid-template-columns: 36px minmax(0, 1fr) minmax(72px, max-content);
gap: 10px; gap: 10px;
align-items: center; align-items: center;
min-width: 0;
width: 100%; width: 100%;
padding: 12px; max-width: 100%;
padding: 12px 10px;
border-radius: 10px; border-radius: 10px;
background: #fff; background: #fff;
border: 1px solid #f1f5f9; border: 1px solid #f1f5f9;
@@ -515,6 +885,7 @@ defineExpose({ refresh: loadData });
color: #94a3b8; color: #94a3b8;
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
min-width: 0;
} }
.rank.top { .rank.top {
@@ -523,6 +894,7 @@ defineExpose({ refresh: loadData });
} }
.region-name { .region-name {
min-width: 0;
overflow: hidden; overflow: hidden;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
@@ -531,9 +903,14 @@ defineExpose({ refresh: loadData });
} }
.region-value { .region-value {
min-width: 0;
overflow: hidden;
color: var(--ip-blue); color: var(--ip-blue);
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
} }
@media (max-width: 1100px) { @media (max-width: 1100px) {
@@ -544,20 +921,72 @@ defineExpose({ refresh: loadData });
.distribution-card { .distribution-card {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.map-panel,
.rank-panel {
min-height: 0;
}
.rank-panel {
max-height: none;
}
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.ip-locations-toolbar { .ip-locations-toolbar {
flex-direction: column; 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 { .metric-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.china-map { .source-map {
height: 320px; 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> </style>
@@ -48,6 +48,7 @@ describe("PermissionMonitoring.vue", () => {
PermissionTrendCharts: true, PermissionTrendCharts: true,
PermissionAccessLogs: true, PermissionAccessLogs: true,
PermissionIpLocations: true, PermissionIpLocations: true,
SecurityCenter: true,
}, },
}, },
}); });
@@ -64,6 +65,7 @@ describe("PermissionMonitoring.vue", () => {
PermissionTrendCharts: true, PermissionTrendCharts: true,
PermissionAccessLogs: true, PermissionAccessLogs: true,
PermissionIpLocations: true, PermissionIpLocations: true,
SecurityCenter: true,
}, },
}, },
}); });
@@ -77,4 +79,53 @@ describe("PermissionMonitoring.vue", () => {
expect(source).toContain("isAdmin?: boolean"); expect(source).toContain("isAdmin?: boolean");
expect(source).toContain(':show-security-log="props.isAdmin"'); 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("被拒绝最多的权限");
});
}); });
+199 -15
View File
@@ -1,7 +1,13 @@
<template> <template>
<div class="permission-monitoring"> <div class="permission-monitoring" :class="{ 'is-source-tab': activeTab === 'ip-locations' }">
<el-tabs v-model="activeTab" @tab-change="onTabChange"> <el-tabs v-model="activeTab" class="soybean-monitoring-tabs" @tab-change="onTabChange">
<el-tab-pane label="实时概览" name="overview"> <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-if="statsSummary" class="stats-summary">
<div v-for="card in summaryCards" :key="card.label" class="summary-card" :class="card.tone"> <div v-for="card in summaryCards" :key="card.label" class="summary-card" :class="card.tone">
<div class="summary-icon"> <div class="summary-icon">
@@ -83,7 +89,7 @@
<div v-if="topDenied && topDenied.length > 0" class="denied-card"> <div v-if="topDenied && topDenied.length > 0" class="denied-card">
<div class="card-header"> <div class="card-header">
<h3>被拒绝最多的权限</h3> <h3>异常拒绝排行</h3>
<el-tag effect="plain" size="small" type="info">近 7 天</el-tag> <el-tag effect="plain" size="small" type="info">近 7 天</el-tag>
</div> </div>
<div class="denied-list"> <div class="denied-list">
@@ -121,23 +127,53 @@
</div> </div>
</el-tab-pane> </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" /> <PermissionTrendCharts ref="trendChartsRef" />
</el-tab-pane> </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" /> <PermissionAccessLogs ref="accessLogsRef" :show-security-log="props.isAdmin" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="IP属地" name="ip-locations"> <el-tab-pane label="来源分析" name="ip-locations">
<PermissionIpLocations ref="ipLocationsRef" /> <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-tab-pane>
</el-tabs> </el-tabs>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, h, onMounted } from "vue"; import { ref, computed, h, nextTick, onMounted } from "vue";
import type { import type {
PermissionMetricsResponse, PermissionMetricsResponse,
AlertsResponse, AlertsResponse,
@@ -155,6 +191,7 @@ import {
import PermissionTrendCharts from "./PermissionTrendCharts.vue"; import PermissionTrendCharts from "./PermissionTrendCharts.vue";
import PermissionAccessLogs from "./PermissionAccessLogs.vue"; import PermissionAccessLogs from "./PermissionAccessLogs.vue";
import PermissionIpLocations from "./PermissionIpLocations.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" }, [ 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" }), 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: "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" }), 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 activeTab = ref("overview");
const props = withDefaults(defineProps<{ isAdmin?: boolean }>(), { const props = withDefaults(defineProps<{ isAdmin?: boolean }>(), {
@@ -189,7 +229,13 @@ const statsSummary = ref<StatsSummaryResponse | null>(null);
const trendChartsRef = ref<InstanceType<typeof PermissionTrendCharts>>(); const trendChartsRef = ref<InstanceType<typeof PermissionTrendCharts>>();
const accessLogsRef = ref<InstanceType<typeof PermissionAccessLogs>>(); 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 => { const formatTime = (timestamp: number): string => {
return new Date(timestamp * 1000).toLocaleString("zh-CN"); return new Date(timestamp * 1000).toLocaleString("zh-CN");
@@ -226,19 +272,19 @@ const summaryCards = computed(() => {
if (!statsSummary.value) return []; if (!statsSummary.value) return [];
return [ return [
{ {
label: "今日总检查", label: "今日监测事件",
value: statsSummary.value.today.total_checks.toLocaleString(), value: statsSummary.value.today.total_checks.toLocaleString(),
tone: "blue", tone: "blue",
icon: IconCheck, icon: IconCheck,
}, },
{ {
label: "每分钟请求", label: "每分钟事件",
value: statsSummary.value.last_hour.requests_per_minute, value: statsSummary.value.last_hour.requests_per_minute,
tone: "cyan", tone: "cyan",
icon: IconActivity, icon: IconActivity,
}, },
{ {
label: "今日允许率", label: "今日通过率",
value: `${statsSummary.value.today.allow_rate}%`, value: `${statsSummary.value.today.allow_rate}%`,
tone: statsSummary.value.today.allow_rate >= 80 ? "green" : "warning", tone: statsSummary.value.today.allow_rate >= 80 ? "green" : "warning",
icon: IconPercent, icon: IconPercent,
@@ -250,7 +296,7 @@ const summaryCards = computed(() => {
icon: IconClock, icon: IconClock,
}, },
{ {
label: "历史总记录", label: "历史事件总数",
value: statsSummary.value.total_logs.toLocaleString(), value: statsSummary.value.total_logs.toLocaleString(),
tone: "violet", tone: "violet",
icon: IconDatabase, icon: IconDatabase,
@@ -273,13 +319,18 @@ const loadOverviewData = async () => {
if (summaryRes.status === "fulfilled") statsSummary.value = summaryRes.value.data; if (summaryRes.status === "fulfilled") statsSummary.value = summaryRes.value.data;
}; };
const onTabChange = (tab: string) => { const onTabChange = async (tab: string) => {
if (tab === "overview") loadOverviewData(); if (tab === "overview") loadOverviewData();
if (tab === "ip-locations") {
await nextTick();
await ipLocationsRef.value?.resize();
}
}; };
const refresh = () => { const refresh = () => {
if (activeTab.value === "overview") loadOverviewData(); if (activeTab.value === "overview") loadOverviewData();
else if (activeTab.value === "trends") trendChartsRef.value?.refresh(); 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 === "logs") accessLogsRef.value?.refresh();
else if (activeTab.value === "ip-locations") ipLocationsRef.value?.refresh(); else if (activeTab.value === "ip-locations") ipLocationsRef.value?.refresh();
}; };
@@ -294,11 +345,144 @@ defineExpose({ refresh });
--mon-ink: #1a2332; --mon-ink: #1a2332;
--mon-muted: #64748b; --mon-muted: #64748b;
--mon-border: #e2e8f0; --mon-border: #e2e8f0;
--mon-tab-active: #6c63ff;
--mon-tab-active-bg: #f0edff;
--mon-radius: 16px; --mon-radius: 16px;
--mon-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03); --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; 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 { .stats-summary {
display: grid; display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr)); 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-charts">
<div class="trend-toolbar"> <div class="trend-toolbar">
<div class="toolbar-left"> <div class="toolbar-left">
<span class="toolbar-title">数据趋势</span> <span class="toolbar-title">系统性能趋势</span>
<span class="toolbar-desc">查看权限系统各项指标的变化趋势</span>
</div> </div>
<el-radio-group v-model="period" size="small" @change="loadData"> <el-radio-group v-model="period" size="small" @change="loadData">
<el-radio-button value="24h">24小时</el-radio-button> <el-radio-button value="24h">24小时</el-radio-button>
@@ -18,7 +17,7 @@
<div class="chart-icon blue"> <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> <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> </div>
<h4>检查量趋势</h4> <h4>监测事件趋势</h4>
</div> </div>
<v-chart :option="checksChartOption" autoresize class="chart" /> <v-chart :option="checksChartOption" autoresize class="chart" />
</div> </div>
@@ -45,7 +44,7 @@
<div class="chart-icon danger"> <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> <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> </div>
<h4>拒绝率趋势</h4> <h4>异常拒绝率趋势</h4>
</div> </div>
<v-chart :option="denyRateOption" autoresize class="chart" /> <v-chart :option="denyRateOption" autoresize class="chart" />
</div> </div>
@@ -181,7 +180,7 @@ const denyRateOption = computed(() => ({
yAxis: { type: "value", max: 100, name: "%", splitLine: { lineStyle: { color: "#f1f5f9" } }, axisLabel: { color: "#64748b" } }, yAxis: { type: "value", max: 100, name: "%", splitLine: { lineStyle: { color: "#f1f5f9" } }, axisLabel: { color: "#64748b" } },
series: [ series: [
{ {
name: "拒绝率", name: "异常拒绝率",
type: "line", type: "line",
smooth: true, smooth: true,
symbol: "circle", 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");
});
});
+971
View File
@@ -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");
});
});
+110
View 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",
]);
});
});
+19 -3
View File
@@ -2,6 +2,9 @@ export const TEXT = {
common: { common: {
appName: "CTMS", appName: "CTMS",
fallback: "—", fallback: "—",
separators: {
dot: "·",
},
loading: "加载中", loading: "加载中",
actions: { actions: {
add: "新增", add: "新增",
@@ -88,6 +91,18 @@ export const TEXT = {
userFallback: "用户", userFallback: "用户",
basicInfo: "基本信息", basicInfo: "基本信息",
allSites: "所有中心", allSites: "所有中心",
locked: "已锁定",
phase: "分期",
plannedSites: "中心",
plannedEnrollment: "入组",
dataUpdatedAt: "数据",
projectReminders: "提醒",
projectRemindersSubtitle: "逾期与风险提示",
projectRemindersEmpty: "暂无逾期风险",
overdueAes: "逾期 AE",
overdueAesDesc: "需关注安全性事件处理时效",
overdueMonitoringIssues: "逾期监查问题",
overdueMonitoringIssuesDesc: "需跟进问题关闭与整改",
yes: "是", yes: "是",
no: "否", no: "否",
}, },
@@ -262,6 +277,7 @@ export const TEXT = {
projectManagement: "项目管理", projectManagement: "项目管理",
auditLogs: "审计日志", auditLogs: "审计日志",
permissionManagement: "权限管理", permissionManagement: "权限管理",
systemMonitoring: "系统监测",
currentProject: "当前项目", currentProject: "当前项目",
projectOverview: "项目总览", projectOverview: "项目总览",
projectMilestones: "项目里程碑", projectMilestones: "项目里程碑",
@@ -950,8 +966,7 @@ export const TEXT = {
default: "当前角色无权执行该操作", default: "当前角色无权执行该操作",
}, },
profile: { profile: {
title: "个人设置", title: "个人中心",
subtitle: "更新个人信息或修改登录密码",
uploadAvatar: "上传头像", uploadAvatar: "上传头像",
currentPassword: "当前密码", currentPassword: "当前密码",
currentPasswordHint: "修改密码时必填", currentPasswordHint: "修改密码时必填",
@@ -965,6 +980,7 @@ export const TEXT = {
updateSuccess: "已更新", updateSuccess: "已更新",
updateFailed: "更新失败", updateFailed: "更新失败",
avatarUpdated: "头像已更新", avatarUpdated: "头像已更新",
avatarTypeInvalid: "头像仅支持图片格式",
avatarUploadFailed: "头像上传失败", avatarUploadFailed: "头像上传失败",
}, },
}, },
@@ -1135,7 +1151,7 @@ export const TEXT = {
title: "权限管理", title: "权限管理",
moduleLevel: "模块级权限", moduleLevel: "模块级权限",
apiLevel: "角色权限", apiLevel: "角色权限",
monitoring: "权限监控", monitoring: "系统监测",
refreshMetrics: "刷新指标", refreshMetrics: "刷新指标",
save: "保存", save: "保存",
search: "搜索端点", search: "搜索端点",
+18 -1
View File
@@ -45,7 +45,7 @@ describe("admin project route permissions", () => {
it("guards project permission configuration with the system-level project config permission", () => { it("guards project permission configuration with the system-level project config permission", () => {
const source = readRouter(); const source = readRouter();
const projectPermissionRouteStart = source.indexOf('name: "AdminPermissionsProject"'); 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); const projectPermissionRoute = source.slice(projectPermissionRouteStart, projectPermissionRouteEnd);
expect(source).toContain('const SYSTEM_PERMISSION_PROJECT_CONFIG = "system:permissions:project_config";'); 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"); 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", () => { it("keeps login landing independent from PM management backend access", () => {
const source = readRouter(); const source = readRouter();
+15 -8
View File
@@ -18,6 +18,7 @@ import AdminUserApproval from "../views/admin/AdminUserApproval.vue";
import AdminProjects from "../views/admin/Projects.vue"; import AdminProjects from "../views/admin/Projects.vue";
import AdminSites from "../views/admin/Sites.vue"; import AdminSites from "../views/admin/Sites.vue";
import PermissionManagement from "../views/admin/PermissionManagement.vue"; import PermissionManagement from "../views/admin/PermissionManagement.vue";
import SystemMonitoringPage from "../views/admin/SystemMonitoringPage.vue";
import ProjectDetail from "../views/admin/ProjectDetail.vue"; import ProjectDetail from "../views/admin/ProjectDetail.vue";
import ProfileSettings from "../views/ProfileSettings.vue"; import ProfileSettings from "../views/ProfileSettings.vue";
import ProjectOverview from "../views/ia/ProjectOverview.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_READ = "system:permissions:read";
const SYSTEM_PERMISSION_PROJECT_CONFIG = "system:permissions:project_config"; const SYSTEM_PERMISSION_PROJECT_CONFIG = "system:permissions:project_config";
const SYSTEM_PERMISSION_MONITORING_METRICS = "system:monitoring:metrics";
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
{ {
@@ -372,6 +372,20 @@ const routes: RouteRecordRaw[] = [
component: AuditLogs, component: AuditLogs,
meta: { title: TEXT.menu.auditLogs, adminProjectPermission: { module: "audit_export", action: "read" } }, 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", path: "permissions",
redirect: "/admin/permissions/system", redirect: "/admin/permissions/system",
@@ -388,12 +402,6 @@ const routes: RouteRecordRaw[] = [
component: PermissionManagement, component: PermissionManagement,
meta: { title: "项目权限配置", systemPermission: SYSTEM_PERMISSION_PROJECT_CONFIG }, 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[]> = { const SYSTEM_PERMISSION_ROLES: Record<string, string[]> = {
[SYSTEM_PERMISSION_READ]: ["ADMIN", "PM"], [SYSTEM_PERMISSION_READ]: ["ADMIN", "PM"],
[SYSTEM_PERMISSION_PROJECT_CONFIG]: ["ADMIN", "PM"], [SYSTEM_PERMISSION_PROJECT_CONFIG]: ["ADMIN", "PM"],
[SYSTEM_PERMISSION_MONITORING_METRICS]: ["ADMIN", "PM"],
}; };
const getTargetProjectId = (to: any) => { const getTargetProjectId = (to: any) => {
-16
View File
@@ -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");
});
});
+11 -4
View File
@@ -218,7 +218,7 @@ export interface HealthResponse {
cache_stats: CacheStatsResponse; cache_stats: CacheStatsResponse;
} }
// 权限监控 - 访问日志 // 系统监测 - 访问日志
export interface AccessLogItem { export interface AccessLogItem {
id: string; id: string;
study_id: string; study_id: string;
@@ -273,10 +273,17 @@ export interface SecurityAccessLogItem {
status_code: number; status_code: number;
elapsed_ms: number; elapsed_ms: number;
client_ip: string | null; client_ip: string | null;
ip_location: string;
ip_country: string;
ip_province: string;
ip_city: string;
ip_isp: string;
user_agent: string | null; user_agent: string | null;
auth_status: "ANONYMOUS" | "INVALID_TOKEN" | "AUTHENTICATED" | string; auth_status: "ANONYMOUS" | "INVALID_TOKEN" | "AUTHENTICATED" | string;
user_identifier: string | null; user_identifier: string | null;
account_label: string; 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; created_at: string;
} }
@@ -293,7 +300,7 @@ export interface SecurityAccessLogsResponse {
items: SecurityAccessLogItem[]; items: SecurityAccessLogItem[];
} }
// 权限监控 - 趋势数据 // 系统监测 - 趋势数据
export interface TrendDataPoint { export interface TrendDataPoint {
bucket_time: string; bucket_time: string;
total_checks: number; total_checks: number;
@@ -312,7 +319,7 @@ export interface TrendsResponse {
data_points: TrendDataPoint[]; data_points: TrendDataPoint[];
} }
// 权限监控 - 被拒绝最多 // 系统监测 - 被拒绝最多
export interface TopDeniedItem { export interface TopDeniedItem {
endpoint_key: string; endpoint_key: string;
role: string; role: string;
@@ -349,7 +356,7 @@ export interface IpLocationsResponse {
items: IpLocationStatItem[]; items: IpLocationStatItem[];
} }
// 权限监控 - 统计摘要 // 系统监测 - 统计摘要
export interface StatsSummaryResponse { export interface StatsSummaryResponse {
total_logs: number; total_logs: number;
today: { 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");
});
});
+19 -11
View File
@@ -18,18 +18,19 @@ describe("Login protocol agreement", () => {
expect(protocolGuardIndex).toBeLessThan(loginCallIndex); 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(); const source = readLoginView();
expect(source).toContain('v-model="form.rememberPassword"');
expect(source).toContain("记住密码");
expect(source).toContain('autocomplete="username"'); expect(source).toContain('autocomplete="username"');
expect(source).toContain('name="username"'); expect(source).toContain('name="username"');
expect(source).toContain(":name=\"form.rememberPassword ? 'password' : 'ctms-login-password'\""); expect(source).toContain('name="ctms-login-password"');
expect(source).toContain("tryLoadBrowserCredential"); expect(source).toContain('autocomplete="new-password"');
expect(source).toContain("tryStoreBrowserCredential"); expect(source).not.toContain('v-model="form.rememberPassword"');
expect(source).toContain("navigator.credentials"); expect(source).not.toContain("记住密码");
expect(source).toContain("password: true"); 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"');
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"); expect(source).toContain("confirmProtocol");
}); });
it("avoids browser password caching when remember password is off", () => { it("avoids browser password caching", () => {
const source = readLoginView(); const source = readLoginView();
expect(source).toContain(":name=\"form.rememberPassword ? 'password' : 'ctms-login-password'\""); expect(source).toContain('name="ctms-login-password"');
expect(source).toContain(":autocomplete=\"form.rememberPassword ? 'current-password' : 'new-password'\""); expect(source).toContain('autocomplete="new-password"');
expect(source).not.toContain('autocomplete="current-password"'); expect(source).not.toContain('autocomplete="current-password"');
}); });
@@ -87,4 +88,11 @@ describe("Login protocol agreement", () => {
expect(source).toContain("已安全退出"); expect(source).toContain("已安全退出");
expect(source).toContain(".logout-notice"); 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);");
});
}); });
+4 -60
View File
@@ -80,8 +80,8 @@
placeholder="请输入密码" placeholder="请输入密码"
show-password show-password
size="large" size="large"
:name="form.rememberPassword ? 'password' : 'ctms-login-password'" name="ctms-login-password"
:autocomplete="form.rememberPassword ? 'current-password' : 'new-password'" autocomplete="new-password"
class="login-input" class="login-input"
> >
<template #prefix> <template #prefix>
@@ -94,9 +94,6 @@
</el-form-item> </el-form-item>
<div class="login-options"> <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"> <el-checkbox v-model="form.agreeProtocol" class="protocol-checkbox">
<span class="protocol-text"> <span class="protocol-text">
我已阅读并同意 我已阅读并同意
@@ -160,17 +157,12 @@ import { authProtocolSections } from "../content/authProtocol";
const auth = useAuthStore(); const auth = useAuthStore();
const router = useRouter(); const router = useRouter();
const REMEMBER_PASSWORD_KEY = "ctms_remember_password";
const AGREE_PROTOCOL_KEY = "ctms_agree_protocol"; 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 formRef = ref<FormInstance>();
const form = reactive({ const form = reactive({
email: "", email: "",
password: "", password: "",
rememberPassword: false,
agreeProtocol: false, agreeProtocol: false,
}); });
@@ -210,9 +202,7 @@ onMounted(async () => {
}; };
} }
form.email = localStorage.getItem("ctms_last_login_email") || ""; form.email = localStorage.getItem("ctms_last_login_email") || "";
form.rememberPassword = localStorage.getItem(REMEMBER_PASSWORD_KEY) === "true";
form.agreeProtocol = localStorage.getItem(AGREE_PROTOCOL_KEY) === "true"; form.agreeProtocol = localStorage.getItem(AGREE_PROTOCOL_KEY) === "true";
await tryLoadBrowserCredential();
}); });
watch( 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 = () => { const openProtocolDialog = () => {
protocolDialogVisible.value = true; protocolDialogVisible.value = true;
}; };
@@ -276,7 +232,6 @@ const onSubmit = async () => {
loading.value = true; loading.value = true;
try { try {
await auth.login(form.email, form.password); await auth.login(form.email, form.password);
await tryStoreBrowserCredential(form.email, form.password);
const studyStore = useStudyStore(); const studyStore = useStudyStore();
const userKey = auth.user?.email || form.email; const userKey = auth.user?.email || form.email;
await studyStore.restoreStudyForUser(userKey, { preferActive: !!auth.user?.is_admin }); await studyStore.restoreStudyForUser(userKey, { preferActive: !!auth.user?.is_admin });
@@ -350,9 +305,8 @@ const onSubmit = async () => {
.login-container { .login-container {
position: relative; position: relative;
z-index: 10; z-index: 10;
width: 100%; width: clamp(480px, 34vw, 560px);
max-width: 440px; max-width: calc(100vw - 40px);
padding: 0 20px;
} }
.login-card { .login-card {
@@ -511,7 +465,6 @@ const onSubmit = async () => {
margin: -4px 0 26px; margin: -4px 0 26px;
} }
.remember-checkbox,
.protocol-checkbox { .protocol-checkbox {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -519,12 +472,10 @@ const onSubmit = async () => {
min-height: 24px; min-height: 24px;
} }
.remember-checkbox :deep(.el-checkbox__input),
.protocol-checkbox :deep(.el-checkbox__input) { .protocol-checkbox :deep(.el-checkbox__input) {
margin-top: 2px; margin-top: 2px;
} }
.remember-checkbox :deep(.el-checkbox__label),
.protocol-checkbox :deep(.el-checkbox__label) { .protocol-checkbox :deep(.el-checkbox__label) {
display: block; display: block;
padding-left: 10px; padding-left: 10px;
@@ -532,13 +483,6 @@ const onSubmit = async () => {
white-space: normal; white-space: normal;
} }
.remember-text {
color: #52657b;
font-size: 12px;
font-weight: 700;
line-height: 18px;
}
.protocol-text { .protocol-text {
color: #475569; color: #475569;
font-size: 12px; 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"');
});
});
+301 -60
View File
@@ -1,70 +1,103 @@
<template> <template>
<div class="page"> <div class="page">
<el-card class="unified-shell"> <div class="profile-layout">
<h3 class="title">{{ TEXT.modules.profile.title }}</h3> <aside class="profile-aside">
<p class="subtitle">{{ TEXT.modules.profile.subtitle }}</p> <div class="avatar-panel">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" class="form" autocomplete="off"> <el-avatar :size="88" :src="avatarPreview" :alt="form.full_name || form.email" class="profile-avatar">
<el-form-item :label="TEXT.common.fields.avatar"> {{ profileInitial }}
<div class="avatar-row"> </el-avatar>
<el-avatar :size="64" :src="avatarPreview" :alt="form.full_name || form.email"> <div class="avatar-meta">
{{ form.full_name?.charAt(0)?.toUpperCase() || form.email?.charAt(0)?.toUpperCase() }} <div class="avatar-name">{{ form.full_name || TEXT.modules.profile.title }}</div>
</el-avatar> <div class="avatar-email">{{ form.email }}</div>
<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> </div>
</el-form-item> <el-upload
<el-form-item :label="TEXT.common.fields.email"> class="avatar-uploader"
<el-input v-model="form.email" disabled /> :show-file-list="false"
</el-form-item> accept="image/png,image/jpeg,image/gif,image/webp"
<el-form-item :label="TEXT.common.fields.name" prop="full_name"> action="/api/v1/auth/me/avatar"
<el-input v-model="form.full_name" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" /> name="file"
</el-form-item> :headers="uploadHeaders"
<el-form-item :label="TEXT.common.fields.clinicalDepartment" prop="clinical_department"> :before-upload="beforeAvatarUpload"
<el-input v-model="form.clinical_department" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.clinicalDepartment" /> :on-success="onAvatarUploaded"
</el-form-item> :on-error="onAvatarError"
<el-divider /> >
<el-form-item :label="TEXT.modules.profile.currentPassword" prop="current_password"> <el-button :icon="Upload" class="upload-button">{{ TEXT.modules.profile.uploadAvatar }}</el-button>
<el-input </el-upload>
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>
</div> </div>
</el-form> </aside>
</el-card>
<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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref, watch } from "vue";
import type { FormInstance, FormRules } from "element-plus"; import type { FormInstance, FormRules, UploadRawFile } from "element-plus";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { Close, Upload } from "@element-plus/icons-vue";
import { updateProfile, fetchMe } from "../api/auth"; import { updateProfile, fetchMe } from "../api/auth";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import { getToken } from "../utils/auth"; import { getToken } from "../utils/auth";
import { TEXT, requiredMessage } from "../locales"; import { TEXT, requiredMessage } from "../locales";
const emit = defineEmits<{
"close-request": [];
"dirty-change": [dirty: boolean];
saved: [];
}>();
const auth = useAuthStore(); const auth = useAuthStore();
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
const submitting = ref(false); const submitting = ref(false);
@@ -76,10 +109,22 @@ const form = reactive({
password: "", password: "",
confirmPassword: "", confirmPassword: "",
}); });
const savedProfile = ref({
full_name: "",
clinical_department: "",
});
const avatarPreview = ref<string | undefined>(); const avatarPreview = ref<string | undefined>();
const uploadHeaders = computed<Record<string, string>>(() => ({ const uploadHeaders = computed<Record<string, string>>(() => ({
Authorization: `Bearer ${getToken() || ""}`, 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> = { const rules: FormRules<typeof form> = {
full_name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), trigger: "blur" }], full_name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), trigger: "blur" }],
@@ -128,6 +173,10 @@ const loadProfile = async () => {
form.email = data.email; form.email = data.email;
form.full_name = data.full_name; form.full_name = data.full_name;
form.clinical_department = data.clinical_department; form.clinical_department = data.clinical_department;
savedProfile.value = {
full_name: data.full_name,
clinical_department: data.clinical_department,
};
avatarPreview.value = data.avatar_url || undefined; avatarPreview.value = data.avatar_url || undefined;
}; };
@@ -148,7 +197,12 @@ const onSubmit = async () => {
form.current_password = ""; form.current_password = "";
form.password = ""; form.password = "";
form.confirmPassword = ""; form.confirmPassword = "";
savedProfile.value = {
full_name: form.full_name,
clinical_department: form.clinical_department,
};
avatarPreview.value = auth.user?.avatar_url || avatarPreview.value; avatarPreview.value = auth.user?.avatar_url || avatarPreview.value;
emit("saved");
} catch (error: any) { } catch (error: any) {
ElMessage.error(error?.response?.data?.message || TEXT.modules.profile.updateFailed); ElMessage.error(error?.response?.data?.message || TEXT.modules.profile.updateFailed);
} finally { } finally {
@@ -163,6 +217,12 @@ const onAvatarUploaded = async () => {
ElMessage.success(TEXT.modules.profile.avatarUpdated); 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) => { const onAvatarError = (err: any) => {
ElMessage.error(err?.response?.data?.message || TEXT.modules.profile.avatarUploadFailed); ElMessage.error(err?.response?.data?.message || TEXT.modules.profile.avatarUploadFailed);
}; };
@@ -170,24 +230,205 @@ const onAvatarError = (err: any) => {
onMounted(() => { onMounted(() => {
loadProfile(); loadProfile();
}); });
watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: true });
</script> </script>
<style scoped> <style scoped>
.page { .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 { .title {
margin: 0; margin: 0;
color: #172033;
font-size: 24px;
font-weight: 800;
line-height: 1.25;
} }
.subtitle {
margin: 6px 0 16px;
color: #666;
}
.form { .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 { .actions {
margin-top: 12px; margin-top: 28px;
padding-left: 112px;
text-align: right; 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> </style>
-36
View File
@@ -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);
});
});
-27
View File
@@ -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"); const source = readFileSync(resolve(__dirname, "./ApiPermissions.vue"), "utf8");
expect(source).toContain('label="角色权限"'); 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", () => { it("has save button disabled when not dirty", () => {
+2 -70
View File
@@ -19,10 +19,6 @@
</span> </span>
</div> </div>
<div class="permission-actions"> <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-button type="primary" :loading="saving" :disabled="!dirty" @click="save">
<el-icon><Check /></el-icon> <el-icon><Check /></el-icon>
保存 保存
@@ -46,17 +42,6 @@
@update="onApiMatrixUpdate" @update="onApiMatrixUpdate"
/> />
</el-tab-pane> </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> </el-tabs>
</div> </div>
</div> </div>
@@ -66,39 +51,24 @@
import { ref, computed, onMounted } from "vue"; import { ref, computed, onMounted } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { Key, RefreshRight, Check } from "@element-plus/icons-vue"; import { Key, Check } from "@element-plus/icons-vue";
import type { import type {
ApiEndpointPermissionsResponse, ApiEndpointPermissionsResponse,
PermissionMetricsResponse,
CacheStatsResponse,
AlertsResponse,
HealthResponse,
} from "@/types/api"; } from "@/types/api";
import { import {
fetchApiEndpointPermissions, fetchApiEndpointPermissions,
updateApiEndpointPermissions, updateApiEndpointPermissions,
fetchPermissionMetrics,
fetchCacheStats,
fetchPermissionAlerts,
fetchPermissionHealth,
resetPermissionMetrics,
} from "@/api/projectPermissions"; } from "@/api/projectPermissions";
import { useStudyStore } from "@/store/study"; import { useStudyStore } from "@/store/study";
import { useAuthStore } from "@/store/auth";
import { isSystemAdmin } from "@/utils/roles";
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue"; import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
import PermissionTemplateSelector from "@/components/PermissionTemplateSelector.vue"; import PermissionTemplateSelector from "@/components/PermissionTemplateSelector.vue";
const route = useRoute(); const route = useRoute();
const studyStore = useStudyStore(); 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 loading = ref(false);
const saving = ref(false); const saving = ref(false);
const resetting = ref(false);
const project = computed(() => studyStore.currentStudy); const project = computed(() => studyStore.currentStudy);
const studyId = computed(() => { const studyId = computed(() => {
@@ -109,11 +79,6 @@ const studyId = computed(() => {
// 权限数据 // 权限数据
const apiMatrix = ref<ApiEndpointPermissionsResponse | null>(null); 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); 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) => { const onApiMatrixUpdate = (newMatrix: ApiEndpointPermissionsResponse) => {
apiMatrix.value = newMatrix; apiMatrix.value = newMatrix;
dirty.value = true; 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(() => { onMounted(() => {
loadPermissionData(); loadPermissionData();
}); });
@@ -151,6 +151,13 @@ describe("permission management custom roles", () => {
expect(source).not.toContain("project.members.manage"); 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", () => { it("uses QA and CTA as preset project permission role keys", () => {
const source = readSource(); const source = readSource();
@@ -344,7 +351,9 @@ describe("permission management custom roles", () => {
it("does not render the duplicated monitoring header", () => { it("does not render the duplicated monitoring header", () => {
const source = readSource(); 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("重置指标"); expect(source).not.toContain("重置指标");
expect(source).not.toContain("refreshMonitoring"); expect(source).not.toContain("refreshMonitoring");
@@ -84,7 +84,6 @@
<el-table-column prop="full_name" label="姓名" min-width="140"> <el-table-column prop="full_name" label="姓名" min-width="140">
<template #default="{ row }"> <template #default="{ row }">
<div class="member-name-cell"> <div class="member-name-cell">
<span class="member-avatar">{{ (row.full_name || '?')[0] }}</span>
<span class="member-name">{{ row.full_name }}</span> <span class="member-name">{{ row.full_name }}</span>
</div> </div>
</template> </template>
@@ -216,18 +215,6 @@
</div> </div>
</template> </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"; } from "@/utils/projectPermissionModules";
import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta"; import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue"; import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
import PermissionTemplateSelector from "@/components/PermissionTemplateSelector.vue"; import PermissionTemplateSelector from "@/components/PermissionTemplateSelector.vue";
const route = useRoute(); const route = useRoute();
@@ -658,16 +644,15 @@ const memberRoleLabels = (row: MemberRoleRow) => {
}; };
// ── 顶层标签 ── // ── 顶层标签 ──
const tabFromPath = (): "project" | "system" | "monitoring" => { const tabFromPath = (): "project" | "system" => {
const p = route.path; const p = route.path;
if (p.endsWith("/system")) return "system"; if (p.endsWith("/system")) return "system";
if (p.endsWith("/monitoring")) return "monitoring";
return "project"; return "project";
}; };
const activeTab = computed({ const activeTab = computed({
get: () => tabFromPath(), get: () => tabFromPath(),
set: (tab: "project" | "system" | "monitoring") => { set: (tab: "project" | "system") => {
const pathMap = { system: "/admin/permissions/system", project: "/admin/permissions/project", monitoring: "/admin/permissions/monitoring" }; const pathMap = { system: "/admin/permissions/system", project: "/admin/permissions/project" };
router.push({ path: pathMap[tab], query: route.query }); router.push({ path: pathMap[tab], query: route.query });
}, },
}); });
@@ -1782,21 +1767,6 @@ onMounted(async () => {
.member-name-cell { .member-name-cell {
display: flex; display: flex;
align-items: center; 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 { .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");
});
});
-31
View File
@@ -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'\"");
});
});
-103
View File
@@ -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");
});
});
-41
View File
@@ -87,9 +87,6 @@
<el-table-column :label="TEXT.common.fields.name" width="360"> <el-table-column :label="TEXT.common.fields.name" width="360">
<template #default="scope"> <template #default="scope">
<div class="user-cell"> <div class="user-cell">
<div class="user-avatar" :class="'avatar--' + (scope.row.status || '').toLowerCase()">
{{ getInitials(scope.row.full_name) }}
</div>
<div class="user-info"> <div class="user-info">
<span class="user-name">{{ scope.row.full_name }}</span> <span class="user-name">{{ scope.row.full_name }}</span>
<span class="user-email">{{ scope.row.email }}</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 pendingCount = computed(() => allUsers.value.filter(u => u.status === 'PENDING').length);
const disabledCount = computed(() => allUsers.value.filter(u => u.status === 'DISABLED').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) => { const statusType = (status: string) => {
switch (status) { switch (status) {
case "ACTIVE": return "success"; case "ACTIVE": return "success";
@@ -487,37 +477,6 @@ onBeforeUnmount(() => {
.user-cell { .user-cell {
display: flex; display: flex;
align-items: center; 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 { .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");
});
});
-56
View File
@@ -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)");
});
});
+40 -96
View File
@@ -2,24 +2,6 @@
<div class="page ctms-page-shell page--flush"> <div class="page ctms-page-shell page--flush">
<div v-if="study.currentStudy" class="page-body"> <div v-if="study.currentStudy" class="page-body">
<div class="overview-container"> <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"> <section class="overview-card">
<div class="card-header"> <div class="card-header">
<div class="card-header-left"> <div class="card-header-left">
@@ -28,11 +10,19 @@
</span> </span>
<div class="card-title">中心整体进度</div> <div class="card-title">中心整体进度</div>
</div> </div>
<div class="progress-legend"> <div class="card-header-right">
<span class="legend-item"><span class="legend-dot completed"></span>已完成</span> <el-button size="small" @click="loadOverview" class="refresh-btn">
<span class="legend-item"><span class="legend-dot active"></span>进行中</span> <template #icon>
<span class="legend-item"><span class="legend-dot pending"></span>未开始</span> <el-icon><Refresh /></el-icon>
<span class="legend-item"><span class="legend-dot blocked"></span>阻塞/延期</span> </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>
</div> </div>
<StateLoading v-if="loading" :rows="6" /> <StateLoading v-if="loading" :rows="6" />
@@ -84,11 +74,10 @@
</template> </template>
<script setup lang="ts"> <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 { Refresh } from "@element-plus/icons-vue";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { displayDateTime } from "../../utils/display";
import { fetchProjectOverview } from "../../api/overview"; import { fetchProjectOverview } from "../../api/overview";
import { fetchSites } from "../../api/sites"; import { fetchSites } from "../../api/sites";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
@@ -101,8 +90,6 @@ const study = useStudyStore();
const loading = ref(false); const loading = ref(false);
const overview = ref<ProjectOverviewViewModel | null>(null); const overview = ref<ProjectOverviewViewModel | null>(null);
const chartMode = ref<"center" | "month">("center"); 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 || []); const centers = computed(() => overview.value?.centers || []);
@@ -202,16 +189,6 @@ const reset = () => {
onMounted(() => { onMounted(() => {
loadOverview(); loadOverview();
clockTimer = setInterval(() => {
liveClock.value = displayDateTime(new Date());
}, 1000);
});
onUnmounted(() => {
if (clockTimer) {
clearInterval(clockTimer);
clockTimer = null;
}
}); });
watch( watch(
@@ -235,58 +212,18 @@ watch(
} }
.overview-container { .overview-container {
padding: 24px 28px 32px; padding: 8px 10px 12px;
display: flex; display: flex;
flex-direction: column; 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; gap: 10px;
} }
.overview-top-icon { .card-header-right {
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 {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
} flex-wrap: wrap;
justify-content: flex-end;
.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;
} }
.refresh-btn { .refresh-btn {
@@ -296,8 +233,8 @@ watch(
.overview-card { .overview-card {
background: var(--ctms-bg-card); background: var(--ctms-bg-card);
border: 1px solid var(--ctms-border-color); border: 1px solid var(--ctms-border-color);
border-radius: var(--ctms-radius-lg); border-radius: 8px;
padding: 20px 24px; padding: 12px 14px;
transition: var(--ctms-transition); transition: var(--ctms-transition);
box-shadow: var(--ctms-shadow-sm); box-shadow: var(--ctms-shadow-sm);
} }
@@ -311,9 +248,9 @@ watch(
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 10px;
flex-wrap: wrap; flex-wrap: wrap;
margin-bottom: 16px; margin-bottom: 10px;
} }
.card-header-left { .card-header-left {
@@ -382,8 +319,17 @@ watch(
} }
.legend-dot.active { .legend-dot.active {
border-color: var(--ctms-primary); position: relative;
box-shadow: 0 0 0 2px rgba(63, 93, 117, 0.18); 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 { .legend-dot.pending {
@@ -402,12 +348,12 @@ watch(
} }
.overview-empty-panel { .overview-empty-panel {
min-height: 168px; min-height: 132px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 32px 24px; padding: 20px 16px;
border-radius: 12px; border-radius: 8px;
background: linear-gradient(180deg, #f8fafc, #f1f5f9); background: linear-gradient(180deg, #f8fafc, #f1f5f9);
} }
@@ -445,11 +391,11 @@ watch(
@media (max-width: 768px) { @media (max-width: 768px) {
.overview-container { .overview-container {
padding: 16px; padding: 8px;
} }
.overview-card { .overview-card {
padding: 16px; padding: 10px;
} }
.card-header { .card-header {
@@ -457,10 +403,8 @@ watch(
align-items: flex-start; align-items: flex-start;
} }
.overview-top-bar { .card-header-right {
flex-direction: column; justify-content: flex-start;
align-items: flex-start;
gap: 12px;
} }
} }
</style> </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 { .center-row {
display: grid; display: grid;
grid-template-columns: 180px 1fr; grid-template-columns: 180px 1fr;
gap: 16px; gap: 12px;
padding: 14px 16px; padding: 8px 10px;
border-radius: 10px; border-radius: 8px;
margin: 2px 0; margin: 0;
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
} }
@@ -100,7 +100,7 @@ const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase(
align-items: flex-start; align-items: flex-start;
gap: 8px; gap: 8px;
overflow-x: auto; overflow-x: auto;
padding-bottom: 4px; padding: 6px 0 2px;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
} }
@@ -125,7 +125,7 @@ const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase(
height: 2px; height: 2px;
background-color: var(--ctms-border-color); background-color: var(--ctms-border-color);
border-radius: 999px; border-radius: 999px;
margin-top: 6px; margin-top: 8px;
} }
.stage-connector::after { .stage-connector::after {
@@ -145,7 +145,9 @@ const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase(
} }
.connector-in_progress { .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 { .connector-blocked {
@@ -158,8 +160,10 @@ const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase(
} }
.connector-in_progress::after { .connector-in_progress::after {
border-top-color: var(--ctms-primary); width: 7px;
border-right-color: var(--ctms-primary); height: 7px;
border-top-color: #2f5f86;
border-right-color: #2f5f86;
} }
.connector-blocked::after { .connector-blocked::after {

Some files were not shown because too many files have changed in this diff Show More