diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index f486ddf9..99b16bed 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -1,6 +1,6 @@ import uuid -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession 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 member as member_crud 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() @@ -17,12 +17,14 @@ router = APIRouter() async def list_users( skip: int = 0, limit: int = 100, + keyword: str | None = Query(default=None), + user_status: UserStatus | None = Query(default=None, alias="status"), db: AsyncSession = Depends(get_db_session), current_user=Depends(require_roles(["ADMIN"])), ) -> PaginatedResponse[UserRead]: - users = await user_crud.list_users(db, skip=skip, limit=limit) - total_users = await user_crud.list_users(db, skip=0, limit=10_000_000) - return paginate(list(users), total=len(total_users)) + users = await user_crud.list_users(db, skip=skip, limit=limit, keyword=keyword, status=user_status) + total_users = await user_crud.count_users(db, keyword=keyword, status=user_status) + return paginate(list(users), total=total_users) @router.post("/", response_model=UserRead, status_code=status.HTTP_201_CREATED) diff --git a/backend/app/crud/site.py b/backend/app/crud/site.py index 29c16f40..330d0984 100644 --- a/backend/app/crud/site.py +++ b/backend/app/crud/site.py @@ -16,7 +16,6 @@ from app.models.document import Document from app.models.document_version import DocumentVersion from app.models.distribution import Distribution from app.models.drug_shipment import DrugShipment -from app.models.fee_attachment import FeeAttachment from app.models.finance import FinanceItem from app.models.kickoff_meeting import KickoffMeeting from app.models.precaution import Precaution @@ -33,7 +32,6 @@ from app.models.visit import Visit from app.schemas.site import SiteCreate, SiteUpdate ATTACHMENT_ROOT = Path(__file__).resolve().parent.parent / "uploads" -FEE_ATTACHMENT_ROOT = ATTACHMENT_ROOT / "fees" 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: - 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))) 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 _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 db.commit() diff --git a/backend/app/crud/user.py b/backend/app/crud/user.py index 8888af47..f3fec7d3 100644 --- a/backend/app/crud/user.py +++ b/backend/app/crud/user.py @@ -3,7 +3,7 @@ from __future__ import annotations import uuid 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 app.core.config import ( @@ -79,11 +79,45 @@ async def update_user(db: AsyncSession, user: User, user_in: UserUpdate) -> User return user -async def list_users(db: AsyncSession, skip: int = 0, limit: int = 100) -> Sequence[User]: - result = await db.execute(select(User).offset(skip).limit(limit)) +def _apply_user_filters(query, *, keyword: str | None = None, status: UserStatus | None = None): + 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() +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( db: AsyncSession, study_id: uuid.UUID, diff --git a/backend/app/schemas/study_setup_config.py b/backend/app/schemas/study_setup_config.py index 94d809d9..c727e2b5 100644 --- a/backend/app/schemas/study_setup_config.py +++ b/backend/app/schemas/study_setup_config.py @@ -1,5 +1,6 @@ import uuid from datetime import datetime +from typing import Optional from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -141,7 +142,7 @@ class StudySetupConfigVersionRead(BaseModel): parent_version_id: uuid.UUID | None = None merged_from_version_id: uuid.UUID | None = None config: StudySetupConfigData - published_project_snapshot: "ProjectPublishSnapshot | None" = None + published_project_snapshot: Optional[ProjectPublishSnapshot] = None is_current_published: bool = False is_active_draft_base: bool = False published_by: uuid.UUID | None = None diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index edb166ba..b12858ee 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -70,22 +70,9 @@ async def _create_test_engine(): connect_args={"check_same_thread": False}, ) - # Import all models to register them with Base metadata - from app.db.base_class import Base - import app.models.study - 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 + # Import the central model registry so Base metadata is complete regardless + # of test collection/import order. + from app.db.base import Base # Replace PostgreSQL UUID type with custom GUID type for SQLite for table in Base.metadata.tables.values(): diff --git a/backend/tests/test_migrated_endpoints_batch3.py b/backend/tests/test_migrated_endpoints_batch3.py index 25442977..ea9db9e2 100644 --- a/backend/tests/test_migrated_endpoints_batch3.py +++ b/backend/tests/test_migrated_endpoints_batch3.py @@ -101,41 +101,16 @@ async def test_startup_auth_read_with_permission(db_session: AsyncSession): # ============================================================================ -# 项目权限管理 (project_permissions) - 2个端点 +# 项目权限管理已迁移为系统级权限 # ============================================================================ -@pytest.mark.asyncio -async def test_project_permissions_get_with_permission(db_session: AsyncSession): - """验证有权限的PM可以查询项目权限矩阵""" - 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() +def test_project_permissions_are_not_project_matrix_permissions(): + """项目权限配置由 system:permissions:project_config 控制,不再进入项目矩阵。""" + from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, SYSTEM_PERMISSIONS - allowed = await role_has_api_permission(db_session, study_id, "PM", "permissions:read") - assert allowed is True - - -@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 + assert "permissions:read" not in API_ENDPOINT_PERMISSIONS + assert "permissions:update" not in API_ENDPOINT_PERMISSIONS + assert "system:permissions:project_config" in SYSTEM_PERMISSIONS # ============================================================================ @@ -506,17 +481,9 @@ async def test_startup_permission_denied_for_cra(db_session: AsyncSession): @pytest.mark.asyncio -async def test_project_permissions_denied_for_cra(db_session: AsyncSession): - """验证CRA无法修改项目权限""" +async def test_removed_project_permissions_are_denied_for_project_roles(db_session: AsyncSession): + """项目角色不能再通过 permissions:update 这种残留 key 获得权限管理能力。""" 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") assert allowed is False diff --git a/backend/tests/test_registration.py b/backend/tests/test_registration.py index aaf2b347..7952c466 100644 --- a/backend/tests/test_registration.py +++ b/backend/tests/test_registration.py @@ -190,6 +190,64 @@ async def test_admin_can_approve_user(client_and_db): 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(): assert "role" not in UserRegisterRequest.model_fields diff --git a/frontend/src/api/sites.ts b/frontend/src/api/sites.ts index a7d004e0..9f838b56 100644 --- a/frontend/src/api/sites.ts +++ b/frontend/src/api/sites.ts @@ -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"; -export const fetchSites = (studyId: string, params?: Record) => +export const fetchSites = (studyId: string, params?: Record, config?: ApiRequestConfig) => apiGet | 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 & { name: string }) => diff --git a/frontend/src/views/admin/AuditLogs.vue b/frontend/src/views/admin/AuditLogs.vue index 4ee51dac..4c07179b 100644 --- a/frontend/src/views/admin/AuditLogs.vue +++ b/frontend/src/views/admin/AuditLogs.vue @@ -162,7 +162,7 @@ v-model="detailVisible" direction="rtl" size="580px" - :close-on-click-modal="false" + :close-on-click-modal="true" :show-close="false" class="audit-detail-drawer" > diff --git a/frontend/src/views/admin/ProjectDetail.vue b/frontend/src/views/admin/ProjectDetail.vue index fd7ee592..f2d79d04 100644 --- a/frontend/src/views/admin/ProjectDetail.vue +++ b/frontend/src/views/admin/ProjectDetail.vue @@ -1653,7 +1653,8 @@ v-model="projectMilestoneEditorVisible" direction="rtl" size="480px" - :close-on-click-modal="false" + :close-on-click-modal="true" + :before-close="projectMilestoneEditorDirtyGuard.beforeClose" :show-close="false" class="setup-milestone-editor-drawer" > @@ -1756,7 +1757,8 @@ v-model="siteMilestoneEditorVisible" direction="rtl" size="480px" - :close-on-click-modal="false" + :close-on-click-modal="true" + :before-close="siteMilestoneEditorDirtyGuard.beforeClose" :show-close="false" class="setup-milestone-editor-drawer" > @@ -1836,7 +1838,8 @@ v-model="siteEnrollmentEditorVisible" direction="rtl" size="480px" - :close-on-click-modal="false" + :close-on-click-modal="true" + :before-close="siteEnrollmentEditorDirtyGuard.beforeClose" :show-close="false" class="setup-milestone-editor-drawer" > @@ -1936,6 +1939,7 @@ import { isSystemAdmin } from "../../utils/roles"; import { isApiPermissionAllowed } from "../../utils/apiPermissionValue"; import { groupErrorsBySection, parseFieldPath, type SetupValidationError } from "../../utils/setupFieldLocator"; import { buildSetupReadableDiffRows, serializeDiffValue, type SetupDiffRow } from "../../utils/setupDiffRows"; +import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard"; import { SETUP_WORKFLOW_TAG_META, buildProjectDiffRows, @@ -2161,6 +2165,9 @@ const siteEnrollmentEditorForm = reactive({ note: "", stageBreakdown: "", }); +const projectMilestoneEditorDirtyGuard = useDrawerDirtyGuard(() => projectMilestoneEditorForm); +const siteMilestoneEditorDirtyGuard = useDrawerDirtyGuard(() => siteMilestoneEditorForm); +const siteEnrollmentEditorDirtyGuard = useDrawerDirtyGuard(() => siteEnrollmentEditorForm); type EnrollmentCycle = "month" | "quarter"; @@ -4394,6 +4401,9 @@ const loadProject = async () => { const { data } = studyRes; project.value = data as Study; projectPermissions.value = permRes.data as any; + studyStore.setViewContext({ + pageTitle: project.value.name || TEXT.modules.adminProjects.detailTitle, + }); if (!canReadSetupConfig.value) { ElMessage.warning("当前角色无权访问立项配置"); @@ -5694,7 +5704,7 @@ const removeProjectMilestone = (index: number) => { setupDraft.projectMilestones.splice(index, 1); }; const openProjectMilestoneEditor = (index: number) => { - openIndexedEditor(setupDraft.projectMilestones, index, projectMilestoneEditor, (row) => { + const opened = openIndexedEditor(setupDraft.projectMilestones, index, projectMilestoneEditor, (row) => { projectMilestoneEditorForm.id = row.id || ""; projectMilestoneEditorForm.name = row.name || ""; projectMilestoneEditorForm.startDate = resolveProjectMilestoneStart(row); @@ -5705,6 +5715,7 @@ const openProjectMilestoneEditor = (index: number) => { projectMilestoneEditorForm.remark = row.remark || ""; projectMilestoneEditorForm.status = row.status || "未开始"; }); + if (opened) projectMilestoneEditorDirtyGuard.syncBaseline(); }; const saveProjectMilestoneEditor = () => { if (!canMutateDraft()) return; @@ -5784,7 +5795,7 @@ const removeSiteMilestone = (index: number) => { setupDraft.siteMilestones.splice(index, 1); }; const openSiteMilestoneEditor = (index: number) => { - openIndexedEditor(setupDraft.siteMilestones, index, siteMilestoneEditor, (row) => { + const opened = openIndexedEditor(setupDraft.siteMilestones, index, siteMilestoneEditor, (row) => { siteMilestoneEditorForm.id = row.id || ""; siteMilestoneEditorForm.milestone = row.milestone || ""; siteMilestoneEditorForm.planDate = row.planDate || ""; @@ -5792,6 +5803,7 @@ const openSiteMilestoneEditor = (index: number) => { siteMilestoneEditorForm.remark = row.remark || ""; siteMilestoneEditorForm.status = row.status || "未开始"; }); + if (opened) siteMilestoneEditorDirtyGuard.syncBaseline(); }; const saveSiteMilestoneEditor = () => { if (!canMutateDraft()) return; @@ -5832,7 +5844,7 @@ const removeSiteEnrollment = (index: number) => { setupDraft.siteEnrollmentPlans.splice(index, 1); }; const openSiteEnrollmentEditor = (index: number) => { - openIndexedEditor(setupDraft.siteEnrollmentPlans, index, siteEnrollmentEditor, (row) => { + const opened = openIndexedEditor(setupDraft.siteEnrollmentPlans, index, siteEnrollmentEditor, (row) => { siteEnrollmentEditorForm.id = row.id || ""; siteEnrollmentEditorForm.siteId = row.siteId || ""; siteEnrollmentEditorForm.siteName = row.siteName || ""; @@ -5842,6 +5854,7 @@ const openSiteEnrollmentEditor = (index: number) => { siteEnrollmentEditorForm.note = row.note || ""; siteEnrollmentEditorForm.stageBreakdown = row.stageBreakdown || ""; }); + if (opened) siteEnrollmentEditorDirtyGuard.syncBaseline(); }; const saveSiteEnrollmentEditor = () => { if (!canMutateDraft()) return; diff --git a/frontend/src/views/admin/Projects.test.ts b/frontend/src/views/admin/Projects.test.ts index 7afc7b5c..ffbddeb1 100644 --- a/frontend/src/views/admin/Projects.test.ts +++ b/frontend/src/views/admin/Projects.test.ts @@ -52,6 +52,28 @@ describe("project management access", () => { 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", () => { const source = readProjects(); diff --git a/frontend/src/views/admin/Projects.vue b/frontend/src/views/admin/Projects.vue index e133db92..e805fbe8 100644 --- a/frontend/src/views/admin/Projects.vue +++ b/frontend/src/views/admin/Projects.vue @@ -63,8 +63,8 @@
- - + + - + + + + - + - + + + + + +
{{ TEXT.modules.adminUsers.passwordHint }}
+
({ ], full_name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), 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 = () => { @@ -264,6 +302,13 @@ const onSubmit = async () => { color: var(--ctms-warning); } +.password-hint { + font-size: 12px; + line-height: 1.4; + color: var(--ctms-text-secondary); + margin-top: 6px; +} + .dialog-footer { display: flex; justify-content: flex-end; diff --git a/frontend/src/views/admin/Users.test.ts b/frontend/src/views/admin/Users.test.ts index d371a244..72d08a55 100644 --- a/frontend/src/views/admin/Users.test.ts +++ b/frontend/src/views/admin/Users.test.ts @@ -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", () => { it("prevents browser credential autofill when creating a user", () => { const formSource = readUserForm(); expect(formSource).toContain('autocomplete="off"'); + expect(formSource).toContain('name="ctms-create-initial-password"'); + expect(formSource).toContain('autocomplete="new-password"'); expect(formSource).not.toContain("current_password"); expect(formSource).not.toContain("confirmPassword"); expect(formSource).not.toContain("TEXT.modules.profile.currentPassword"); @@ -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", () => { it("hydrates the selected user immediately when the dialog is mounted open", () => { const formSource = readUserForm(); diff --git a/frontend/src/views/admin/Users.vue b/frontend/src/views/admin/Users.vue index c4eaa653..6d90bde7 100644 --- a/frontend/src/views/admin/Users.vue +++ b/frontend/src/views/admin/Users.vue @@ -65,11 +65,12 @@ clearable class="filter-input-comp" :prefix-icon="Search" - @keyup.enter="loadUsers" + @clear="applyFilters" + @keyup.enter="applyFilters" />
- + @@ -77,7 +78,7 @@
- {{ TEXT.common.actions.search }} + {{ TEXT.common.actions.search }}
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }} @@ -249,6 +250,11 @@ const loadUsers = async () => { } }; +const applyFilters = () => { + page.value = 1; + loadUsers(); +}; + const onPageSizeChange = (size: number) => { pageSize.value = size; page.value = 1; diff --git a/frontend/src/views/startup/KickoffForm.vue b/frontend/src/views/startup/KickoffForm.vue index 6fbd99ff..fb2394c3 100644 --- a/frontend/src/views/startup/KickoffForm.vue +++ b/frontend/src/views/startup/KickoffForm.vue @@ -34,18 +34,18 @@
-
- +
+
{{ TEXT.common.labels.attachments }}
- +
@@ -62,14 +62,16 @@ import { fetchSites } from "../../api/sites"; import { listMembers } from "../../api/members"; import { fetchUsers } from "../../api/users"; import AttachmentList from "../../components/attachments/AttachmentList.vue"; -import StateEmpty from "../../components/StateEmpty.vue"; import { TEXT } from "../../locales"; +import { usePermission } from "../../utils/permission"; const route = useRoute(); const router = useRouter(); const auth = useAuthStore(); const study = useStudyStore(); +const { can } = usePermission(); const saving = ref(false); +const attachmentPanelRef = ref | null>(null); const meetingId = computed(() => route.params.meetingId as string | undefined); const isEdit = computed(() => !!meetingId.value); @@ -79,6 +81,10 @@ const siteId = ref(""); const users = ref([]); const members = ref([]); 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 map: Record = {}; @@ -103,12 +109,13 @@ const ownerLabel = computed(() => { .map((c) => memberNameMap.value[c] || c) .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 = [ - { entityType: "startup_kickoff_minutes", title: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel }, - { entityType: "startup_kickoff_signin", title: TEXT.modules.startupMeetingAuth.kickoffSignInLabel }, - { entityType: "startup_kickoff_ppt", title: TEXT.modules.startupMeetingAuth.kickoffPptLabel }, - { entityType: "startup_kickoff", title: TEXT.modules.startupMeetingAuth.kickoffOtherLabel }, + { entityType: "startup_kickoff_minutes", label: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel }, + { entityType: "startup_kickoff_signin", label: TEXT.modules.startupMeetingAuth.kickoffSignInLabel }, + { entityType: "startup_kickoff_ppt", label: TEXT.modules.startupMeetingAuth.kickoffPptLabel }, + { entityType: "startup_kickoff", label: TEXT.modules.startupMeetingAuth.kickoffOtherLabel }, ]; const form = reactive({ @@ -149,7 +156,11 @@ const load = async () => { const submit = async () => { if (!studyId.value) return; - if (isReadOnly.value) { + if (!canSaveAuth.value) { + ElMessage.warning("权限不足"); + return; + } + if (isInactiveSite.value) { ElMessage.warning("中心已停用"); return; } @@ -159,15 +170,16 @@ const submit = async () => { kickoff_date: form.kickoff_date || null, attendees: parseAttendees(form.attendees), }; + let savedId = meetingId.value || ""; if (isEdit.value && meetingId.value) { await updateKickoff(studyId.value, meetingId.value, payload); - ElMessage.success(TEXT.common.messages.saveSuccess); - router.push(`/startup/kickoff/${meetingId.value}`); } else { const { data } = await createKickoff(studyId.value, { ...payload, site_id: siteId.value }); - ElMessage.success(TEXT.common.messages.createSuccess); - router.push(`/startup/kickoff/${data.id}`); + savedId = 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) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed); } finally { @@ -179,8 +191,11 @@ const goBack = () => router.push("/startup/meeting-auth"); onMounted(async () => { if (studyId.value) { - const membersReq = listMembers(studyId.value, { limit: 500 }); const usersReq = isAdmin.value ? fetchUsers({ limit: 500 }) : Promise.resolve(null); + let membersReq: Promise = 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); if (membersResp?.data) { members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || []; @@ -200,9 +215,4 @@ onMounted(async () => { gap: 0; } -.attachment-grid { - display: flex; - flex-direction: column; - gap: 16px; -} diff --git a/frontend/src/views/startup/TrainingForm.vue b/frontend/src/views/startup/TrainingForm.vue index 54376054..db16be94 100644 --- a/frontend/src/views/startup/TrainingForm.vue +++ b/frontend/src/views/startup/TrainingForm.vue @@ -40,14 +40,17 @@
+
+
{{ TEXT.common.labels.attachments }}
+
-
@@ -61,13 +64,15 @@ import { useStudyStore } from "../../store/study"; import { createTrainingAuthorization, getTrainingAuthorization, updateTrainingAuthorization } from "../../api/startup"; import { fetchSites } from "../../api/sites"; import AttachmentList from "../../components/attachments/AttachmentList.vue"; -import StateEmpty from "../../components/StateEmpty.vue"; import { TEXT } from "../../locales"; +import { usePermission } from "../../utils/permission"; const route = useRoute(); const router = useRouter(); const study = useStudyStore(); +const { can } = usePermission(); const saving = ref(false); +const attachmentPanelRef = ref | null>(null); const sites = ref([]); const recordId = computed(() => route.params.recordId as string | undefined); @@ -92,7 +97,11 @@ const siteActiveMap = computed(() => { }); 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 () => { if (!studyId.value) return; @@ -125,7 +134,11 @@ const load = async () => { const submit = async () => { if (!studyId.value) return; - if (isReadOnly.value) { + if (!canSaveAuth.value) { + ElMessage.warning("权限不足"); + return; + } + if (isInactiveSite.value) { ElMessage.warning("中心已停用"); return; } @@ -145,15 +158,16 @@ const submit = async () => { authorized_date: form.authorized_date || null, remark: form.remark || null, }; + let savedId = recordId.value || ""; if (isEdit.value && recordId.value) { await updateTrainingAuthorization(studyId.value, recordId.value, payload); - ElMessage.success(TEXT.common.messages.saveSuccess); - router.push(`/startup/training/${recordId.value}`); } else { const { data } = await createTrainingAuthorization(studyId.value, payload); - ElMessage.success(TEXT.common.messages.createSuccess); - router.push(`/startup/training/${data.id}`); + savedId = 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) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed); } finally {