diff --git a/backend/app/api/v1/faq_categories.py b/backend/app/api/v1/faq_categories.py index 16d43ab4..efbdb0ef 100644 --- a/backend/app/api/v1/faq_categories.py +++ b/backend/app/api/v1/faq_categories.py @@ -50,7 +50,7 @@ async def create_category( action="CREATE_FAQ_CATEGORY", detail=f"FAQ 分类 {category.name} 已创建", operator_id=current_user.id, - operator_role=await get_operator_role_label(db, study_id, current_user), + operator_role=await get_operator_role_label(db, payload.study_id, current_user), ) return CategoryRead.model_validate(category) @@ -112,7 +112,7 @@ async def update_category( action="UPDATE_FAQ_CATEGORY", detail=f"FAQ 分类 {category_id} 已更新", operator_id=current_user.id, - operator_role=await get_operator_role_label(db, study_id, current_user), + operator_role=await get_operator_role_label(db, updated.study_id, current_user), ) return CategoryRead.model_validate(updated) @@ -150,5 +150,5 @@ async def delete_category( action="DELETE_FAQ_CATEGORY", detail=f"FAQ 分类 {category_id} 已删除", operator_id=current_user.id, - operator_role=await get_operator_role_label(db, study_id, current_user), + operator_role=await get_operator_role_label(db, category.study_id, current_user), ) diff --git a/backend/app/api/v1/faqs.py b/backend/app/api/v1/faqs.py index 61fd0b65..72fed2e8 100644 --- a/backend/app/api/v1/faqs.py +++ b/backend/app/api/v1/faqs.py @@ -74,7 +74,7 @@ async def create_faq( action="CREATE_FAQ_ITEM", detail="FAQ 已创建", operator_id=current_user.id, - operator_role=await get_operator_role_label(db, study_id, current_user), + operator_role=await get_operator_role_label(db, payload.study_id, current_user), ) return FaqRead.model_validate(item) @@ -200,7 +200,7 @@ async def update_faq( action=action, detail=detail, operator_id=current_user.id, - operator_role=await get_operator_role_label(db, study_id, current_user), + operator_role=await get_operator_role_label(db, item.study_id, current_user), ) return FaqRead.model_validate(updated) @@ -356,7 +356,7 @@ async def create_reply( action="CREATE_FAQ_REPLY", detail="FAQ 已回复", operator_id=current_user.id, - operator_role=await get_operator_role_label(db, study_id, current_user), + operator_role=await get_operator_role_label(db, item.study_id, current_user), ) data = FaqReplyRead.model_validate(reply) if quote: @@ -401,7 +401,7 @@ async def delete_faq( action="DELETE_FAQ_ITEM", detail="FAQ 已删除", operator_id=current_user.id, - operator_role=await get_operator_role_label(db, study_id, current_user), + operator_role=await get_operator_role_label(db, item.study_id, current_user), ) @@ -454,5 +454,5 @@ async def delete_reply( action="DELETE_FAQ_REPLY", detail="FAQ 回复已删除", operator_id=current_user.id, - operator_role=await get_operator_role_label(db, study_id, current_user), + operator_role=await get_operator_role_label(db, item.study_id, current_user), ) diff --git a/frontend/src/api/faqs.test.ts b/frontend/src/api/faqs.test.ts new file mode 100644 index 00000000..cb130076 --- /dev/null +++ b/frontend/src/api/faqs.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const apiPost = vi.fn(); +const apiPatch = vi.fn(); +const apiDelete = vi.fn(); + +vi.mock("./axios", () => ({ + apiPost, + apiPatch, + apiDelete, +})); + +describe("faqs category api", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("passes study_id as a query parameter for category write permission checks", async () => { + const { createFaqCategory, updateFaqCategory, deleteFaqCategory } = await import("./faqs"); + const payload = { study_id: "study-1", name: "用药咨询" }; + + createFaqCategory(payload); + updateFaqCategory("category-1", payload); + deleteFaqCategory("category-1", "study-1"); + + expect(apiPost).toHaveBeenCalledWith("/api/v1/faqs/categories/", payload, { params: { study_id: "study-1" } }); + expect(apiPatch).toHaveBeenCalledWith("/api/v1/faqs/categories/category-1", payload, { + params: { study_id: "study-1" }, + }); + expect(apiDelete).toHaveBeenCalledWith("/api/v1/faqs/categories/category-1", { + params: { study_id: "study-1" }, + }); + }); +}); + +describe("faqs item api", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("passes study_id as a query parameter for item write permission checks", async () => { + const { createFaqItem, updateFaqItem, deleteFaqItem } = await import("./faqs"); + const payload = { study_id: "study-1", category_id: "category-1", question: "是否需要空腹用药?" }; + + createFaqItem(payload); + updateFaqItem("item-1", payload); + deleteFaqItem("item-1", "study-1"); + + expect(apiPost).toHaveBeenCalledWith("/api/v1/faqs/items/", payload, { params: { study_id: "study-1" } }); + expect(apiPatch).toHaveBeenCalledWith("/api/v1/faqs/items/item-1", payload, { + params: { study_id: "study-1" }, + }); + expect(apiDelete).toHaveBeenCalledWith("/api/v1/faqs/items/item-1", { + params: { study_id: "study-1" }, + }); + }); +}); diff --git a/frontend/src/api/faqs.ts b/frontend/src/api/faqs.ts index e3160723..1200ec9f 100644 --- a/frontend/src/api/faqs.ts +++ b/frontend/src/api/faqs.ts @@ -51,27 +51,30 @@ export interface FaqReply { export const fetchFaqCategories = (params?: Record): Promise>> => apiGet("/api/v1/faqs/categories/", { params }); +const studyQuery = (studyId?: string | null) => (studyId ? { params: { study_id: studyId } } : undefined); + export const createFaqCategory = (payload: Record) => - apiPost("/api/v1/faqs/categories/", payload); + apiPost("/api/v1/faqs/categories/", payload, studyQuery(payload.study_id)); export const updateFaqCategory = (categoryId: string, payload: Record) => - apiPatch(`/api/v1/faqs/categories/${categoryId}`, payload); + apiPatch(`/api/v1/faqs/categories/${categoryId}`, payload, studyQuery(payload.study_id)); -export const deleteFaqCategory = (categoryId: string) => - apiDelete(`/api/v1/faqs/categories/${categoryId}`); +export const deleteFaqCategory = (categoryId: string, studyId?: string | null) => + apiDelete(`/api/v1/faqs/categories/${categoryId}`, studyQuery(studyId)); export const fetchFaqItems = (params?: Record): Promise>> => apiGet("/api/v1/faqs/items/", { params }); export const fetchFaqItem = (itemId: string) => apiGet(`/api/v1/faqs/items/${itemId}`); -export const createFaqItem = (payload: Record) => apiPost("/api/v1/faqs/items/", payload); +export const createFaqItem = (payload: Record) => + apiPost("/api/v1/faqs/items/", payload, studyQuery(payload.study_id)); export const updateFaqItem = (itemId: string, payload: Record) => - apiPatch(`/api/v1/faqs/items/${itemId}`, payload); + apiPatch(`/api/v1/faqs/items/${itemId}`, payload, studyQuery(payload.study_id)); -export const deleteFaqItem = (itemId: string) => - apiDelete(`/api/v1/faqs/items/${itemId}`); +export const deleteFaqItem = (itemId: string, studyId?: string | null) => + apiDelete(`/api/v1/faqs/items/${itemId}`, studyQuery(studyId)); export const fetchFaqReplies = (itemId: string) => apiGet(`/api/v1/faqs/items/${itemId}/replies`); diff --git a/frontend/src/components/FaqCategoryForm.test.ts b/frontend/src/components/FaqCategoryForm.test.ts new file mode 100644 index 00000000..b3eaab2c --- /dev/null +++ b/frontend/src/components/FaqCategoryForm.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const readSource = () => readFileSync(resolve(__dirname, "./FaqCategoryForm.vue"), "utf8"); + +describe("FaqCategoryForm drawer editor", () => { + it("uses the shared right-side drawer pattern instead of a dialog", () => { + const source = readSource(); + + expect(source).toContain(" { + const source = readSource(); + + expect(source).toContain("TEXT.modules.knowledgeMedicalConsult.categoryName"); + expect(source).not.toContain(':label="TEXT.common.fields.name"'); + }); +}); diff --git a/frontend/src/components/FaqCategoryForm.vue b/frontend/src/components/FaqCategoryForm.vue index dcb562f5..4d4f18b8 100644 --- a/frontend/src/components/FaqCategoryForm.vue +++ b/frontend/src/components/FaqCategoryForm.vue @@ -1,24 +1,47 @@ + + diff --git a/frontend/src/components/FaqCategoryPanel.vue b/frontend/src/components/FaqCategoryPanel.vue index 40fe50dc..1eb23722 100644 --- a/frontend/src/components/FaqCategoryPanel.vue +++ b/frontend/src/components/FaqCategoryPanel.vue @@ -2,8 +2,8 @@
{{ TEXT.common.labels.category }} -
- +
+ {{ TEXT.modules.knowledgeMedicalConsult.newCategory }}
@@ -12,11 +12,11 @@ {{ TEXT.common.labels.all }} {{ c.name }} -
- +
+ {{ TEXT.common.actions.edit }} - + {{ TEXT.common.actions.delete }}
@@ -52,8 +52,9 @@ const editing = ref(null); const { can } = usePermission(); const isAdmin = computed(() => !!auth.user?.is_admin); -const canMaintain = computed(() => can("faq.edit")); -const canDelete = computed(() => isAdmin.value); +const canCreateCategory = computed(() => can("faq.category.create")); +const canUpdateCategory = computed(() => can("faq.category.update")); +const canDeleteCategory = computed(() => can("faq.category.delete")); const sortedCategories = computed(() => [...props.categories].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)) @@ -62,7 +63,7 @@ const sortedCategories = computed(() => const activeCategory = computed(() => props.modelValue || ""); const canEdit = (c: FaqCategory) => { - if (!canMaintain.value) return false; + if (!canUpdateCategory.value) return false; if (isAdmin.value) return true; return c.study_id === study.currentStudy?.id; }; @@ -77,13 +78,17 @@ const openForm = (cat?: FaqCategory) => { }; const onDelete = async (cat: FaqCategory) => { + if (!canDeleteCategory.value) { + ElMessage.warning("权限不足"); + return; + } const ok = await ElMessageBox.confirm( TEXT.modules.knowledgeMedicalConsult.confirmDeleteCategory.replace("{name}", cat.name), TEXT.common.labels.tips ).catch(() => null); if (!ok) return; try { - await deleteFaqCategory(cat.id); + await deleteFaqCategory(cat.id, study.currentStudy?.id); ElMessage.success(TEXT.common.messages.deleteSuccess); emit("refresh"); } catch (e: any) { diff --git a/frontend/src/components/FaqItemForm.test.ts b/frontend/src/components/FaqItemForm.test.ts new file mode 100644 index 00000000..907f87e3 --- /dev/null +++ b/frontend/src/components/FaqItemForm.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const readSource = () => readFileSync(resolve(__dirname, "./FaqItemForm.vue"), "utf8"); + +describe("FaqItemForm drawer", () => { + it("uses the unified drawer pattern instead of a dialog", () => { + const source = readSource(); + + expect(source).toContain(" - - - - - - - - - - - - - + + + + +
+
+ + {{ TEXT.common.labels.basicInfo }} +
+ + + + + + + + + + + +
-
+ + + diff --git a/frontend/src/components/FaqList.vue b/frontend/src/components/FaqList.vue index 25f8331f..88bd4db0 100644 --- a/frontend/src/components/FaqList.vue +++ b/frontend/src/components/FaqList.vue @@ -27,16 +27,16 @@ {{ statusLabel(scope.row.status) }} - + diff --git a/frontend/src/views/startup/FeasibilityEditorDrawer.vue b/frontend/src/views/startup/FeasibilityEditorDrawer.vue new file mode 100644 index 00000000..e01d2be3 --- /dev/null +++ b/frontend/src/views/startup/FeasibilityEditorDrawer.vue @@ -0,0 +1,266 @@ + + + + + diff --git a/frontend/src/views/startup/FeasibilityForm.vue b/frontend/src/views/startup/FeasibilityForm.vue index 53fc2209..2470c39c 100644 --- a/frontend/src/views/startup/FeasibilityForm.vue +++ b/frontend/src/views/startup/FeasibilityForm.vue @@ -73,18 +73,17 @@
- - +
+
{{ TEXT.common.labels.attachments }}
+
+
@@ -100,13 +99,15 @@ import { createFeasibility, getFeasibility, updateFeasibility } from "../../api/ import { fetchSites } from "../../api/sites"; import type { Site } from "../../types/api"; 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 recordId = computed(() => route.params.recordId as string | undefined); const isEdit = computed(() => !!recordId.value); @@ -123,7 +124,11 @@ const siteActiveMap = computed(() => { }); return map; }); -const isReadOnly = computed(() => isEdit.value && !!form.site_id && siteActiveMap.value[form.site_id] === false); +const canCreateInitiation = computed(() => can("startup.initiation.create")); +const canUpdateInitiation = computed(() => can("startup.initiation.update")); +const canSaveInitiation = computed(() => (isEdit.value ? canUpdateInitiation.value : canCreateInitiation.value)); +const isInactiveSite = computed(() => isEdit.value && !!form.site_id && siteActiveMap.value[form.site_id] === false); +const isReadOnly = computed(() => !canSaveInitiation.value || isInactiveSite.value); const form = reactive({ site_id: "", @@ -161,7 +166,11 @@ const load = async () => { const submit = async () => { if (!studyId.value) return; - if (isReadOnly.value) { + if (!canSaveInitiation.value) { + ElMessage.warning("权限不足"); + return; + } + if (isInactiveSite.value) { ElMessage.warning("中心已停用"); return; } @@ -178,15 +187,16 @@ const submit = async () => { approved_date: form.approved_date || null, project_no: form.project_no || null, }; + let savedId = recordId.value || ""; if (isEdit.value && recordId.value) { await updateFeasibility(studyId.value, recordId.value, payload); - ElMessage.success(TEXT.common.messages.saveSuccess); - router.push(`/startup/feasibility/${recordId.value}`); } else { const { data } = await createFeasibility(studyId.value, payload); - ElMessage.success(TEXT.common.messages.createSuccess); - router.push(`/startup/feasibility/${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/feasibility/${savedId}`); } catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed); } finally { diff --git a/frontend/src/views/startup/StartupFormDetailPermissions.test.ts b/frontend/src/views/startup/StartupFormDetailPermissions.test.ts new file mode 100644 index 00000000..ab713b87 --- /dev/null +++ b/frontend/src/views/startup/StartupFormDetailPermissions.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const read = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8"); + +describe("startup initiation and ethics form/detail permissions", () => { + it("uses startup initiation create/update permissions in feasibility form and detail", () => { + const formSource = read("./FeasibilityForm.vue"); + const detailSource = read("./FeasibilityDetail.vue"); + + expect(formSource).toContain('const canCreateInitiation = computed(() => can("startup.initiation.create"))'); + expect(formSource).toContain('const canUpdateInitiation = computed(() => can("startup.initiation.update"))'); + expect(formSource).toContain("const canSaveInitiation = computed(() => (isEdit.value ? canUpdateInitiation.value : canCreateInitiation.value))"); + expect(formSource).toContain("if (!canSaveInitiation.value)"); + expect(detailSource).toContain('const canUpdateInitiation = computed(() => can("startup.initiation.update"))'); + expect(detailSource).toContain('v-if="canUpdateInitiation"'); + expect(detailSource).toContain("if (!canUpdateInitiation.value)"); + }); + + it("uses startup ethics create/update permissions in ethics form and detail", () => { + const formSource = read("./EthicsForm.vue"); + const detailSource = read("./EthicsDetail.vue"); + + expect(formSource).toContain('const canCreateEthics = computed(() => can("startup.ethics.create"))'); + expect(formSource).toContain('const canUpdateEthics = computed(() => can("startup.ethics.update"))'); + expect(formSource).toContain("const canSaveEthics = computed(() => (isEdit.value ? canUpdateEthics.value : canCreateEthics.value))"); + expect(formSource).toContain("if (!canSaveEthics.value)"); + expect(detailSource).toContain('const canUpdateEthics = computed(() => can("startup.ethics.update"))'); + expect(detailSource).toContain('v-if="canUpdateEthics"'); + expect(detailSource).toContain("if (!canUpdateEthics.value)"); + }); + + it("keeps detail attachment areas display-only and moves upload into editor drawers", () => { + const feasibilityDetail = read("./FeasibilityDetail.vue"); + const ethicsDetail = read("./EthicsDetail.vue"); + const feasibilityDrawer = read("./FeasibilityEditorDrawer.vue"); + const ethicsDrawer = read("./EthicsEditorDrawer.vue"); + + expect(feasibilityDetail).toContain(":hide-uploader=\"true\""); + expect(ethicsDetail).toContain(":hide-uploader=\"true\""); + expect(feasibilityDrawer).toContain("AttachmentList"); + expect(feasibilityDrawer).toContain('ref="attachmentPanelRef"'); + expect(feasibilityDrawer).toContain('entity-type="startup_feasibility"'); + expect(feasibilityDrawer).toContain(":entity-id=\"recordId || ''\""); + expect(feasibilityDrawer).toContain(':mode="\'upload\'"'); + expect(feasibilityDrawer).toContain("attachments: attachmentPanelRef.value?.pendingSnapshot() || []"); + expect(feasibilityDrawer).toContain("await attachmentPanelRef.value?.uploadPending(id)"); + expect(feasibilityDrawer).not.toContain("PendingAttachmentUpload"); + expect(ethicsDrawer).toContain("AttachmentList"); + expect(ethicsDrawer).toContain('ref="attachmentPanelRef"'); + expect(ethicsDrawer).toContain('entity-type="startup_ethics"'); + expect(ethicsDrawer).toContain(":entity-id=\"recordId || ''\""); + expect(ethicsDrawer).toContain(':mode="\'upload\'"'); + expect(ethicsDrawer).toContain("attachments: attachmentPanelRef.value?.pendingSnapshot() || []"); + expect(ethicsDrawer).toContain("await attachmentPanelRef.value?.uploadPending(id)"); + expect(ethicsDrawer).not.toContain("PendingAttachmentUpload"); + expect(feasibilityDrawer).not.toContain('class="attachment-group"'); + expect(ethicsDrawer).not.toContain('class="attachment-group"'); + }); + + it("allows legacy feasibility and ethics forms to queue attachments before the record exists", () => { + const feasibilityForm = read("./FeasibilityForm.vue"); + const ethicsForm = read("./EthicsForm.vue"); + + expect(feasibilityForm).toContain(" { expect(source).toContain("isSystemAdmin"); [ "subjects:update", + "subject_histories:read", "subject_histories:create", "subject_histories:update", "subject_histories:delete", + "visits:list", "visits:create", "visits:update", "visits:delete", "subject_aes:create", - "subject_aes:list", + "subject_aes:read", "subject_aes:update", "subject_aes:delete", "subject_pds:create", @@ -75,6 +77,10 @@ describe("SubjectDetail medication adherence", () => { expect(source).toContain(operationKey); }); expect(source).toContain("v-if=\"currentTabAction.allowed\""); + expect(source).toContain("v-if=\"canReadHistory\""); + expect(source).toContain("v-if=\"canReadVisit\""); + expect(source).toContain("v-if=\"canListAe\""); + expect(source).toContain("v-if=\"canListPd\""); expect(source).toContain("v-if=\"canUpdateHistory || canDeleteHistory\""); expect(source).toContain("v-if=\"canUpdateVisit || canDeleteVisit\""); expect(source).toContain("v-if=\"canUpdateAe || canDeleteAe\""); @@ -83,10 +89,26 @@ describe("SubjectDetail medication adherence", () => { expect(source).toContain("const canSaveVisit = computed(() => (visitEditingId.value ? canUpdateVisit.value : canCreateVisit.value));"); expect(source).toContain("const canSaveAe = computed(() => (aeEditingId.value ? canUpdateAe.value : canCreateAe.value));"); expect(source).toContain("const canSavePd = computed(() => (pdEditingId.value ? canUpdatePd.value : canCreatePd.value));"); + expect(source).toContain("const canReadHistory = computed(() => canUseApiPermission(\"subject_histories:read\"));"); + expect(source).toContain("const canReadVisit = computed(() => canUseApiPermission(\"visits:list\"));"); + expect(source).toContain("if (!canReadHistory.value)"); + expect(source).toContain("if (!canReadVisit.value)"); expect(source).toContain("const canCreateAe = computed(() => canUseApiPermission(\"subject_aes:create\"));"); expect(source).toContain("const canUpdateAe = computed(() => canUseApiPermission(\"subject_aes:update\"));"); expect(source).toContain("const canDeleteAe = computed(() => canUseApiPermission(\"subject_aes:delete\"));"); - expect(source).toContain("const canListAe = computed(() => canUseApiPermission(\"subject_aes:list\"));"); + expect(source).toContain("const canListAe = computed(() => canUseApiPermission(\"subject_aes:read\"));"); expect(source).toContain("const canListPd = computed(() => canUseApiPermission(\"subject_pds:list\"));"); }); }); + +describe("SubjectDetail drawer editor", () => { + it("opens participant basic information edit in the shared drawer editor", () => { + const source = readSubjectDetail(); + + expect(source).toContain("SubjectEditorDrawer"); + expect(source).toContain("subjectEditorVisible"); + expect(source).toContain("@success=\"handleSubjectEditorSuccess\""); + expect(source).not.toContain("subjectEditing"); + expect(source).not.toContain("saveSubjectEdit"); + }); +}); diff --git a/frontend/src/views/subjects/SubjectDetail.vue b/frontend/src/views/subjects/SubjectDetail.vue index 09ed519d..4be32aac 100644 --- a/frontend/src/views/subjects/SubjectDetail.vue +++ b/frontend/src/views/subjects/SubjectDetail.vue @@ -6,22 +6,9 @@

{{ TEXT.modules.subjectManagement.screeningNo }}

- - {{ TEXT.common.actions.save }} - - - {{ TEXT.common.actions.cancel }} - - + {{ TEXT.common.actions.edit }} - {{ TEXT.common.actions.back }}
@@ -33,63 +20,27 @@ {{ displaySubjectStatus(detail) }} - - {{ displayDate(detail.screening_date) }} + {{ displayDate(detail.screening_date) }} - - {{ displayDate(detail.consent_date) }} + {{ displayDate(detail.consent_date) }} - - {{ displayDate(detail.enrollment_date) }} + {{ displayDate(detail.enrollment_date) }} - - {{ displayDate(detail.baseline_date) }} + {{ displayDate(detail.baseline_date) }} - - {{ displayDate(detail.completion_date) }} + {{ displayDate(detail.completion_date) }} - - {{ detail.drop_reason || TEXT.common.fallback }} + {{ detail.drop_reason || TEXT.common.fallback }} - +
- + @@ -150,7 +101,7 @@ - + @@ -237,7 +188,7 @@ - +
@@ -274,7 +225,7 @@
- + @@ -329,7 +280,7 @@ - + @@ -374,6 +325,12 @@
+ + @@ -522,7 +479,7 @@ + +