完善后台管理与测试支撑

1、为后台用户列表增加关键词和状态筛选,后端同步支持过滤与总数统计。

2、创建用户时要求填写初始密码,并补充前端校验和自动填充隔离。

3、优化项目管理列表角色展示、项目详情抽屉脏数据保护和审计详情抽屉关闭体验。

4、清理旧费用附件关联、统一测试模型注册,并更新迁移端点和注册筛选测试。
This commit is contained in:
Cheng Zhou
2026-06-04 11:12:40 +08:00
parent 90ca061db8
commit 41968384b5
17 changed files with 337 additions and 145 deletions
+7 -5
View File
@@ -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)
-20
View File
@@ -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()
+37 -3
View File
@@ -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,
+2 -1
View File
@@ -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
+3 -16
View File
@@ -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
+58
View File
@@ -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
+4 -3
View File
@@ -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 }) =>
+1 -1
View File
@@ -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"
> >
+19 -6
View File
@@ -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;
+22
View File
@@ -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();
+33 -7
View File
@@ -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;
+45
View File
@@ -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;
+26
View File
@@ -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();
+9 -3
View File
@@ -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;
+38 -28
View File
@@ -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>
+24 -10
View File
@@ -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 {