完善后台管理与测试支撑
1、为后台用户列表增加关键词和状态筛选,后端同步支持过滤与总数统计。 2、创建用户时要求填写初始密码,并补充前端校验和自动填充隔离。 3、优化项目管理列表角色展示、项目详情抽屉脏数据保护和审计详情抽屉关闭体验。 4、清理旧费用附件关联、统一测试模型注册,并更新迁移端点和注册筛选测试。
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import get_db_session, is_system_admin, require_roles
|
from app.core.deps import get_db_session, is_system_admin, require_roles
|
||||||
@@ -8,7 +8,7 @@ from app.schemas.common import PaginatedResponse
|
|||||||
from app.crud import user as user_crud
|
from app.crud import user as user_crud
|
||||||
from app.crud import member as member_crud
|
from app.crud import member as member_crud
|
||||||
from app.utils.pagination import paginate
|
from app.utils.pagination import paginate
|
||||||
from app.schemas.user import UserCreate, UserRead, UserUpdate
|
from app.schemas.user import UserCreate, UserRead, UserStatus, UserUpdate
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -17,12 +17,14 @@ router = APIRouter()
|
|||||||
async def list_users(
|
async def list_users(
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
|
keyword: str | None = Query(default=None),
|
||||||
|
user_status: UserStatus | None = Query(default=None, alias="status"),
|
||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
current_user=Depends(require_roles(["ADMIN"])),
|
current_user=Depends(require_roles(["ADMIN"])),
|
||||||
) -> PaginatedResponse[UserRead]:
|
) -> PaginatedResponse[UserRead]:
|
||||||
users = await user_crud.list_users(db, skip=skip, limit=limit)
|
users = await user_crud.list_users(db, skip=skip, limit=limit, keyword=keyword, status=user_status)
|
||||||
total_users = await user_crud.list_users(db, skip=0, limit=10_000_000)
|
total_users = await user_crud.count_users(db, keyword=keyword, status=user_status)
|
||||||
return paginate(list(users), total=len(total_users))
|
return paginate(list(users), total=total_users)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=UserRead, status_code=status.HTTP_201_CREATED)
|
@router.post("/", response_model=UserRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ from app.models.document import Document
|
|||||||
from app.models.document_version import DocumentVersion
|
from app.models.document_version import DocumentVersion
|
||||||
from app.models.distribution import Distribution
|
from app.models.distribution import Distribution
|
||||||
from app.models.drug_shipment import DrugShipment
|
from app.models.drug_shipment import DrugShipment
|
||||||
from app.models.fee_attachment import FeeAttachment
|
|
||||||
from app.models.finance import FinanceItem
|
from app.models.finance import FinanceItem
|
||||||
from app.models.kickoff_meeting import KickoffMeeting
|
from app.models.kickoff_meeting import KickoffMeeting
|
||||||
from app.models.precaution import Precaution
|
from app.models.precaution import Precaution
|
||||||
@@ -33,7 +32,6 @@ from app.models.visit import Visit
|
|||||||
from app.schemas.site import SiteCreate, SiteUpdate
|
from app.schemas.site import SiteCreate, SiteUpdate
|
||||||
|
|
||||||
ATTACHMENT_ROOT = Path(__file__).resolve().parent.parent / "uploads"
|
ATTACHMENT_ROOT = Path(__file__).resolve().parent.parent / "uploads"
|
||||||
FEE_ATTACHMENT_ROOT = ATTACHMENT_ROOT / "fees"
|
|
||||||
DOCUMENT_ATTACHMENT_ROOT = ATTACHMENT_ROOT / "documents"
|
DOCUMENT_ATTACHMENT_ROOT = ATTACHMENT_ROOT / "documents"
|
||||||
|
|
||||||
|
|
||||||
@@ -239,24 +237,7 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
fee_attachment_paths: list[str] = []
|
|
||||||
if contract_fee_ids:
|
if contract_fee_ids:
|
||||||
fee_attachment_paths.extend(
|
|
||||||
(
|
|
||||||
await db.execute(
|
|
||||||
select(FeeAttachment.storage_key).where(
|
|
||||||
FeeAttachment.entity_type == "contract_fee",
|
|
||||||
FeeAttachment.entity_id.in_(contract_fee_ids),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
)
|
|
||||||
await db.execute(
|
|
||||||
delete(FeeAttachment).where(
|
|
||||||
FeeAttachment.entity_type == "contract_fee",
|
|
||||||
FeeAttachment.entity_id.in_(contract_fee_ids),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await db.execute(delete(ContractFeePayment).where(ContractFeePayment.contract_fee_id.in_(contract_fee_ids)))
|
await db.execute(delete(ContractFeePayment).where(ContractFeePayment.contract_fee_id.in_(contract_fee_ids)))
|
||||||
|
|
||||||
if distribution_ids:
|
if distribution_ids:
|
||||||
@@ -311,7 +292,6 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
|
|||||||
|
|
||||||
await db.execute(delete(Site).where(Site.id == site_id))
|
await db.execute(delete(Site).where(Site.id == site_id))
|
||||||
await _remove_files(attachment_paths, ATTACHMENT_ROOT)
|
await _remove_files(attachment_paths, ATTACHMENT_ROOT)
|
||||||
await _remove_files(fee_attachment_paths, FEE_ATTACHMENT_ROOT)
|
|
||||||
await _remove_files(version_file_paths, DOCUMENT_ATTACHMENT_ROOT)
|
await _remove_files(version_file_paths, DOCUMENT_ATTACHMENT_ROOT)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
|
|
||||||
from sqlalchemy import delete, func, select, update
|
from sqlalchemy import delete, func, or_, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.config import (
|
from app.core.config import (
|
||||||
@@ -79,11 +79,45 @@ async def update_user(db: AsyncSession, user: User, user_in: UserUpdate) -> User
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def list_users(db: AsyncSession, skip: int = 0, limit: int = 100) -> Sequence[User]:
|
def _apply_user_filters(query, *, keyword: str | None = None, status: UserStatus | None = None):
|
||||||
result = await db.execute(select(User).offset(skip).limit(limit))
|
if keyword:
|
||||||
|
pattern = f"%{keyword.strip()}%"
|
||||||
|
query = query.where(
|
||||||
|
or_(
|
||||||
|
User.email.ilike(pattern),
|
||||||
|
User.full_name.ilike(pattern),
|
||||||
|
User.clinical_department.ilike(pattern),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if status is not None:
|
||||||
|
query = query.where(User.status == status)
|
||||||
|
return query
|
||||||
|
|
||||||
|
|
||||||
|
async def list_users(
|
||||||
|
db: AsyncSession,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
*,
|
||||||
|
keyword: str | None = None,
|
||||||
|
status: UserStatus | None = None,
|
||||||
|
) -> Sequence[User]:
|
||||||
|
query = _apply_user_filters(select(User), keyword=keyword, status=status)
|
||||||
|
result = await db.execute(query.order_by(User.created_at.desc()).offset(skip).limit(limit))
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def count_users(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
keyword: str | None = None,
|
||||||
|
status: UserStatus | None = None,
|
||||||
|
) -> int:
|
||||||
|
query = _apply_user_filters(select(func.count()).select_from(User), keyword=keyword, status=status)
|
||||||
|
result = await db.execute(query)
|
||||||
|
return int(result.scalar_one() or 0)
|
||||||
|
|
||||||
|
|
||||||
async def list_active_member_candidates_for_study(
|
async def list_active_member_candidates_for_study(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
@@ -141,7 +142,7 @@ class StudySetupConfigVersionRead(BaseModel):
|
|||||||
parent_version_id: uuid.UUID | None = None
|
parent_version_id: uuid.UUID | None = None
|
||||||
merged_from_version_id: uuid.UUID | None = None
|
merged_from_version_id: uuid.UUID | None = None
|
||||||
config: StudySetupConfigData
|
config: StudySetupConfigData
|
||||||
published_project_snapshot: "ProjectPublishSnapshot | None" = None
|
published_project_snapshot: Optional[ProjectPublishSnapshot] = None
|
||||||
is_current_published: bool = False
|
is_current_published: bool = False
|
||||||
is_active_draft_base: bool = False
|
is_active_draft_base: bool = False
|
||||||
published_by: uuid.UUID | None = None
|
published_by: uuid.UUID | None = None
|
||||||
|
|||||||
@@ -70,22 +70,9 @@ async def _create_test_engine():
|
|||||||
connect_args={"check_same_thread": False},
|
connect_args={"check_same_thread": False},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Import all models to register them with Base metadata
|
# Import the central model registry so Base metadata is complete regardless
|
||||||
from app.db.base_class import Base
|
# of test collection/import order.
|
||||||
import app.models.study
|
from app.db.base import Base
|
||||||
import app.models.api_endpoint_permission
|
|
||||||
import app.models.api_endpoint_registry
|
|
||||||
import app.models.user
|
|
||||||
import app.models.study_member
|
|
||||||
import app.models.subject
|
|
||||||
import app.models.visit
|
|
||||||
import app.models.ae
|
|
||||||
import app.models.monitoring_visit_issue
|
|
||||||
import app.models.site
|
|
||||||
import app.models.permission_template
|
|
||||||
import app.models.permission_access_log
|
|
||||||
import app.models.permission_metric_snapshot
|
|
||||||
import app.models.security_access_log
|
|
||||||
|
|
||||||
# Replace PostgreSQL UUID type with custom GUID type for SQLite
|
# Replace PostgreSQL UUID type with custom GUID type for SQLite
|
||||||
for table in Base.metadata.tables.values():
|
for table in Base.metadata.tables.values():
|
||||||
|
|||||||
@@ -101,41 +101,16 @@ async def test_startup_auth_read_with_permission(db_session: AsyncSession):
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# 项目权限管理 (project_permissions) - 2个端点
|
# 项目权限管理已迁移为系统级权限
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_project_permissions_are_not_project_matrix_permissions():
|
||||||
async def test_project_permissions_get_with_permission(db_session: AsyncSession):
|
"""项目权限配置由 system:permissions:project_config 控制,不再进入项目矩阵。"""
|
||||||
"""验证有权限的PM可以查询项目权限矩阵"""
|
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, SYSTEM_PERMISSIONS
|
||||||
study_id = uuid.uuid4()
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="PM",
|
|
||||||
endpoint_key="permissions:read",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "permissions:read")
|
assert "permissions:read" not in API_ENDPOINT_PERMISSIONS
|
||||||
assert allowed is True
|
assert "permissions:update" not in API_ENDPOINT_PERMISSIONS
|
||||||
|
assert "system:permissions:project_config" in SYSTEM_PERMISSIONS
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_project_permissions_update_with_permission(db_session: AsyncSession):
|
|
||||||
"""验证有权限的PM可以更新项目权限矩阵"""
|
|
||||||
study_id = uuid.uuid4()
|
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="PM",
|
|
||||||
endpoint_key="permissions:update",
|
|
||||||
allowed=True,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "permissions:update")
|
|
||||||
assert allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -506,17 +481,9 @@ async def test_startup_permission_denied_for_cra(db_session: AsyncSession):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_project_permissions_denied_for_cra(db_session: AsyncSession):
|
async def test_removed_project_permissions_are_denied_for_project_roles(db_session: AsyncSession):
|
||||||
"""验证CRA无法修改项目权限"""
|
"""项目角色不能再通过 permissions:update 这种残留 key 获得权限管理能力。"""
|
||||||
study_id = uuid.uuid4()
|
study_id = uuid.uuid4()
|
||||||
perm = ApiEndpointPermission(
|
|
||||||
study_id=study_id,
|
|
||||||
role="CRA",
|
|
||||||
endpoint_key="permissions:update",
|
|
||||||
allowed=False,
|
|
||||||
)
|
|
||||||
db_session.add(perm)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "permissions:update")
|
allowed = await role_has_api_permission(db_session, study_id, "CRA", "permissions:update")
|
||||||
assert allowed is False
|
assert allowed is False
|
||||||
|
|||||||
@@ -190,6 +190,64 @@ async def test_admin_can_approve_user(client_and_db):
|
|||||||
assert refreshed.approved_by is not None
|
assert refreshed.approved_by is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_users_list_filters_by_keyword_and_status(client_and_db):
|
||||||
|
client, SessionLocal = client_and_db
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
User(
|
||||||
|
email="active-filter@test.com",
|
||||||
|
password_hash=hash_password("Password123"),
|
||||||
|
full_name="Active Filter",
|
||||||
|
clinical_department="Oncology",
|
||||||
|
status=UserStatus.ACTIVE,
|
||||||
|
),
|
||||||
|
User(
|
||||||
|
email="pending-filter@test.com",
|
||||||
|
password_hash=hash_password("Password123"),
|
||||||
|
full_name="Pending Filter",
|
||||||
|
clinical_department="Cardiology",
|
||||||
|
status=UserStatus.PENDING,
|
||||||
|
),
|
||||||
|
User(
|
||||||
|
email="disabled-filter@test.com",
|
||||||
|
password_hash=hash_password("Password123"),
|
||||||
|
full_name="Disabled Filter",
|
||||||
|
clinical_department="Oncology",
|
||||||
|
status=UserStatus.DISABLED,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
admin_login = await encrypted_login(client, "admin@test.com", "admin123")
|
||||||
|
token = admin_login.json()["access_token"]
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
active_resp = await client.get("/api/v1/users/?status=ACTIVE", headers=headers)
|
||||||
|
assert active_resp.status_code == 200
|
||||||
|
active_items = active_resp.json()["items"]
|
||||||
|
assert all(item["status"] == "ACTIVE" for item in active_items)
|
||||||
|
assert {item["email"] for item in active_items} >= {"admin@test.com", "active-filter@test.com"}
|
||||||
|
assert "pending-filter@test.com" not in {item["email"] for item in active_items}
|
||||||
|
|
||||||
|
keyword_resp = await client.get("/api/v1/users/?keyword=Oncology", headers=headers)
|
||||||
|
assert keyword_resp.status_code == 200
|
||||||
|
keyword_data = keyword_resp.json()
|
||||||
|
assert keyword_data["total"] == 2
|
||||||
|
assert {item["email"] for item in keyword_data["items"]} == {
|
||||||
|
"active-filter@test.com",
|
||||||
|
"disabled-filter@test.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
combined_resp = await client.get("/api/v1/users/?keyword=Filter&status=PENDING", headers=headers)
|
||||||
|
assert combined_resp.status_code == 200
|
||||||
|
combined_data = combined_resp.json()
|
||||||
|
assert combined_data["total"] == 1
|
||||||
|
assert combined_data["items"][0]["email"] == "pending-filter@test.com"
|
||||||
|
|
||||||
|
|
||||||
def test_register_request_does_not_expose_role_input():
|
def test_register_request_does_not_expose_role_input():
|
||||||
assert "role" not in UserRegisterRequest.model_fields
|
assert "role" not in UserRegisterRequest.model_fields
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
|
import { apiDelete, apiGet, apiPatch, apiPost, type ApiRequestConfig } from "./axios";
|
||||||
import type { ApiListResponse, Site } from "../types/api";
|
import type { ApiListResponse, Site } from "../types/api";
|
||||||
|
|
||||||
export const fetchSites = (studyId: string, params?: Record<string, any>) =>
|
export const fetchSites = (studyId: string, params?: Record<string, any>, config?: ApiRequestConfig) =>
|
||||||
apiGet<ApiListResponse<Site> | Site[]>(`/api/v1/studies/${studyId}/sites/`, {
|
apiGet<ApiListResponse<Site> | Site[]>(`/api/v1/studies/${studyId}/sites/`, {
|
||||||
params: { include_inactive: true, ...(params || {}) },
|
...(config || {}),
|
||||||
|
params: { include_inactive: true, ...(params || {}), ...(config?.params || {}) },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createSite = (studyId: string, payload: Partial<Site> & { name: string }) =>
|
export const createSite = (studyId: string, payload: Partial<Site> & { name: string }) =>
|
||||||
|
|||||||
@@ -162,7 +162,7 @@
|
|||||||
v-model="detailVisible"
|
v-model="detailVisible"
|
||||||
direction="rtl"
|
direction="rtl"
|
||||||
size="580px"
|
size="580px"
|
||||||
:close-on-click-modal="false"
|
:close-on-click-modal="true"
|
||||||
:show-close="false"
|
:show-close="false"
|
||||||
class="audit-detail-drawer"
|
class="audit-detail-drawer"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1653,7 +1653,8 @@
|
|||||||
v-model="projectMilestoneEditorVisible"
|
v-model="projectMilestoneEditorVisible"
|
||||||
direction="rtl"
|
direction="rtl"
|
||||||
size="480px"
|
size="480px"
|
||||||
:close-on-click-modal="false"
|
:close-on-click-modal="true"
|
||||||
|
:before-close="projectMilestoneEditorDirtyGuard.beforeClose"
|
||||||
:show-close="false"
|
:show-close="false"
|
||||||
class="setup-milestone-editor-drawer"
|
class="setup-milestone-editor-drawer"
|
||||||
>
|
>
|
||||||
@@ -1756,7 +1757,8 @@
|
|||||||
v-model="siteMilestoneEditorVisible"
|
v-model="siteMilestoneEditorVisible"
|
||||||
direction="rtl"
|
direction="rtl"
|
||||||
size="480px"
|
size="480px"
|
||||||
:close-on-click-modal="false"
|
:close-on-click-modal="true"
|
||||||
|
:before-close="siteMilestoneEditorDirtyGuard.beforeClose"
|
||||||
:show-close="false"
|
:show-close="false"
|
||||||
class="setup-milestone-editor-drawer"
|
class="setup-milestone-editor-drawer"
|
||||||
>
|
>
|
||||||
@@ -1836,7 +1838,8 @@
|
|||||||
v-model="siteEnrollmentEditorVisible"
|
v-model="siteEnrollmentEditorVisible"
|
||||||
direction="rtl"
|
direction="rtl"
|
||||||
size="480px"
|
size="480px"
|
||||||
:close-on-click-modal="false"
|
:close-on-click-modal="true"
|
||||||
|
:before-close="siteEnrollmentEditorDirtyGuard.beforeClose"
|
||||||
:show-close="false"
|
:show-close="false"
|
||||||
class="setup-milestone-editor-drawer"
|
class="setup-milestone-editor-drawer"
|
||||||
>
|
>
|
||||||
@@ -1936,6 +1939,7 @@ import { isSystemAdmin } from "../../utils/roles";
|
|||||||
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||||||
import { groupErrorsBySection, parseFieldPath, type SetupValidationError } from "../../utils/setupFieldLocator";
|
import { groupErrorsBySection, parseFieldPath, type SetupValidationError } from "../../utils/setupFieldLocator";
|
||||||
import { buildSetupReadableDiffRows, serializeDiffValue, type SetupDiffRow } from "../../utils/setupDiffRows";
|
import { buildSetupReadableDiffRows, serializeDiffValue, type SetupDiffRow } from "../../utils/setupDiffRows";
|
||||||
|
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
|
||||||
import {
|
import {
|
||||||
SETUP_WORKFLOW_TAG_META,
|
SETUP_WORKFLOW_TAG_META,
|
||||||
buildProjectDiffRows,
|
buildProjectDiffRows,
|
||||||
@@ -2161,6 +2165,9 @@ const siteEnrollmentEditorForm = reactive<SiteEnrollmentPlanDraft>({
|
|||||||
note: "",
|
note: "",
|
||||||
stageBreakdown: "",
|
stageBreakdown: "",
|
||||||
});
|
});
|
||||||
|
const projectMilestoneEditorDirtyGuard = useDrawerDirtyGuard(() => projectMilestoneEditorForm);
|
||||||
|
const siteMilestoneEditorDirtyGuard = useDrawerDirtyGuard(() => siteMilestoneEditorForm);
|
||||||
|
const siteEnrollmentEditorDirtyGuard = useDrawerDirtyGuard(() => siteEnrollmentEditorForm);
|
||||||
|
|
||||||
|
|
||||||
type EnrollmentCycle = "month" | "quarter";
|
type EnrollmentCycle = "month" | "quarter";
|
||||||
@@ -4394,6 +4401,9 @@ const loadProject = async () => {
|
|||||||
const { data } = studyRes;
|
const { data } = studyRes;
|
||||||
project.value = data as Study;
|
project.value = data as Study;
|
||||||
projectPermissions.value = permRes.data as any;
|
projectPermissions.value = permRes.data as any;
|
||||||
|
studyStore.setViewContext({
|
||||||
|
pageTitle: project.value.name || TEXT.modules.adminProjects.detailTitle,
|
||||||
|
});
|
||||||
|
|
||||||
if (!canReadSetupConfig.value) {
|
if (!canReadSetupConfig.value) {
|
||||||
ElMessage.warning("当前角色无权访问立项配置");
|
ElMessage.warning("当前角色无权访问立项配置");
|
||||||
@@ -5694,7 +5704,7 @@ const removeProjectMilestone = (index: number) => {
|
|||||||
setupDraft.projectMilestones.splice(index, 1);
|
setupDraft.projectMilestones.splice(index, 1);
|
||||||
};
|
};
|
||||||
const openProjectMilestoneEditor = (index: number) => {
|
const openProjectMilestoneEditor = (index: number) => {
|
||||||
openIndexedEditor(setupDraft.projectMilestones, index, projectMilestoneEditor, (row) => {
|
const opened = openIndexedEditor(setupDraft.projectMilestones, index, projectMilestoneEditor, (row) => {
|
||||||
projectMilestoneEditorForm.id = row.id || "";
|
projectMilestoneEditorForm.id = row.id || "";
|
||||||
projectMilestoneEditorForm.name = row.name || "";
|
projectMilestoneEditorForm.name = row.name || "";
|
||||||
projectMilestoneEditorForm.startDate = resolveProjectMilestoneStart(row);
|
projectMilestoneEditorForm.startDate = resolveProjectMilestoneStart(row);
|
||||||
@@ -5705,6 +5715,7 @@ const openProjectMilestoneEditor = (index: number) => {
|
|||||||
projectMilestoneEditorForm.remark = row.remark || "";
|
projectMilestoneEditorForm.remark = row.remark || "";
|
||||||
projectMilestoneEditorForm.status = row.status || "未开始";
|
projectMilestoneEditorForm.status = row.status || "未开始";
|
||||||
});
|
});
|
||||||
|
if (opened) projectMilestoneEditorDirtyGuard.syncBaseline();
|
||||||
};
|
};
|
||||||
const saveProjectMilestoneEditor = () => {
|
const saveProjectMilestoneEditor = () => {
|
||||||
if (!canMutateDraft()) return;
|
if (!canMutateDraft()) return;
|
||||||
@@ -5784,7 +5795,7 @@ const removeSiteMilestone = (index: number) => {
|
|||||||
setupDraft.siteMilestones.splice(index, 1);
|
setupDraft.siteMilestones.splice(index, 1);
|
||||||
};
|
};
|
||||||
const openSiteMilestoneEditor = (index: number) => {
|
const openSiteMilestoneEditor = (index: number) => {
|
||||||
openIndexedEditor(setupDraft.siteMilestones, index, siteMilestoneEditor, (row) => {
|
const opened = openIndexedEditor(setupDraft.siteMilestones, index, siteMilestoneEditor, (row) => {
|
||||||
siteMilestoneEditorForm.id = row.id || "";
|
siteMilestoneEditorForm.id = row.id || "";
|
||||||
siteMilestoneEditorForm.milestone = row.milestone || "";
|
siteMilestoneEditorForm.milestone = row.milestone || "";
|
||||||
siteMilestoneEditorForm.planDate = row.planDate || "";
|
siteMilestoneEditorForm.planDate = row.planDate || "";
|
||||||
@@ -5792,6 +5803,7 @@ const openSiteMilestoneEditor = (index: number) => {
|
|||||||
siteMilestoneEditorForm.remark = row.remark || "";
|
siteMilestoneEditorForm.remark = row.remark || "";
|
||||||
siteMilestoneEditorForm.status = row.status || "未开始";
|
siteMilestoneEditorForm.status = row.status || "未开始";
|
||||||
});
|
});
|
||||||
|
if (opened) siteMilestoneEditorDirtyGuard.syncBaseline();
|
||||||
};
|
};
|
||||||
const saveSiteMilestoneEditor = () => {
|
const saveSiteMilestoneEditor = () => {
|
||||||
if (!canMutateDraft()) return;
|
if (!canMutateDraft()) return;
|
||||||
@@ -5832,7 +5844,7 @@ const removeSiteEnrollment = (index: number) => {
|
|||||||
setupDraft.siteEnrollmentPlans.splice(index, 1);
|
setupDraft.siteEnrollmentPlans.splice(index, 1);
|
||||||
};
|
};
|
||||||
const openSiteEnrollmentEditor = (index: number) => {
|
const openSiteEnrollmentEditor = (index: number) => {
|
||||||
openIndexedEditor(setupDraft.siteEnrollmentPlans, index, siteEnrollmentEditor, (row) => {
|
const opened = openIndexedEditor(setupDraft.siteEnrollmentPlans, index, siteEnrollmentEditor, (row) => {
|
||||||
siteEnrollmentEditorForm.id = row.id || "";
|
siteEnrollmentEditorForm.id = row.id || "";
|
||||||
siteEnrollmentEditorForm.siteId = row.siteId || "";
|
siteEnrollmentEditorForm.siteId = row.siteId || "";
|
||||||
siteEnrollmentEditorForm.siteName = row.siteName || "";
|
siteEnrollmentEditorForm.siteName = row.siteName || "";
|
||||||
@@ -5842,6 +5854,7 @@ const openSiteEnrollmentEditor = (index: number) => {
|
|||||||
siteEnrollmentEditorForm.note = row.note || "";
|
siteEnrollmentEditorForm.note = row.note || "";
|
||||||
siteEnrollmentEditorForm.stageBreakdown = row.stageBreakdown || "";
|
siteEnrollmentEditorForm.stageBreakdown = row.stageBreakdown || "";
|
||||||
});
|
});
|
||||||
|
if (opened) siteEnrollmentEditorDirtyGuard.syncBaseline();
|
||||||
};
|
};
|
||||||
const saveSiteEnrollmentEditor = () => {
|
const saveSiteEnrollmentEditor = () => {
|
||||||
if (!canMutateDraft()) return;
|
if (!canMutateDraft()) return;
|
||||||
|
|||||||
@@ -52,6 +52,28 @@ describe("project management access", () => {
|
|||||||
expect(source).toContain("canProject(scope.row, 'audit_export', 'read')");
|
expect(source).toContain("canProject(scope.row, 'audit_export', 'read')");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows the current project role only for non-admin project lists", () => {
|
||||||
|
const source = readProjects();
|
||||||
|
|
||||||
|
expect(source).toContain('v-if="!isAdmin" label="我的角色" align="center"');
|
||||||
|
expect(source).toContain("roleLabel(scope.row.role_in_study) || '-'");
|
||||||
|
expect(source).toContain("const { roleLabel, loadRoleTemplates } = useRoleTemplateMeta();");
|
||||||
|
expect(source).toContain("loadRoleTemplates().catch(() => {});");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps project table columns evenly spaced after adding the role column", () => {
|
||||||
|
const source = readProjects();
|
||||||
|
|
||||||
|
expect(source).toContain('class="project-table" style="width: 100%" table-layout="fixed"');
|
||||||
|
expect(source).toContain(':label="TEXT.common.fields.projectName"');
|
||||||
|
expect(source).toContain(`:label="TEXT.common.fields.protocolNo || '方案号'" show-overflow-tooltip`);
|
||||||
|
expect(source).toContain(':label="TEXT.common.fields.status" align="center"');
|
||||||
|
expect(source).toContain('label="锁定状态" align="center"');
|
||||||
|
expect(source).toContain(':label="TEXT.common.labels.actions" align="center"');
|
||||||
|
expect(source).not.toContain('projectColumnWidth');
|
||||||
|
expect(source).not.toContain('fixed="right"');
|
||||||
|
});
|
||||||
|
|
||||||
it("opens project names in management detail when the user has setup config read permission", () => {
|
it("opens project names in management detail when the user has setup config read permission", () => {
|
||||||
const source = readProjects();
|
const source = readProjects();
|
||||||
|
|
||||||
|
|||||||
@@ -63,8 +63,8 @@
|
|||||||
|
|
||||||
<!-- 项目表格 -->
|
<!-- 项目表格 -->
|
||||||
<div class="unified-section project-table-section">
|
<div class="unified-section project-table-section">
|
||||||
<el-table :data="projects" v-loading="loading" class="project-table" table-layout="fixed">
|
<el-table :data="projects" v-loading="loading" class="project-table" style="width: 100%" table-layout="fixed">
|
||||||
<el-table-column :label="TEXT.common.fields.projectName" min-width="220">
|
<el-table-column :label="TEXT.common.fields.projectName">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<div class="project-cell">
|
<div class="project-cell">
|
||||||
<div class="project-icon" :class="'icon--' + (scope.row.status || 'draft').toLowerCase()">
|
<div class="project-icon" :class="'icon--' + (scope.row.status || 'draft').toLowerCase()">
|
||||||
@@ -86,7 +86,12 @@
|
|||||||
<span class="text-muted">{{ scope.row.protocol_no || '-' }}</span>
|
<span class="text-muted">{{ scope.row.protocol_no || '-' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.common.fields.status" width="110" align="center">
|
<el-table-column v-if="!isAdmin" label="我的角色" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<span class="role-badge">{{ roleLabel(scope.row.role_in_study) || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="TEXT.common.fields.status" align="center">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<span class="status-badge" :class="'badge--' + (scope.row.status || 'draft').toLowerCase()">
|
<span class="status-badge" :class="'badge--' + (scope.row.status || 'draft').toLowerCase()">
|
||||||
<span class="badge-dot"></span>
|
<span class="badge-dot"></span>
|
||||||
@@ -94,7 +99,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="锁定状态" width="100" align="center">
|
<el-table-column label="锁定状态" align="center">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<span v-if="scope.row.is_locked" class="lock-indicator locked">
|
<span v-if="scope.row.is_locked" class="lock-indicator locked">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13">
|
||||||
@@ -112,7 +117,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="TEXT.common.labels.actions" width="200" align="center" fixed="right">
|
<el-table-column :label="TEXT.common.labels.actions" align="center">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<div class="action-row">
|
<div class="action-row">
|
||||||
<el-tooltip v-if="canProject(scope.row, 'project_members', 'read')" :content="TEXT.modules.adminProjects.members" placement="top">
|
<el-tooltip v-if="canProject(scope.row, 'project_members', 'read')" :content="TEXT.modules.adminProjects.members" placement="top">
|
||||||
@@ -159,6 +164,7 @@ import { useStudyStore } from "../../store/study";
|
|||||||
import { useAuthStore } from "../../store/auth";
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { isSystemAdmin } from "../../utils/roles";
|
import { isSystemAdmin } from "../../utils/roles";
|
||||||
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||||||
|
import { useRoleTemplateMeta } from "../../composables/useRoleTemplateMeta";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
|
|
||||||
const projects = ref<Study[]>([]);
|
const projects = ref<Study[]>([]);
|
||||||
@@ -170,11 +176,11 @@ const studyStore = useStudyStore();
|
|||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const isAdmin = computed(() => isSystemAdmin(auth.user));
|
const isAdmin = computed(() => isSystemAdmin(auth.user));
|
||||||
const permissionsByProject = ref<Record<string, ApiEndpointPermissionsResponse>>({});
|
const permissionsByProject = ref<Record<string, ApiEndpointPermissionsResponse>>({});
|
||||||
|
const { roleLabel, loadRoleTemplates } = useRoleTemplateMeta();
|
||||||
|
|
||||||
const activeProjectCount = computed(() => projects.value.filter(p => p.status === 'ACTIVE').length);
|
const activeProjectCount = computed(() => projects.value.filter(p => p.status === 'ACTIVE').length);
|
||||||
const draftProjectCount = computed(() => projects.value.filter(p => p.status === 'DRAFT').length);
|
const draftProjectCount = computed(() => projects.value.filter(p => p.status === 'DRAFT').length);
|
||||||
const lockedProjectCount = computed(() => projects.value.filter(p => p.is_locked).length);
|
const lockedProjectCount = computed(() => projects.value.filter(p => p.is_locked).length);
|
||||||
|
|
||||||
const loadProjects = async () => {
|
const loadProjects = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -208,7 +214,7 @@ const openCreate = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MODULE_TO_OPERATION: Record<string, Record<string, string>> = {
|
const MODULE_TO_OPERATION: Record<string, Record<string, string>> = {
|
||||||
project_members: { read: "project_members:list", write: "project_members:update" },
|
project_members: { read: "project_members:read", write: "project_members:update" },
|
||||||
sites: { read: "sites:read", write: "sites:update" },
|
sites: { read: "sites:read", write: "sites:update" },
|
||||||
audit_export: { read: "audit_logs:read", write: "audit_logs:export" },
|
audit_export: { read: "audit_logs:read", write: "audit_logs:export" },
|
||||||
setup_config: { read: "setup_config:read", write: "setup_config:update" },
|
setup_config: { read: "setup_config:read", write: "setup_config:update" },
|
||||||
@@ -346,6 +352,7 @@ const statusLabel = (status: string) =>
|
|||||||
TEXT.enums.projectStatus[status as keyof typeof TEXT.enums.projectStatus] || status;
|
TEXT.enums.projectStatus[status as keyof typeof TEXT.enums.projectStatus] || status;
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
loadRoleTemplates().catch(() => {});
|
||||||
loadProjects();
|
loadProjects();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -471,6 +478,25 @@ onMounted(() => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 64px;
|
||||||
|
max-width: 104px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--ctms-neutral-100);
|
||||||
|
color: var(--ctms-text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* 状态徽章 */
|
/* 状态徽章 */
|
||||||
.status-badge {
|
.status-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -53,6 +53,24 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item v-if="!user" :label="TEXT.modules.adminUsers.passwordInitial" prop="password">
|
||||||
|
<el-input
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
autocomplete="new-password"
|
||||||
|
name="ctms-create-initial-password"
|
||||||
|
:placeholder="TEXT.modules.adminUsers.passwordInitialHint"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="opacity:0.5">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<div class="password-hint">{{ TEXT.modules.adminUsers.passwordHint }}</div>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item v-if="user" :label="TEXT.modules.adminUsers.accountStatus" prop="is_active">
|
<el-form-item v-if="user" :label="TEXT.modules.adminUsers.accountStatus" prop="is_active">
|
||||||
<div class="status-switch-row">
|
<div class="status-switch-row">
|
||||||
<el-switch
|
<el-switch
|
||||||
@@ -125,6 +143,26 @@ const rules = reactive<FormRules>({
|
|||||||
],
|
],
|
||||||
full_name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), trigger: "blur" }],
|
full_name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), trigger: "blur" }],
|
||||||
clinical_department: [{ required: true, message: requiredMessage(TEXT.common.fields.clinicalDepartment), trigger: "blur" }],
|
clinical_department: [{ required: true, message: requiredMessage(TEXT.common.fields.clinicalDepartment), trigger: "blur" }],
|
||||||
|
password: [
|
||||||
|
{
|
||||||
|
validator: (_rule, value, callback) => {
|
||||||
|
if (props.user) {
|
||||||
|
callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!value) {
|
||||||
|
callback(new Error(TEXT.modules.adminUsers.passwordInitialRequired));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^(?=.*[A-Za-z])(?=.*\d).{8,}$/.test(value)) {
|
||||||
|
callback(new Error(TEXT.modules.profile.passwordRule));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
trigger: "blur",
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
@@ -264,6 +302,13 @@ const onSubmit = async () => {
|
|||||||
color: var(--ctms-warning);
|
color: var(--ctms-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.password-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--ctms-text-secondary);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.dialog-footer {
|
.dialog-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|||||||
@@ -17,11 +17,27 @@ describe("Admin users table labels", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Admin users filters", () => {
|
||||||
|
it("applies keyword and status filters from the first page", () => {
|
||||||
|
const viewSource = readUsersView();
|
||||||
|
|
||||||
|
expect(viewSource).toContain("@keyup.enter=\"applyFilters\"");
|
||||||
|
expect(viewSource).toContain("@clear=\"applyFilters\"");
|
||||||
|
expect(viewSource).toContain("@change=\"applyFilters\"");
|
||||||
|
expect(viewSource).toContain("@click=\"applyFilters\"");
|
||||||
|
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", () => {
|
describe("Admin user form autocomplete", () => {
|
||||||
it("prevents browser credential autofill when creating a user", () => {
|
it("prevents browser credential autofill when creating a user", () => {
|
||||||
const formSource = readUserForm();
|
const formSource = readUserForm();
|
||||||
|
|
||||||
expect(formSource).toContain('autocomplete="off"');
|
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("current_password");
|
||||||
expect(formSource).not.toContain("confirmPassword");
|
expect(formSource).not.toContain("confirmPassword");
|
||||||
expect(formSource).not.toContain("TEXT.modules.profile.currentPassword");
|
expect(formSource).not.toContain("TEXT.modules.profile.currentPassword");
|
||||||
@@ -29,6 +45,16 @@ describe("Admin user form autocomplete", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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", () => {
|
describe("Admin user edit form defaults", () => {
|
||||||
it("hydrates the selected user immediately when the dialog is mounted open", () => {
|
it("hydrates the selected user immediately when the dialog is mounted open", () => {
|
||||||
const formSource = readUserForm();
|
const formSource = readUserForm();
|
||||||
|
|||||||
@@ -65,11 +65,12 @@
|
|||||||
clearable
|
clearable
|
||||||
class="filter-input-comp"
|
class="filter-input-comp"
|
||||||
:prefix-icon="Search"
|
:prefix-icon="Search"
|
||||||
@keyup.enter="loadUsers"
|
@clear="applyFilters"
|
||||||
|
@keyup.enter="applyFilters"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-item-form">
|
<div class="filter-item-form">
|
||||||
<el-select v-model="statusFilter" :placeholder="TEXT.common.fields.status" style="width: 140px" clearable class="filter-select-comp" @change="loadUsers">
|
<el-select v-model="statusFilter" :placeholder="TEXT.common.fields.status" style="width: 140px" clearable class="filter-select-comp" @change="applyFilters">
|
||||||
<el-option :label="TEXT.enums.userStatus.PENDING" value="PENDING" />
|
<el-option :label="TEXT.enums.userStatus.PENDING" value="PENDING" />
|
||||||
<el-option :label="TEXT.enums.userStatus.ACTIVE" value="ACTIVE" />
|
<el-option :label="TEXT.enums.userStatus.ACTIVE" value="ACTIVE" />
|
||||||
<el-option :label="TEXT.enums.userStatus.REJECTED" value="REJECTED" />
|
<el-option :label="TEXT.enums.userStatus.REJECTED" value="REJECTED" />
|
||||||
@@ -77,7 +78,7 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-item-form">
|
<div class="filter-item-form">
|
||||||
<el-button @click="loadUsers" :icon="Refresh">{{ TEXT.common.actions.search }}</el-button>
|
<el-button @click="applyFilters" :icon="Refresh">{{ TEXT.common.actions.search }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-spacer"></div>
|
<div class="filter-spacer"></div>
|
||||||
<el-button type="primary" :icon="Plus" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }}</el-button>
|
<el-button type="primary" :icon="Plus" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }}</el-button>
|
||||||
@@ -249,6 +250,11 @@ const loadUsers = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyFilters = () => {
|
||||||
|
page.value = 1;
|
||||||
|
loadUsers();
|
||||||
|
};
|
||||||
|
|
||||||
const onPageSizeChange = (size: number) => {
|
const onPageSizeChange = (size: number) => {
|
||||||
pageSize.value = size;
|
pageSize.value = size;
|
||||||
page.value = 1;
|
page.value = 1;
|
||||||
|
|||||||
@@ -34,18 +34,18 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
</section>
|
</section>
|
||||||
<section class="unified-section">
|
<section class="unified-section">
|
||||||
<div v-if="isEdit && meetingId" class="attachment-grid">
|
<div class="ctms-section-header">
|
||||||
<AttachmentList
|
<div class="ctms-section-title">{{ TEXT.common.labels.attachments }}</div>
|
||||||
v-for="group in kickoffAttachmentGroups"
|
|
||||||
:key="group.entityType"
|
|
||||||
:study-id="studyId"
|
|
||||||
:entity-type="group.entityType"
|
|
||||||
:entity-id="meetingId"
|
|
||||||
:title="group.title"
|
|
||||||
:readonly="isReadOnly"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<StateEmpty v-else :description="TEXT.modules.startupMeetingAuth.kickoffUploadHint" />
|
<AttachmentList
|
||||||
|
ref="attachmentPanelRef"
|
||||||
|
:study-id="studyId"
|
||||||
|
entity-type="startup_kickoff"
|
||||||
|
:entity-id="meetingId || ''"
|
||||||
|
:entity-groups="kickoffAttachmentGroups"
|
||||||
|
:readonly="isReadOnly"
|
||||||
|
:mode="'upload'"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,14 +62,16 @@ import { fetchSites } from "../../api/sites";
|
|||||||
import { listMembers } from "../../api/members";
|
import { listMembers } from "../../api/members";
|
||||||
import { fetchUsers } from "../../api/users";
|
import { fetchUsers } from "../../api/users";
|
||||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
|
import { usePermission } from "../../utils/permission";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
|
const { can } = usePermission();
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
|
||||||
|
|
||||||
const meetingId = computed(() => route.params.meetingId as string | undefined);
|
const meetingId = computed(() => route.params.meetingId as string | undefined);
|
||||||
const isEdit = computed(() => !!meetingId.value);
|
const isEdit = computed(() => !!meetingId.value);
|
||||||
@@ -79,6 +81,10 @@ const siteId = ref("");
|
|||||||
const users = ref<any[]>([]);
|
const users = ref<any[]>([]);
|
||||||
const members = ref<any[]>([]);
|
const members = ref<any[]>([]);
|
||||||
const isAdmin = computed(() => auth.user?.is_admin);
|
const isAdmin = computed(() => auth.user?.is_admin);
|
||||||
|
const canReadMembers = computed(() => can("project.members.list"));
|
||||||
|
const canCreateAuth = computed(() => can("startup.auth.create"));
|
||||||
|
const canUpdateAuth = computed(() => can("startup.auth.update"));
|
||||||
|
const canSaveAuth = computed(() => (isEdit.value ? canUpdateAuth.value : canCreateAuth.value));
|
||||||
|
|
||||||
const memberNameMap = computed(() => {
|
const memberNameMap = computed(() => {
|
||||||
const map: Record<string, string> = {};
|
const map: Record<string, string> = {};
|
||||||
@@ -103,12 +109,13 @@ const ownerLabel = computed(() => {
|
|||||||
.map((c) => memberNameMap.value[c] || c)
|
.map((c) => memberNameMap.value[c] || c)
|
||||||
.join("、") || TEXT.common.fallback;
|
.join("、") || TEXT.common.fallback;
|
||||||
});
|
});
|
||||||
const isReadOnly = computed(() => siteInfo.is_active === false);
|
const isInactiveSite = computed(() => siteInfo.is_active === false);
|
||||||
|
const isReadOnly = computed(() => !canSaveAuth.value || isInactiveSite.value);
|
||||||
const kickoffAttachmentGroups = [
|
const kickoffAttachmentGroups = [
|
||||||
{ entityType: "startup_kickoff_minutes", title: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel },
|
{ entityType: "startup_kickoff_minutes", label: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel },
|
||||||
{ entityType: "startup_kickoff_signin", title: TEXT.modules.startupMeetingAuth.kickoffSignInLabel },
|
{ entityType: "startup_kickoff_signin", label: TEXT.modules.startupMeetingAuth.kickoffSignInLabel },
|
||||||
{ entityType: "startup_kickoff_ppt", title: TEXT.modules.startupMeetingAuth.kickoffPptLabel },
|
{ entityType: "startup_kickoff_ppt", label: TEXT.modules.startupMeetingAuth.kickoffPptLabel },
|
||||||
{ entityType: "startup_kickoff", title: TEXT.modules.startupMeetingAuth.kickoffOtherLabel },
|
{ entityType: "startup_kickoff", label: TEXT.modules.startupMeetingAuth.kickoffOtherLabel },
|
||||||
];
|
];
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
@@ -149,7 +156,11 @@ const load = async () => {
|
|||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!studyId.value) return;
|
if (!studyId.value) return;
|
||||||
if (isReadOnly.value) {
|
if (!canSaveAuth.value) {
|
||||||
|
ElMessage.warning("权限不足");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isInactiveSite.value) {
|
||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -159,15 +170,16 @@ const submit = async () => {
|
|||||||
kickoff_date: form.kickoff_date || null,
|
kickoff_date: form.kickoff_date || null,
|
||||||
attendees: parseAttendees(form.attendees),
|
attendees: parseAttendees(form.attendees),
|
||||||
};
|
};
|
||||||
|
let savedId = meetingId.value || "";
|
||||||
if (isEdit.value && meetingId.value) {
|
if (isEdit.value && meetingId.value) {
|
||||||
await updateKickoff(studyId.value, meetingId.value, payload);
|
await updateKickoff(studyId.value, meetingId.value, payload);
|
||||||
ElMessage.success(TEXT.common.messages.saveSuccess);
|
|
||||||
router.push(`/startup/kickoff/${meetingId.value}`);
|
|
||||||
} else {
|
} else {
|
||||||
const { data } = await createKickoff(studyId.value, { ...payload, site_id: siteId.value });
|
const { data } = await createKickoff(studyId.value, { ...payload, site_id: siteId.value });
|
||||||
ElMessage.success(TEXT.common.messages.createSuccess);
|
savedId = data.id;
|
||||||
router.push(`/startup/kickoff/${data.id}`);
|
|
||||||
}
|
}
|
||||||
|
await attachmentPanelRef.value?.uploadPending(savedId);
|
||||||
|
ElMessage.success(isEdit.value ? TEXT.common.messages.saveSuccess : TEXT.common.messages.createSuccess);
|
||||||
|
router.push(`/startup/kickoff/${savedId}`);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -179,8 +191,11 @@ const goBack = () => router.push("/startup/meeting-auth");
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (studyId.value) {
|
if (studyId.value) {
|
||||||
const membersReq = listMembers(studyId.value, { limit: 500 });
|
|
||||||
const usersReq = isAdmin.value ? fetchUsers({ limit: 500 }) : Promise.resolve(null);
|
const usersReq = isAdmin.value ? fetchUsers({ limit: 500 }) : Promise.resolve(null);
|
||||||
|
let membersReq: Promise<any> = Promise.resolve(null);
|
||||||
|
if (canReadMembers.value) {
|
||||||
|
membersReq = listMembers(studyId.value, { limit: 500 });
|
||||||
|
}
|
||||||
const [membersResp, usersResp] = await Promise.all([membersReq, usersReq]).catch(() => [null, null] as const);
|
const [membersResp, usersResp] = await Promise.all([membersReq, usersReq]).catch(() => [null, null] as const);
|
||||||
if (membersResp?.data) {
|
if (membersResp?.data) {
|
||||||
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
|
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
|
||||||
@@ -200,9 +215,4 @@ onMounted(async () => {
|
|||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachment-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -40,14 +40,17 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
</section>
|
</section>
|
||||||
<section class="unified-section">
|
<section class="unified-section">
|
||||||
|
<div class="ctms-section-header">
|
||||||
|
<div class="ctms-section-title">{{ TEXT.common.labels.attachments }}</div>
|
||||||
|
</div>
|
||||||
<AttachmentList
|
<AttachmentList
|
||||||
v-if="isEdit && recordId"
|
ref="attachmentPanelRef"
|
||||||
:study-id="studyId"
|
:study-id="studyId"
|
||||||
entity-type="training_authorization"
|
entity-type="training_authorization"
|
||||||
:entity-id="recordId"
|
:entity-id="recordId || ''"
|
||||||
:readonly="isReadOnly"
|
:readonly="isReadOnly"
|
||||||
|
:mode="'upload'"
|
||||||
/>
|
/>
|
||||||
<StateEmpty v-else :description="TEXT.modules.startupMeetingAuth.trainingUploadHint" />
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,13 +64,15 @@ import { useStudyStore } from "../../store/study";
|
|||||||
import { createTrainingAuthorization, getTrainingAuthorization, updateTrainingAuthorization } from "../../api/startup";
|
import { createTrainingAuthorization, getTrainingAuthorization, updateTrainingAuthorization } from "../../api/startup";
|
||||||
import { fetchSites } from "../../api/sites";
|
import { fetchSites } from "../../api/sites";
|
||||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
|
import { usePermission } from "../../utils/permission";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
|
const { can } = usePermission();
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
|
||||||
const sites = ref<any[]>([]);
|
const sites = ref<any[]>([]);
|
||||||
|
|
||||||
const recordId = computed(() => route.params.recordId as string | undefined);
|
const recordId = computed(() => route.params.recordId as string | undefined);
|
||||||
@@ -92,7 +97,11 @@ const siteActiveMap = computed(() => {
|
|||||||
});
|
});
|
||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
const isReadOnly = computed(() => isEdit.value && !!form.site_name && siteActiveMap.value[form.site_name] === false);
|
const canCreateAuth = computed(() => can("startup.auth.create"));
|
||||||
|
const canUpdateAuth = computed(() => can("startup.auth.update"));
|
||||||
|
const canSaveAuth = computed(() => (isEdit.value ? canUpdateAuth.value : canCreateAuth.value));
|
||||||
|
const isInactiveSite = computed(() => isEdit.value && !!form.site_name && siteActiveMap.value[form.site_name] === false);
|
||||||
|
const isReadOnly = computed(() => !canSaveAuth.value || isInactiveSite.value);
|
||||||
|
|
||||||
const loadSites = async () => {
|
const loadSites = async () => {
|
||||||
if (!studyId.value) return;
|
if (!studyId.value) return;
|
||||||
@@ -125,7 +134,11 @@ const load = async () => {
|
|||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!studyId.value) return;
|
if (!studyId.value) return;
|
||||||
if (isReadOnly.value) {
|
if (!canSaveAuth.value) {
|
||||||
|
ElMessage.warning("权限不足");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isInactiveSite.value) {
|
||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -145,15 +158,16 @@ const submit = async () => {
|
|||||||
authorized_date: form.authorized_date || null,
|
authorized_date: form.authorized_date || null,
|
||||||
remark: form.remark || null,
|
remark: form.remark || null,
|
||||||
};
|
};
|
||||||
|
let savedId = recordId.value || "";
|
||||||
if (isEdit.value && recordId.value) {
|
if (isEdit.value && recordId.value) {
|
||||||
await updateTrainingAuthorization(studyId.value, recordId.value, payload);
|
await updateTrainingAuthorization(studyId.value, recordId.value, payload);
|
||||||
ElMessage.success(TEXT.common.messages.saveSuccess);
|
|
||||||
router.push(`/startup/training/${recordId.value}`);
|
|
||||||
} else {
|
} else {
|
||||||
const { data } = await createTrainingAuthorization(studyId.value, payload);
|
const { data } = await createTrainingAuthorization(studyId.value, payload);
|
||||||
ElMessage.success(TEXT.common.messages.createSuccess);
|
savedId = data.id;
|
||||||
router.push(`/startup/training/${data.id}`);
|
|
||||||
}
|
}
|
||||||
|
await attachmentPanelRef.value?.uploadPending(savedId);
|
||||||
|
ElMessage.success(isEdit.value ? TEXT.common.messages.saveSuccess : TEXT.common.messages.createSuccess);
|
||||||
|
router.push(`/startup/training/${savedId}`);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user