统一业务页面权限与抽屉编辑

1、将 FAQ 分类和问题维护改为抽屉交互,并按分类、问题、回复的细粒度权限控制入口。

2、将注意事项、可行性和伦理记录接入详情页抽屉编辑,创建时支持先保存主记录再上传附件。

3、将参与者基础信息编辑改为抽屉,详情页按可读权限显示病史、访视、AE 和 PD 标签页。

4、补充业务页面权限一致性测试,覆盖停用中心、无权限入口和面包屑上下文。
This commit is contained in:
Cheng Zhou
2026-06-04 11:10:20 +08:00
parent 6a2e43f829
commit 6e8494abd5
39 changed files with 2334 additions and 337 deletions
+57
View File
@@ -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" },
});
});
});
+11 -8
View File
@@ -51,27 +51,30 @@ export interface FaqReply {
export const fetchFaqCategories = (params?: Record<string, any>): Promise<AxiosResponse<ApiListResponse<FaqCategory>>> =>
apiGet("/api/v1/faqs/categories/", { params });
const studyQuery = (studyId?: string | null) => (studyId ? { params: { study_id: studyId } } : undefined);
export const createFaqCategory = (payload: Record<string, any>) =>
apiPost<FaqCategory>("/api/v1/faqs/categories/", payload);
apiPost<FaqCategory>("/api/v1/faqs/categories/", payload, studyQuery(payload.study_id));
export const updateFaqCategory = (categoryId: string, payload: Record<string, any>) =>
apiPatch<FaqCategory>(`/api/v1/faqs/categories/${categoryId}`, payload);
apiPatch<FaqCategory>(`/api/v1/faqs/categories/${categoryId}`, payload, studyQuery(payload.study_id));
export const deleteFaqCategory = (categoryId: string) =>
apiDelete<void>(`/api/v1/faqs/categories/${categoryId}`);
export const deleteFaqCategory = (categoryId: string, studyId?: string | null) =>
apiDelete<void>(`/api/v1/faqs/categories/${categoryId}`, studyQuery(studyId));
export const fetchFaqItems = (params?: Record<string, any>): Promise<AxiosResponse<ApiListResponse<FaqItem>>> =>
apiGet("/api/v1/faqs/items/", { params });
export const fetchFaqItem = (itemId: string) => apiGet<FaqItem>(`/api/v1/faqs/items/${itemId}`);
export const createFaqItem = (payload: Record<string, any>) => apiPost<FaqItem>("/api/v1/faqs/items/", payload);
export const createFaqItem = (payload: Record<string, any>) =>
apiPost<FaqItem>("/api/v1/faqs/items/", payload, studyQuery(payload.study_id));
export const updateFaqItem = (itemId: string, payload: Record<string, any>) =>
apiPatch<FaqItem>(`/api/v1/faqs/items/${itemId}`, payload);
apiPatch<FaqItem>(`/api/v1/faqs/items/${itemId}`, payload, studyQuery(payload.study_id));
export const deleteFaqItem = (itemId: string) =>
apiDelete<void>(`/api/v1/faqs/items/${itemId}`);
export const deleteFaqItem = (itemId: string, studyId?: string | null) =>
apiDelete<void>(`/api/v1/faqs/items/${itemId}`, studyQuery(studyId));
export const fetchFaqReplies = (itemId: string) =>
apiGet<FaqReply[] | { items: FaqReply[] }>(`/api/v1/faqs/items/${itemId}/replies`);
@@ -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("<el-drawer");
expect(source).toContain("faq-category-editor-drawer");
expect(source).toContain('direction="rtl"');
expect(source).toContain("editor-header");
expect(source).toContain("drawer-footer");
expect(source).not.toContain("<el-dialog");
});
it("labels the required name field as category name", () => {
const source = readSource();
expect(source).toContain("TEXT.modules.knowledgeMedicalConsult.categoryName");
expect(source).not.toContain(':label="TEXT.common.fields.name"');
});
});
+88 -18
View File
@@ -1,24 +1,47 @@
<template>
<el-dialog append-to=".layout-main .content-wrapper" :model-value="modelValue" :title="isEdit ? TEXT.modules.knowledgeMedicalConsult.editCategory : TEXT.modules.knowledgeMedicalConsult.newCategory" width="480px" @close="close">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item :label="TEXT.common.fields.name" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.description">
<el-input v-model="form.description" type="textarea" :rows="2" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.sortOrder">
<el-input-number v-model="form.sort_order" :min="0" :step="1" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.enabled">
<el-switch v-model="form.is_active" />
</el-form-item>
<el-drawer
v-if="modelValue"
:model-value="modelValue"
direction="rtl"
size="520px"
:close-on-click-modal="true"
:show-close="false"
class="faq-category-editor-drawer"
@update:model-value="emit('update:modelValue', $event)"
@close="close"
>
<template #header>
<div class="editor-header">
<div class="editor-title">{{ isEdit ? TEXT.modules.knowledgeMedicalConsult.editCategory : TEXT.modules.knowledgeMedicalConsult.newCategory }}</div>
</div>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="faq-category-form">
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-basic"></span>
{{ TEXT.modules.knowledgeMedicalConsult.title }}
</div>
<el-form-item :label="TEXT.modules.knowledgeMedicalConsult.categoryName" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.description">
<el-input v-model="form.description" type="textarea" :rows="2" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.sortOrder">
<el-input-number v-model="form.sort_order" :min="0" :step="1" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.enabled">
<el-switch v-model="form.is_active" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="close">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
<div class="drawer-footer">
<el-button @click="close">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
</div>
</template>
</el-dialog>
</el-drawer>
</template>
<script setup lang="ts">
@@ -44,7 +67,7 @@ const form = reactive({
});
const rules: FormRules = {
name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), trigger: "blur" }],
name: [{ required: true, message: requiredMessage(TEXT.modules.knowledgeMedicalConsult.categoryName), trigger: "blur" }],
};
const isEdit = computed(() => !!props.category);
@@ -117,3 +140,50 @@ const onSubmit = async () => {
}
};
</script>
<style scoped>
.editor-header {
display: flex;
align-items: center;
}
.editor-title {
color: var(--ctms-text-main);
font-size: 18px;
font-weight: 700;
}
.faq-category-form {
padding: 0 4px;
}
.form-group {
padding: 16px 0;
}
.form-group-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
color: var(--ctms-text-main);
font-size: 15px;
font-weight: 700;
}
.group-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.group-dot-basic {
background: var(--ctms-primary);
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>
+14 -9
View File
@@ -2,8 +2,8 @@
<div class="panel unified-shell">
<div class="header">
<span>{{ TEXT.common.labels.category }}</span>
<div v-if="canMaintain">
<PermissionAction action="faq.edit">
<div v-if="canCreateCategory">
<PermissionAction action="faq.category.create">
<el-button type="primary" size="small" @click="openForm()">{{ TEXT.modules.knowledgeMedicalConsult.newCategory }}</el-button>
</PermissionAction>
</div>
@@ -12,11 +12,11 @@
<el-menu-item index="">{{ TEXT.common.labels.all }}</el-menu-item>
<el-menu-item v-for="c in sortedCategories" :key="c.id" :index="c.id">
<span class="name" :title="c.name">{{ c.name }}</span>
<div v-if="canEdit(c) || canDelete" class="actions">
<PermissionAction v-if="canEdit(c)" action="faq.edit">
<div v-if="canEdit(c) || canDeleteCategory" class="actions">
<PermissionAction v-if="canEdit(c)" action="faq.category.update">
<el-button type="text" size="small" @click.stop="openForm(c)">{{ TEXT.common.actions.edit }}</el-button>
</PermissionAction>
<PermissionAction v-if="canDelete" action="faq.edit">
<PermissionAction v-if="canDeleteCategory" action="faq.category.delete">
<el-button type="text" size="small" class="danger" @click.stop="onDelete(c)">{{ TEXT.common.actions.delete }}</el-button>
</PermissionAction>
</div>
@@ -52,8 +52,9 @@ const editing = ref<FaqCategory | null>(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) {
@@ -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("<el-drawer");
expect(source).toContain('class="faq-item-editor-drawer"');
expect(source).toContain('class="editor-header"');
expect(source).toContain('class="drawer-footer"');
expect(source).not.toContain("<el-dialog");
expect(source).not.toContain('append-to=".layout-main .content-wrapper"');
});
});
+91 -16
View File
@@ -1,23 +1,47 @@
<template>
<el-dialog append-to=".layout-main .content-wrapper" :model-value="modelValue" :title="isEdit ? TEXT.modules.knowledgeMedicalConsult.editItem : TEXT.modules.knowledgeMedicalConsult.newItem" width="640px" @close="close">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item :label="TEXT.common.labels.category" prop="category_id">
<el-select v-model="form.category_id" :placeholder="TEXT.common.placeholders.select">
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.question" prop="question">
<el-input v-model="form.question" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.enabled">
<el-switch v-model="form.is_active" />
</el-form-item>
<el-drawer
v-if="modelValue"
:model-value="modelValue"
direction="rtl"
size="620px"
:close-on-click-modal="true"
:show-close="false"
class="faq-item-editor-drawer"
@update:model-value="emit('update:modelValue', $event)"
@close="close"
>
<template #header>
<div class="editor-header">
<div class="editor-title">{{ isEdit ? TEXT.modules.knowledgeMedicalConsult.editItem : TEXT.modules.knowledgeMedicalConsult.newItem }}</div>
</div>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="faq-item-form">
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-basic"></span>
{{ TEXT.common.labels.basicInfo }}
</div>
<el-form-item :label="TEXT.common.labels.category" prop="category_id">
<el-select v-model="form.category_id" :placeholder="TEXT.common.placeholders.select" class="full-width">
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.question" prop="question">
<el-input v-model="form.question" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.enabled">
<el-switch v-model="form.is_active" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="close">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.submit }}</el-button>
<div class="drawer-footer">
<el-button @click="close">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.submit }}</el-button>
</div>
</template>
</el-dialog>
</el-drawer>
</template>
<script setup lang="ts">
@@ -98,3 +122,54 @@ const onSubmit = async () => {
}
};
</script>
<style scoped>
.editor-header {
display: flex;
align-items: center;
}
.editor-title {
color: var(--ctms-text-main);
font-size: 18px;
font-weight: 700;
}
.faq-item-form {
padding: 0 4px;
}
.form-group {
padding: 16px 0;
}
.form-group-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
color: var(--ctms-text-main);
font-size: 15px;
font-weight: 700;
}
.group-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.group-dot-basic {
background: var(--ctms-primary);
}
.full-width {
width: 100%;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>
+16 -12
View File
@@ -27,16 +27,16 @@
<el-tag :type="statusTagType(scope.row.status)" size="small">{{ statusLabel(scope.row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="140">
<el-table-column v-if="canUpdate || canDelete" :label="TEXT.common.labels.actions" width="140">
<template #default="scope">
<el-button v-if="canDelete(scope.row)" type="text" size="small" class="danger" @click="remove(scope.row)">
<el-button v-if="canDelete" type="text" size="small" class="danger" @click.stop="remove(scope.row)">
{{ TEXT.common.actions.delete }}
</el-button>
<PermissionAction v-if="canEdit" action="faq.edit">
<PermissionAction v-if="canUpdate" action="faq.update">
<el-button
type="text"
size="small"
@click="toggle(scope.row)"
@click.stop="toggle(scope.row)"
:style="{ color: scope.row.is_active ? '#f56c6c' : '#67c23a' }"
>
{{ scope.row.is_active ? TEXT.common.actions.disable : TEXT.common.actions.enable }}
@@ -55,19 +55,18 @@ import { deleteFaqItem, updateFaqItem } from "../api/faqs";
import type { FaqItem, FaqCategory } from "../api/faqs";
import PermissionAction from "./PermissionAction.vue";
import { displayDateTime, displayEnum } from "../utils/display";
import { useAuthStore } from "../store/auth";
import { TEXT } from "../locales";
const props = defineProps<{
items: FaqItem[];
categories: FaqCategory[];
loading: boolean;
canEdit: boolean;
canUpdate: boolean;
canDelete: boolean;
}>();
const emit = defineEmits(["refresh"]);
const router = useRouter();
const auth = useAuthStore();
const categoryMap = computed(() =>
props.categories.reduce<Record<string, string>>((acc, cur) => {
@@ -95,10 +94,11 @@ const statusTagType = (status?: string) => {
return "info";
};
const canDelete = (row: FaqItem) => props.canEdit || row.created_by === auth.user?.id;
const toggle = async (row: FaqItem) => {
if (!props.canUpdate) {
ElMessage.warning("权限不足");
return;
}
const target = !row.is_active;
const ok = await ElMessageBox.confirm(
TEXT.modules.knowledgeMedicalConsult.confirmToggleItem.replace("{action}", target ? TEXT.common.actions.enable : TEXT.common.actions.disable),
@@ -106,7 +106,7 @@ const toggle = async (row: FaqItem) => {
).catch(() => null);
if (!ok) return;
try {
await updateFaqItem(row.id, { is_active: target });
await updateFaqItem(row.id, { study_id: row.study_id, is_active: target });
ElMessage.success(TEXT.common.messages.updateSuccess);
emit("refresh");
} catch (e: any) {
@@ -115,13 +115,17 @@ const toggle = async (row: FaqItem) => {
};
const remove = async (row: FaqItem) => {
if (!props.canDelete) {
ElMessage.warning("权限不足");
return;
}
const ok = await ElMessageBox.confirm(
TEXT.modules.knowledgeMedicalConsult.confirmDeleteItem.replace("{name}", row.question),
TEXT.common.labels.tips
).catch(() => null);
if (!ok) return;
try {
await deleteFaqItem(row.id);
await deleteFaqItem(row.id, row.study_id);
ElMessage.success(TEXT.common.messages.deleteSuccess);
emit("refresh");
} catch (e: any) {
+12 -4
View File
@@ -1,14 +1,14 @@
<template>
<div class="page ctms-page-shell page--flush">
<el-row :gutter="12">
<el-col :span="5">
<el-col v-if="canReadCategories" :span="5">
<FaqCategoryPanel
v-model="activeCategory"
:categories="categories"
@refresh="loadCategories"
/>
</el-col>
<el-col :span="19">
<el-col :span="canReadCategories ? 19 : 24">
<div class="faq-main unified-shell">
<div class="filters unified-action-bar">
<el-input
@@ -29,7 +29,8 @@
:items="faqs"
:categories="categories"
:loading="loading"
:can-edit="canEdit"
:can-update="canUpdateFaq"
:can-delete="canDeleteFaq"
@refresh="loadFaqs"
/>
<div class="pagination-wrap" v-if="total > 0">
@@ -81,11 +82,18 @@ const showForm = ref(false);
const editing = ref<any | null>(null);
const { can } = usePermission();
const canEdit = computed(() => can("faq.edit"));
const canReadCategories = computed(() => can("faq.category.read"));
const canUpdateFaq = computed(() => can("faq.update"));
const canDeleteFaq = computed(() => can("faq.delete"));
const loadCategories = async () => {
try {
if (!study.currentStudy) return;
if (!canReadCategories.value) {
categories.value = [];
activeCategory.value = "";
return;
}
const params: Record<string, any> = {};
if (study.currentStudy) params.study_id = study.currentStudy.id;
const { data } = await fetchFaqCategories(params);
+36 -12
View File
@@ -134,6 +134,8 @@ const replyFiles = ref<UploadUserFile[]>([]);
const replyAttachmentsMap = ref<Record<string, any[]>>({});
const { can } = usePermission();
const canReadCategories = computed(() => can("faq.category.read"));
const canReadMembers = computed(() => can("project.members.list"));
const canReply = computed(() => {
if (!item.value) return false;
@@ -150,29 +152,24 @@ const canUploadReplyAttachment = computed(() => {
const canDeleteReply = (reply: any) => {
if (reply.is_deleted) return false;
if (auth.user?.is_admin) return true;
if (reply.created_by === auth.user?.id) return true;
return item.value?.study_id ? can("faq.edit") : false;
if (!item.value?.study_id) return false;
return can("faq.reply.delete");
};
const canSelectBest = computed(() => {
if (!item.value) return false;
return can("faq.reply");
return can("faq.update");
});
const canEditQuestion = computed(() => {
if (!item.value) return false;
if (auth.user?.is_admin) return true;
if (item.value.created_by === auth.user?.id) return true;
return can("faq.edit");
return can("faq.update");
});
const canConfirmResolved = computed(() => {
if (!item.value) return false;
if (item.value.status === "RESOLVED") return false;
if (auth.user?.is_admin) return true;
if (item.value.created_by === auth.user?.id) return true;
return can("faq.edit");
return can("faq.update");
});
const statusLabel = computed(() => {
@@ -218,6 +215,10 @@ const categoryName = computed(() => {
const loadMembers = async (studyId?: string | null) => {
if (!canReadMembers.value) {
members.value = [];
return;
}
if (!studyId) {
members.value = [];
return;
@@ -250,12 +251,19 @@ const loadData = async () => {
try {
const { data: faqData } = await fetchFaqItem(id);
item.value = faqData;
const questionTitle = String(faqData.question || "").trim();
study.setViewContext({
pageTitle: questionTitle ? questionTitle.slice(0, 24) : TEXT.modules.knowledgeMedicalConsult.detailTitle,
});
const categoryParams: Record<string, any> = {};
if (faqData.study_id) {
categoryParams.study_id = faqData.study_id;
}
const { data: catData } = await fetchFaqCategories(categoryParams);
categories.value = catData.items || catData || [];
categories.value = [];
if (canReadCategories.value) {
const { data: catData } = await fetchFaqCategories(categoryParams);
categories.value = catData.items || catData || [];
}
await Promise.all([loadReplies(), loadMembers(faqData.study_id)]);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
@@ -309,6 +317,10 @@ const submitReply = async () => {
const removeReply = async (reply: any) => {
if (!item.value) return;
if (!canDeleteReply(reply)) {
ElMessage.warning("权限不足");
return;
}
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
try {
@@ -322,6 +334,10 @@ const removeReply = async (reply: any) => {
const toggleBest = async (reply: any) => {
if (!item.value) return;
if (!canSelectBest.value) {
ElMessage.warning("权限不足");
return;
}
try {
const target = item.value.best_reply_id === reply.id ? null : reply.id;
if (target) {
@@ -338,6 +354,10 @@ const toggleBest = async (reply: any) => {
const confirmResolved = async () => {
if (!item.value) return;
if (!canConfirmResolved.value) {
ElMessage.warning("权限不足");
return;
}
try {
const { data } = await setFaqStatus(item.value.id, { status: "RESOLVED" });
item.value = data;
@@ -348,6 +368,10 @@ const confirmResolved = async () => {
};
const openEdit = () => {
if (!canEditQuestion.value) {
ElMessage.warning("权限不足");
return;
}
editing.value = item.value;
showForm.value = true;
};
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const read = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8");
describe("FAQ project permissions", () => {
it("maps FAQ, category, and reply actions to backend operation permissions", () => {
const source = read("../utils/permission.ts");
expect(source).toContain('"faq.update": "faq:update"');
expect(source).toContain('"faq.delete": "faq:delete"');
expect(source).toContain('"faq.category.read": "faq_category:read"');
expect(source).toContain('"faq.category.create": "faq_category:create"');
expect(source).toContain('"faq.category.update": "faq_category:update"');
expect(source).toContain('"faq.category.delete": "faq_category:delete"');
expect(source).toContain('"faq.reply.delete": "faq_reply:delete"');
});
it("passes item update and delete permissions separately to the FAQ list", () => {
const source = read("./Faq.vue");
expect(source).toContain('const canReadCategories = computed(() => can("faq.category.read"))');
expect(source).toContain('const canUpdateFaq = computed(() => can("faq.update"))');
expect(source).toContain('const canDeleteFaq = computed(() => can("faq.delete"))');
expect(source).toContain('v-if="canReadCategories"');
expect(source).toContain('if (!canReadCategories.value)');
expect(source).toContain(':can-update="canUpdateFaq"');
expect(source).toContain(':can-delete="canDeleteFaq"');
});
it("does not expose FAQ item delete through creator ownership when backend requires delete permission", () => {
const source = read("../components/FaqList.vue");
expect(source).toContain("canDelete: boolean");
expect(source).toContain('v-if="canDelete"');
expect(source).not.toContain("row.created_by === auth.user?.id");
});
it("splits FAQ category create update and delete controls by backend operation permissions", () => {
const source = read("../components/FaqCategoryPanel.vue");
expect(source).toContain('const canCreateCategory = computed(() => can("faq.category.create"))');
expect(source).toContain('const canUpdateCategory = computed(() => can("faq.category.update"))');
expect(source).toContain('const canDeleteCategory = computed(() => can("faq.category.delete"))');
expect(source).toContain('action="faq.category.update"');
expect(source).toContain('action="faq.category.delete"');
expect(source).not.toContain("const canDelete = computed(() => isAdmin.value)");
});
it("uses FAQ update and reply delete permissions for detail actions that hit protected endpoints", () => {
const source = read("./FaqDetail.vue");
expect(source).toContain('return can("faq.update")');
expect(source).toContain('return can("faq.reply.delete")');
expect(source).toContain('const canReadCategories = computed(() => can("faq.category.read"))');
expect(source).toContain('const canReadMembers = computed(() => can("project.members.list"))');
expect(source).toContain("if (canReadCategories.value)");
expect(source).toContain("if (!canReadMembers.value)");
expect(source).not.toContain("item.value.created_by === auth.user?.id");
expect(source).not.toContain("reply.created_by === auth.user?.id");
});
});
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8");
describe("precautions project permissions", () => {
it("splits list create and delete controls by backend operation permissions", () => {
const source = readSource("./Precautions.vue");
expect(source).toContain('const canCreatePrecaution = computed(() => can("precautions.create"))');
expect(source).toContain('const canDeletePrecaution = computed(() => can("precautions.delete"))');
expect(source).toContain('v-if="canCreatePrecaution"');
expect(source).toContain('v-if="canDeletePrecaution"');
expect(source).toContain("if (!canDeletePrecaution.value)");
expect(source).not.toContain("canWritePrecautions");
});
});
describe("precautions drawer editor", () => {
it("opens create form in a drawer instead of routing to the form page", () => {
const source = readSource("./Precautions.vue");
expect(source).toContain("PrecautionEditorDrawer");
expect(source).toContain("precautionDrawerVisible");
expect(source).not.toContain('router.push("/knowledge/precautions/new")');
});
it("uses selects for site and level fields in the create drawer", () => {
const source = readSource("../knowledge/PrecautionEditorDrawer.vue");
expect(source).toContain('<el-select v-model="form.site_name"');
expect(source).toContain('v-for="site in sites"');
expect(source).toContain(':value="site.name"');
expect(source).toContain('<el-select v-model="form.level"');
expect(source).toContain("const levelOptions = precautionLevelOptions");
expect(source).toContain('v-for="level in levelOptions"');
expect(source).not.toContain('<el-input v-model="form.site_name"');
expect(source).not.toContain('<el-input v-model="form.level"');
});
it("moves precaution attachment upload into the editor drawer", () => {
const drawer = readSource("../knowledge/PrecautionEditorDrawer.vue");
const detail = readSource("../knowledge/PrecautionDetail.vue");
expect(drawer).toContain("AttachmentList");
expect(drawer).toContain('ref="attachmentPanelRef"');
expect(drawer).toContain(':entity-type="\'precaution\'"');
expect(drawer).toContain(':mode="\'upload\'"');
expect(drawer).toContain("attachments: attachmentPanelRef.value?.pendingSnapshot() || []");
expect(drawer).toContain("await attachmentPanelRef.value?.uploadPending(id)");
expect(detail).toContain(":hide-uploader=\"true\"");
expect(detail).toContain(":refresh-key=\"attachmentRefreshKey\"");
expect(detail).toContain("attachmentRefreshKey.value += 1");
});
});
+21 -5
View File
@@ -24,7 +24,7 @@
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</div>
<div class="filter-spacer"></div>
<el-button v-if="canWritePrecautions" type="primary" @click="goNew">
<el-button v-if="canCreatePrecaution" type="primary" @click="goNew">
{{ TEXT.modules.knowledgeNotes.newTitle }}
</el-button>
</el-form>
@@ -45,10 +45,10 @@
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" show-overflow-tooltip>
<template #default="scope">{{ displayDateTime(scope.row.updated_at) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120">
<el-table-column v-if="canDeletePrecaution" :label="TEXT.common.labels.actions" width="120">
<template #default="scope">
<el-button
v-if="canWritePrecautions"
v-if="canDeletePrecaution"
link
type="danger"
size="small"
@@ -65,6 +65,10 @@
</el-table>
</div>
</div>
<PrecautionEditorDrawer
v-model="precautionDrawerVisible"
@success="handlePrecautionEditorSuccess"
/>
</div>
</template>
@@ -78,11 +82,13 @@ import { fetchSites } from "../../api/sites";
import { displayDateTime } from "../../utils/display";
import { usePermission } from "../../utils/permission";
import { TEXT } from "../../locales";
import PrecautionEditorDrawer from "../knowledge/PrecautionEditorDrawer.vue";
const router = useRouter();
const study = useStudyStore();
const { can } = usePermission();
const loading = ref(false);
const precautionDrawerVisible = ref(false);
const items = ref<any[]>([]);
const sites = ref<any[]>([]);
const filters = reactive({
@@ -112,7 +118,8 @@ const filteredItems = computed(() => {
const sortedItems = computed(() =>
[...filteredItems.value].sort((a, b) => Number(isInactiveSite(a?.site_name)) - Number(isInactiveSite(b?.site_name)))
);
const canWritePrecautions = computed(() => can("precautions.write"));
const canCreatePrecaution = computed(() => can("precautions.create"));
const canDeletePrecaution = computed(() => can("precautions.delete"));
const loadSites = async () => {
const studyId = study.currentStudy?.id;
@@ -144,8 +151,13 @@ const resetFilters = () => {
filters.keyword = "";
};
const goNew = () => router.push("/knowledge/precautions/new");
const goNew = () => {
precautionDrawerVisible.value = true;
};
const goDetail = (id: string) => router.push(`/knowledge/precautions/${id}`);
const handlePrecautionEditorSuccess = () => {
load();
};
const onRowClick = (row: any) => {
if (!row?.id) return;
goDetail(row.id);
@@ -154,6 +166,10 @@ const onRowClick = (row: any) => {
const remove = async (row: any) => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (!canDeletePrecaution.value) {
ElMessage.warning("权限不足");
return;
}
if (isInactiveSite(row?.site_name)) {
ElMessage.warning("中心已停用");
return;
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./ProjectMilestones.vue"), "utf8");
describe("ProjectMilestones project permissions", () => {
it("hides edit controls and blocks saves without project milestone update permission", () => {
const source = readSource();
expect(source).toContain('const canUpdateMilestones = computed(() => can("project.milestones.update"))');
expect(source).toContain('v-if="canUpdateMilestones"');
expect(source).toContain("if (!canUpdateMilestones.value)");
});
});
+19 -4
View File
@@ -102,9 +102,9 @@
<el-table-column prop="remark" :label="TEXT.modules.projectMilestones.columns.remark" show-overflow-tooltip>
<template #default="scope">{{ scope.row.remark || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column :label="TEXT.modules.projectMilestones.columns.operations" width="100">
<el-table-column v-if="canUpdateMilestones" :label="TEXT.modules.projectMilestones.columns.operations" width="100">
<template #default="scope">
<el-button link type="primary" @click="openEditor(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
<el-button v-if="canUpdateMilestones" link type="primary" @click="openEditor(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
</template>
</el-table-column>
<template #empty>
@@ -120,7 +120,8 @@
v-model="editorVisible"
direction="rtl"
size="460px"
:close-on-click-modal="false"
:close-on-click-modal="true"
:before-close="editorDirtyGuard.beforeClose"
:show-close="false"
class="milestone-editor-drawer"
>
@@ -185,7 +186,7 @@
<template #footer>
<div class="time-editor-footer">
<el-button @click="editorVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" @click="saveEditor">{{ TEXT.common.actions.save }}</el-button>
<el-button v-if="canUpdateMilestones" type="primary" @click="saveEditor">{{ TEXT.common.actions.save }}</el-button>
</div>
</template>
</el-drawer>
@@ -200,6 +201,8 @@ import { listProjectMilestones, updateProjectMilestone } from "../../api/project
import { TEXT } from "../../locales";
import StateEmpty from "../../components/StateEmpty.vue";
import { displayDateTime } from "../../utils/display";
import { usePermission } from "../../utils/permission";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
interface MilestoneRow {
id: string;
@@ -231,6 +234,7 @@ type MilestoneLocalEdit = {
};
const study = useStudyStore();
const { can } = usePermission();
const rows = ref<MilestoneRow[]>([]);
const loading = ref(false);
const dataSourceLabel = ref<string>(TEXT.modules.projectMilestones.sourcePublished);
@@ -244,8 +248,10 @@ const editorForm = reactive<MilestoneLocalEdit>({
actualEndDate: "",
remark: "",
});
const editorDirtyGuard = useDrawerDirtyGuard(() => editorForm);
const doneCount = computed(() => rows.value.filter((item) => getStatusView(item).completed).length);
const canUpdateMilestones = computed(() => can("project.milestones.update"));
const toDateOnly = (value?: string): Date | null => {
if (!value) return null;
@@ -398,18 +404,27 @@ const validateDateRange = (start?: string, end?: string, label?: string) => {
};
const openEditor = (row: MilestoneRow) => {
if (!canUpdateMilestones.value) {
ElMessage.warning("权限不足");
return;
}
editingRowId.value = row.id;
editorForm.adjustedStartDate = row.adjustedStartDate || "";
editorForm.adjustedEndDate = row.adjustedEndDate || "";
editorForm.actualStartDate = row.actualStartDate || "";
editorForm.actualEndDate = row.actualEndDate || "";
editorForm.remark = row.remark || "";
editorDirtyGuard.syncBaseline();
editorVisible.value = true;
};
const saveEditor = async () => {
const studyId = study.currentStudy?.id;
if (!studyId || !editingRowId.value) return;
if (!canUpdateMilestones.value) {
ElMessage.warning("权限不足");
return;
}
if (!validateDateRange(editorForm.adjustedStartDate, editorForm.adjustedEndDate, "调整计划时间")) return;
if (!validateDateRange(editorForm.actualStartDate, editorForm.actualEndDate, "实际时间")) return;
const patch = {
@@ -13,7 +13,7 @@ describe("RiskIssueMonitoringVisits permissions", () => {
expect(source).toContain("getProjectRole");
expect(source).toContain("isApiPermissionAllowed");
[
"monitoring_issues:list",
"monitoring_issues:read",
"monitoring_issues:read",
"monitoring_issues:create",
"monitoring_issues:update",
@@ -26,6 +26,9 @@ describe("RiskIssueMonitoringVisits permissions", () => {
expect(source).toContain('v-if="canReadIssue"');
expect(source).toContain('v-if="canUpdateIssue"');
expect(source).toContain('v-if="canDeleteIssue"');
expect(source).toContain('v-if="canSaveIssue"');
expect(source).not.toContain(':disabled="!canSaveIssue"');
expect(source).toContain("const canSaveIssue = computed(() => (formMode.value === \"edit\" ? canUpdateIssue.value : canCreateIssue.value));");
expect(source).toContain('ElMessage.warning("权限不足")');
});
});
@@ -207,7 +207,8 @@
v-model="formDialogVisible"
direction="rtl"
size="580px"
:close-on-click-modal="false"
:close-on-click-modal="true"
:before-close="formDirtyGuard.beforeClose"
:show-close="false"
class="issue-editor-drawer"
>
@@ -411,7 +412,7 @@
<template #footer>
<div class="editor-footer">
<el-button @click="formDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="saving" :disabled="!canSaveIssue" @click="submitForm">{{ TEXT.common.actions.save }}</el-button>
<el-button v-if="canSaveIssue" type="primary" :loading="saving" @click="submitForm">{{ TEXT.common.actions.save }}</el-button>
</div>
</template>
</el-drawer>
@@ -474,6 +475,7 @@ import type { Site } from "../../types/api";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { displayDateTime } from "../../utils/display";
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
interface MonitoringIssueRow {
id: string;
@@ -534,7 +536,7 @@ const canUseApiPermission = (operationKey: string) => {
if (!role) return false;
return isApiPermissionAllowed(study.currentPermissions?.[role]?.[operationKey]);
};
const canListIssues = computed(() => canUseApiPermission("monitoring_issues:list"));
const canListIssues = computed(() => canUseApiPermission("monitoring_issues:read"));
const canReadIssue = computed(() => canUseApiPermission("monitoring_issues:read"));
const canCreateIssue = computed(() => canUseApiPermission("monitoring_issues:create"));
const canUpdateIssue = computed(() => canUseApiPermission("monitoring_issues:update"));
@@ -605,6 +607,7 @@ const createForm = reactive({
closed_date: "",
responsible_name: "",
});
const formDirtyGuard = useDrawerDirtyGuard(() => createForm);
const createRules: FormRules = {
source: [{ required: true, message: "请选择问题来源", trigger: "change" }],
@@ -831,6 +834,7 @@ const openCreateDialog = () => {
resetCreateForm();
formMode.value = "create";
editingIssueId.value = "";
formDirtyGuard.syncBaseline();
formDialogVisible.value = true;
};
@@ -843,6 +847,7 @@ const openEditDialog = async (row: MonitoringIssueRow) => {
formMode.value = "edit";
editingIssueId.value = row.id;
fillFormFromIssue(issue);
formDirtyGuard.syncBaseline();
formDialogVisible.value = true;
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.loadFailed);
@@ -863,7 +868,11 @@ const openViewDialog = async (row: MonitoringIssueRow) => {
const submitForm = async () => {
const studyId = study.currentStudy?.id;
if (!studyId || !canSaveIssue.value) return;
if (!studyId) return;
if (!canSaveIssue.value) {
ElMessage.warning("权限不足");
return;
}
const ok = await createFormRef.value?.validate().catch(() => false);
if (!ok) return;
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./StartupFeasibilityEthics.vue"), "utf8");
describe("StartupFeasibilityEthics project permissions", () => {
it("hides initiation and ethics create/delete actions by their backend operation permissions", () => {
const source = readSource();
expect(source).toContain('canUseApiPermission("startup_initiation:create")');
expect(source).toContain('canUseApiPermission("startup_initiation:read")');
expect(source).toContain('canUseApiPermission("startup_initiation:update")');
expect(source).toContain('canUseApiPermission("startup_initiation:delete")');
expect(source).toContain('canUseApiPermission("startup_ethics:read")');
expect(source).toContain('canUseApiPermission("startup_ethics:create")');
expect(source).toContain('canUseApiPermission("startup_ethics:update")');
expect(source).toContain('canUseApiPermission("startup_ethics:delete")');
expect(source).toContain('v-if="canReadFeasibility"');
expect(source).toContain('v-if="canReadEthics"');
expect(source).toContain("if (!canReadFeasibility.value)");
expect(source).toContain("if (!canReadEthics.value)");
expect(source).toContain('v-if="canCreateFeasibility"');
expect(source).toContain('v-if="canUpdateFeasibility"');
expect(source).toContain('v-if="canDeleteFeasibility"');
expect(source).toContain('v-if="canCreateEthics"');
expect(source).toContain('v-if="canUpdateEthics"');
expect(source).toContain('v-if="canDeleteEthics"');
});
});
describe("StartupFeasibilityEthics drawer editors", () => {
it("opens initiation and ethics create forms in drawers instead of routing to form pages", () => {
const source = readSource();
expect(source).toContain("FeasibilityEditorDrawer");
expect(source).toContain("EthicsEditorDrawer");
expect(source).toContain("feasibilityDrawerVisible");
expect(source).toContain("ethicsDrawerVisible");
expect(source).not.toContain('router.push("/startup/feasibility/new")');
expect(source).not.toContain('router.push("/startup/ethics/new")');
});
it("opens initiation and ethics edit drawers from table action columns", () => {
const source = readSource();
expect(source).toContain(':record-id="editingFeasibilityId"');
expect(source).toContain(':record-id="editingEthicsId"');
expect(source).toContain("const editingFeasibilityId = ref<string | undefined>();");
expect(source).toContain("const editingEthicsId = ref<string | undefined>();");
expect(source).toContain("@click.stop=\"openFeasibilityEditor(scope.row)\"");
expect(source).toContain("@click.stop=\"openEthicsEditor(scope.row)\"");
expect(source).toContain("editingFeasibilityId.value = row?.id;");
expect(source).toContain("editingEthicsId.value = row?.id;");
});
});
@@ -1,13 +1,13 @@
<template>
<div class="page ctms-page-shell page--flush">
<div class="main-content-flat unified-shell">
<el-tabs v-model="activeTab" class="ctms-tabs">
<el-tab-pane :label="TEXT.modules.startupFeasibilityEthics.feasibilityTab" name="feasibility">
<el-tabs v-if="canReadFeasibility || canReadEthics" v-model="activeTab" class="ctms-tabs">
<el-tab-pane v-if="canReadFeasibility" :label="TEXT.modules.startupFeasibilityEthics.feasibilityTab" name="feasibility">
<div class="ctms-tab-content unified-section section--flush">
<div class="ctms-section-header">
<div class="ctms-section-title">{{ TEXT.modules.startupFeasibilityEthics.feasibilityTab }}</div>
<div class="ctms-section-actions">
<el-button type="primary" @click="goNewFeasibility">
<el-button v-if="canCreateFeasibility" type="primary" @click="goNewFeasibility">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.startupFeasibilityEthics.feasibilityNewTitle }}
</el-button>
@@ -47,9 +47,19 @@
<span class="font-mono text-main">{{ row.project_no }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120" align="center">
<el-table-column v-if="canUpdateFeasibility || canDeleteFeasibility" :label="TEXT.common.labels.actions" width="160" align="center">
<template #default="scope">
<el-button
v-if="canUpdateFeasibility"
link
type="primary"
:disabled="isInactiveSite(scope.row.site_id)"
@click.stop="openFeasibilityEditor(scope.row)"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
v-if="canDeleteFeasibility"
link
type="danger"
:disabled="isInactiveSite(scope.row.site_id)"
@@ -65,12 +75,12 @@
</el-table>
</div>
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.startupFeasibilityEthics.ethicsTab" name="ethics">
<el-tab-pane v-if="canReadEthics" :label="TEXT.modules.startupFeasibilityEthics.ethicsTab" name="ethics">
<div class="ctms-tab-content unified-section section--flush">
<div class="ctms-section-header">
<div class="ctms-section-title">{{ TEXT.modules.startupFeasibilityEthics.ethicsTab }}</div>
<div class="ctms-section-actions">
<el-button type="primary" @click="goNewEthics">
<el-button v-if="canCreateEthics" type="primary" @click="goNewEthics">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.startupFeasibilityEthics.ethicsNewTitle }}
</el-button>
@@ -113,9 +123,19 @@
<span class="font-mono text-main">{{ row.approval_no }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120" align="center">
<el-table-column v-if="canUpdateEthics || canDeleteEthics" :label="TEXT.common.labels.actions" width="160" align="center">
<template #default="scope">
<el-button
v-if="canUpdateEthics"
link
type="primary"
:disabled="isInactiveSite(scope.row.site_id)"
@click.stop="openEthicsEditor(scope.row)"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
v-if="canDeleteEthics"
link
type="danger"
:disabled="isInactiveSite(scope.row.site_id)"
@@ -133,14 +153,26 @@
</el-tab-pane>
</el-tabs>
</div>
<FeasibilityEditorDrawer
v-model="feasibilityDrawerVisible"
:record-id="editingFeasibilityId"
@success="handleFeasibilityEditorSuccess"
/>
<EthicsEditorDrawer
v-model="ethicsDrawerVisible"
:record-id="editingEthicsId"
@success="handleEthicsEditorSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { computed, onMounted, ref, watchEffect } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import { listFeasibility, listEthics, deleteFeasibility, deleteEthics } from "../../api/startup";
import { fetchSites } from "../../api/sites";
@@ -148,16 +180,41 @@ import type { Site } from "../../types/api";
import { displayDate } from "../../utils/display";
import dayjs from "dayjs";
import type { StageStatus } from "./project-overview/overview.adapter";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { isSystemAdmin } from "../../utils/roles";
import { TEXT } from "../../locales";
import FeasibilityEditorDrawer from "../startup/FeasibilityEditorDrawer.vue";
import EthicsEditorDrawer from "../startup/EthicsEditorDrawer.vue";
const router = useRouter();
const auth = useAuthStore();
const study = useStudyStore();
const activeTab = ref("feasibility");
const feasibilityItems = ref<any[]>([]);
const ethicsItems = ref<any[]>([]);
const loadingFeasibility = ref(false);
const loadingEthics = ref(false);
const feasibilityDrawerVisible = ref(false);
const ethicsDrawerVisible = ref(false);
const editingFeasibilityId = ref<string | undefined>();
const editingEthicsId = ref<string | undefined>();
const sites = ref<Site[]>([]);
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const isAdmin = computed(() => isSystemAdmin(auth.user));
const canUseApiPermission = (operationKey: string) =>
isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.[operationKey]);
const canReadFeasibility = computed(() => canUseApiPermission("startup_initiation:read"));
const canCreateFeasibility = computed(() => canUseApiPermission("startup_initiation:create"));
const canUpdateFeasibility = computed(() => canUseApiPermission("startup_initiation:update"));
const canDeleteFeasibility = computed(() => canUseApiPermission("startup_initiation:delete"));
const canReadEthics = computed(() => canUseApiPermission("startup_ethics:read"));
const canCreateEthics = computed(() => canUseApiPermission("startup_ethics:create"));
const canUpdateEthics = computed(() => canUseApiPermission("startup_ethics:update"));
const canDeleteEthics = computed(() => canUseApiPermission("startup_ethics:delete"));
const readableTabs = computed(() => [
...(canReadFeasibility.value ? ["feasibility"] : []),
...(canReadEthics.value ? ["ethics"] : []),
]);
const siteMap = computed(() => new Map(sites.value.map((site) => [site.id, site.name])));
const siteActiveMap = computed(() => new Map(sites.value.map((site) => [site.id, !!site.is_active])));
@@ -237,6 +294,10 @@ const loadSites = async () => {
const loadFeasibility = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (!canReadFeasibility.value) {
feasibilityItems.value = [];
return;
}
loadingFeasibility.value = true;
try {
const { data } = await listFeasibility(studyId) as any;
@@ -251,6 +312,10 @@ const loadFeasibility = async () => {
const loadEthics = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (!canReadEthics.value) {
ethicsItems.value = [];
return;
}
loadingEthics.value = true;
try {
const { data } = await listEthics(studyId) as any;
@@ -262,11 +327,47 @@ const loadEthics = async () => {
}
};
const goNewFeasibility = () => router.push("/startup/feasibility/new");
const goFeasibilityDetail = (id: string) => router.push(`/startup/feasibility/${id}`);
const goNewFeasibility = () => {
editingFeasibilityId.value = undefined;
feasibilityDrawerVisible.value = true;
};
const openFeasibilityEditor = (row: any) => {
if (!canUpdateFeasibility.value) return;
if (isInactiveSite(row?.site_id)) {
ElMessage.warning("中心已停用");
return;
}
editingFeasibilityId.value = row?.id;
feasibilityDrawerVisible.value = true;
};
const goFeasibilityDetail = (id: string) => {
if (!canReadFeasibility.value) return;
router.push(`/startup/feasibility/${id}`);
};
const goNewEthics = () => router.push("/startup/ethics/new");
const goEthicsDetail = (id: string) => router.push(`/startup/ethics/${id}`);
const goNewEthics = () => {
editingEthicsId.value = undefined;
ethicsDrawerVisible.value = true;
};
const openEthicsEditor = (row: any) => {
if (!canUpdateEthics.value) return;
if (isInactiveSite(row?.site_id)) {
ElMessage.warning("中心已停用");
return;
}
editingEthicsId.value = row?.id;
ethicsDrawerVisible.value = true;
};
const goEthicsDetail = (id: string) => {
if (!canReadEthics.value) return;
router.push(`/startup/ethics/${id}`);
};
const handleFeasibilityEditorSuccess = () => {
loadFeasibility();
};
const handleEthicsEditorSuccess = () => {
loadEthics();
};
const onFeasibilityRowClick = (row: any) => goFeasibilityDetail(row.id);
const onEthicsRowClick = (row: any) => goEthicsDetail(row.id);
const feasibilityRowClass = ({ row }: { row: any }) =>
@@ -277,6 +378,7 @@ const ethicsRowClass = ({ row }: { row: any }) =>
const removeFeasibility = async (row: any) => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (!canDeleteFeasibility.value) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
try {
@@ -291,6 +393,7 @@ const removeFeasibility = async (row: any) => {
const removeEthics = async (row: any) => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (!canDeleteEthics.value) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
try {
@@ -307,6 +410,12 @@ onMounted(() => {
loadFeasibility();
loadEthics();
});
watchEffect(() => {
if (readableTabs.value.length > 0 && !readableTabs.value.includes(activeTab.value)) {
activeTab.value = readableTabs.value[0];
}
});
</script>
<style scoped>
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSubjectManagementSource = () => readFileSync(resolve(__dirname, "./SubjectManagement.vue"), "utf8");
describe("SubjectManagement project permissions", () => {
it("hides create and delete actions when the current role lacks backend operation permissions", () => {
const source = readSubjectManagementSource();
expect(source).toContain("isSystemAdmin");
expect(source).toContain("isApiPermissionAllowed");
expect(source).toContain("canUseApiPermission");
expect(source).toContain('canUseApiPermission("subjects:create")');
expect(source).toContain('canUseApiPermission("subjects:delete")');
expect(source).toContain('v-if="canCreateSubject"');
expect(source).toContain('v-if="canDeleteSubject"');
});
});
describe("SubjectManagement drawer editor", () => {
it("opens the participant create form in a drawer instead of routing to a form page", () => {
const source = readSubjectManagementSource();
expect(source).toContain("SubjectEditorDrawer");
expect(source).toContain("subjectDrawerVisible");
expect(source).not.toContain('router.push("/subjects/new")');
});
});
+28 -3
View File
@@ -48,7 +48,7 @@
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</div>
<div class="filter-spacer"></div>
<el-button type="primary" @click="goNew" class="header-action-btn">
<el-button v-if="canCreateSubject" type="primary" @click="goNew" class="header-action-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }}
</el-button>
@@ -93,9 +93,10 @@
<el-table-column prop="completion_date" :label="TEXT.common.fields.completionDate">
<template #default="scope">{{ displayDate(scope.row.completion_date) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120">
<el-table-column v-if="canDeleteSubject" :label="TEXT.common.labels.actions" width="120">
<template #default="scope">
<el-button
v-if="canDeleteSubject"
link
type="danger"
size="small"
@@ -122,6 +123,10 @@
</div>
</div>
</div>
<SubjectEditorDrawer
v-model="subjectDrawerVisible"
@success="handleSubjectEditorSuccess"
/>
</div>
</template>
@@ -130,15 +135,21 @@ import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Search, Location, CircleCheck, Plus } from "@element-plus/icons-vue";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import { deleteSubject, fetchSubjects } from "../../api/subjects";
import { fetchSites } from "../../api/sites";
import { displayDate, displayEnum } from "../../utils/display";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { isSystemAdmin } from "../../utils/roles";
import { TEXT } from "../../locales";
import SubjectEditorDrawer from "../subjects/SubjectEditorDrawer.vue";
const router = useRouter();
const auth = useAuthStore();
const study = useStudyStore();
const loading = ref(false);
const subjectDrawerVisible = ref(false);
const items = ref<any[]>([]);
const siteOptions = ref<Array<{ id: string; name: string }>>([]);
const siteMap = ref<Record<string, string>>({});
@@ -158,6 +169,14 @@ const resetFilters = () => {
filters.value.siteId = "";
filters.value.status = "";
};
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const canUseApiPermission = (operationKey: string) => {
if (isSystemAdmin(auth.user)) return true;
if (!projectRole.value) return false;
return isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.[operationKey]);
};
const canCreateSubject = computed(() => canUseApiPermission("subjects:create"));
const canDeleteSubject = computed(() => canUseApiPermission("subjects:delete"));
const loadSites = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
@@ -193,8 +212,13 @@ const load = async () => {
}
};
const goNew = () => router.push("/subjects/new");
const goNew = () => {
subjectDrawerVisible.value = true;
};
const goDetail = (id: string) => router.push(`/subjects/${id}`);
const handleSubjectEditorSuccess = () => {
load();
};
const onRowClick = (row: any) => {
if (!row?.id) return;
goDetail(row.id);
@@ -202,6 +226,7 @@ const onRowClick = (row: any) => {
const remove = async (row: any) => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (!canDeleteSubject.value) return;
if (isInactiveSite(row?.site_id)) {
ElMessage.warning("中心已停用");
return;
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./PrecautionDetail.vue"), "utf8");
describe("PrecautionDetail project permissions", () => {
it("hides edit controls unless the backend update operation is allowed", () => {
const source = readSource();
expect(source).toContain('const canUpdatePrecaution = computed(() => can("precautions.update"))');
expect(source).toContain('v-if="canUpdatePrecaution"');
expect(source).toContain("if (!canUpdatePrecaution.value)");
expect(source).not.toContain("canWritePrecautions");
});
});
describe("PrecautionDetail drawer editor", () => {
it("opens edit in the shared drawer editor instead of routing to the form page", () => {
const source = readSource();
expect(source).toContain("PrecautionEditorDrawer");
expect(source).toContain("precautionEditorVisible");
expect(source).toContain("@success=\"handlePrecautionEditorSuccess\"");
expect(source).not.toContain("router.push(`/knowledge/precautions/${precautionId}/edit`)");
});
});
@@ -6,8 +6,7 @@
<p class="page-subtitle">{{ TEXT.modules.knowledgeNotes.detailSubtitle }}</p>
</div>
<div class="actions">
<el-button type="primary" :disabled="isReadOnly" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
<el-button v-if="canUpdatePrecaution" type="primary" :disabled="isInactiveSite" @click="openEditor">{{ TEXT.common.actions.edit }}</el-button>
</div>
</div>
@@ -26,13 +25,20 @@
entity-type="precaution"
:entity-id="precautionId"
:readonly="isReadOnly"
:hide-uploader="true"
:refresh-key="attachmentRefreshKey"
/>
<PrecautionEditorDrawer
v-model="precautionEditorVisible"
:precaution-id="precautionId"
@success="handlePrecautionEditorSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useRoute } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { getPrecaution } from "../../api/precautions";
@@ -40,12 +46,14 @@ import { fetchSites } from "../../api/sites";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { usePermission } from "../../utils/permission";
import { TEXT } from "../../locales";
import PrecautionEditorDrawer from "./PrecautionEditorDrawer.vue";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const { can } = usePermission();
const loading = ref(false);
const precautionEditorVisible = ref(false);
const attachmentRefreshKey = ref(0);
const precautionId = route.params.id as string;
const studyId = study.currentStudy?.id || "";
const sites = ref<any[]>([]);
@@ -64,8 +72,9 @@ const siteActiveMap = computed(() => {
});
return map;
});
const canWritePrecautions = computed(() => can("precautions.write"));
const isReadOnly = computed(() => !canWritePrecautions.value || (!!detail.site_name && siteActiveMap.value[detail.site_name] === false));
const canUpdatePrecaution = computed(() => can("precautions.update"));
const isInactiveSite = computed(() => !!detail.site_name && siteActiveMap.value[detail.site_name] === false);
const isReadOnly = computed(() => !canUpdatePrecaution.value || isInactiveSite.value);
const loadSites = async () => {
if (!studyId) return;
@@ -83,6 +92,10 @@ const load = async () => {
try {
const { data } = await getPrecaution(studyId, precautionId);
Object.assign(detail, data);
study.setViewContext({
siteName: detail.site_name || TEXT.common.fallback,
pageTitle: detail.title || TEXT.modules.knowledgeNotes.detailTitle,
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
@@ -90,14 +103,22 @@ const load = async () => {
}
};
const goEdit = () => {
if (isReadOnly.value) {
ElMessage.warning(canWritePrecautions.value ? "中心已停用" : "权限不足");
const openEditor = () => {
if (!canUpdatePrecaution.value) {
ElMessage.warning("权限不足");
return;
}
router.push(`/knowledge/precautions/${precautionId}/edit`);
if (isInactiveSite.value) {
ElMessage.warning("中心已停用");
return;
}
precautionEditorVisible.value = true;
};
const handlePrecautionEditorSuccess = async () => {
await load();
attachmentRefreshKey.value += 1;
};
const goBack = () => router.push("/knowledge/precautions");
onMounted(async () => {
await loadSites();
@@ -0,0 +1,265 @@
<template>
<el-drawer
v-if="modelValue"
:model-value="modelValue"
direction="rtl"
size="620px"
:close-on-click-modal="true"
:before-close="drawerDirtyGuard.beforeClose"
:show-close="false"
class="precaution-editor-drawer"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<div class="editor-header">
<div class="editor-title">{{ precautionId ? TEXT.modules.knowledgeNotes.editTitle : TEXT.modules.knowledgeNotes.newTitle }}</div>
</div>
</template>
<el-form :model="form" label-position="top" class="precaution-editor-form">
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-basic"></span>
{{ TEXT.modules.knowledgeNotes.title }}
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.site" required>
<el-select v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select" filterable class="full-width">
<el-option
v-for="site in sites"
:key="site.id || site.name"
:label="site.name"
:value="site.name"
:disabled="site.is_active === false"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.level">
<el-select v-model="form.level" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select" clearable class="full-width">
<el-option v-for="level in levelOptions" :key="level.value" :label="level.label" :value="level.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="TEXT.common.fields.title" required>
<el-input v-model="form.title" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.title" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.content" required>
<el-input v-model="form.content" :disabled="isReadOnly" type="textarea" :rows="4" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
</div>
<div class="form-group attachment-form-group">
<div class="form-group-title">
<span class="group-dot group-dot-attachment"></span>
{{ TEXT.common.labels.attachments }}
</div>
<AttachmentList
ref="attachmentPanelRef"
:study-id="studyId"
:entity-type="'precaution'"
:entity-id="precautionId || ''"
:mode="'upload'"
:readonly="isReadOnly"
/>
</div>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="emit('update:modelValue', false)">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
</div>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { createPrecaution, getPrecaution, updatePrecaution } from "../../api/precautions";
import { fetchSites } from "../../api/sites";
import { useStudyStore } from "../../store/study";
import { usePermission } from "../../utils/permission";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { TEXT } from "../../locales";
import { precautionLevelOptions } from "./precautionOptions";
const props = defineProps<{ modelValue: boolean; precautionId?: string }>();
const emit = defineEmits<{
(event: "update:modelValue", value: boolean): void;
(event: "success", id: string): void;
}>();
const study = useStudyStore();
const { can } = usePermission();
const saving = ref(false);
const sites = ref<any[]>([]);
const levelOptions = precautionLevelOptions;
const studyId = computed(() => study.currentStudy?.id || "");
const isEdit = computed(() => !!props.precautionId);
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
const form = reactive({
site_name: "",
title: "",
level: "",
content: "",
});
const siteActiveMap = computed(() => new Map(sites.value.map((site) => [site.name, !!site.is_active])));
const canCreatePrecaution = computed(() => can("precautions.create"));
const canUpdatePrecaution = computed(() => can("precautions.update"));
const canSavePrecaution = computed(() => (isEdit.value ? canUpdatePrecaution.value : canCreatePrecaution.value));
const isInactiveSite = computed(() => isEdit.value && !!form.site_name && siteActiveMap.value.get(form.site_name) === false);
const isReadOnly = computed(() => !canSavePrecaution.value || isInactiveSite.value);
const drawerDirtyGuard = useDrawerDirtyGuard(() => ({
form,
attachments: attachmentPanelRef.value?.pendingSnapshot() || [],
}));
const reset = () => {
form.site_name = "";
form.title = "";
form.level = "";
form.content = "";
};
const loadSites = async () => {
if (!studyId.value) return;
try {
const { data } = await fetchSites(studyId.value, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const load = async () => {
reset();
await loadSites();
if (!isEdit.value || !studyId.value || !props.precautionId) {
drawerDirtyGuard.syncBaseline();
return;
}
try {
const { data } = await getPrecaution(studyId.value, props.precautionId);
Object.assign(form, {
site_name: data.site_name || "",
title: data.title || "",
level: data.level || "",
content: data.content || "",
});
drawerDirtyGuard.syncBaseline();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
}
};
const submit = async () => {
if (!studyId.value) return;
if (!canSavePrecaution.value) {
ElMessage.warning("权限不足");
return;
}
if (isReadOnly.value || siteActiveMap.value.get(form.site_name) === false) {
ElMessage.warning("中心已停用");
return;
}
if (!form.site_name) {
ElMessage.warning(TEXT.common.messages.required);
return;
}
saving.value = true;
try {
const payload = {
site_name: form.site_name,
title: form.title,
level: form.level || null,
content: form.content,
};
let id = props.precautionId || "";
if (props.precautionId) {
await updatePrecaution(studyId.value, props.precautionId, payload);
} else {
const { data } = await createPrecaution(studyId.value, payload);
id = data.id;
}
await attachmentPanelRef.value?.uploadPending(id);
ElMessage.success(props.precautionId ? TEXT.common.messages.saveSuccess : TEXT.common.messages.createSuccess);
drawerDirtyGuard.syncBaseline();
emit("success", id);
emit("update:modelValue", false);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
watch(
() => [props.modelValue, props.precautionId, studyId.value] as const,
([visible]) => {
if (visible) load();
},
{ immediate: true }
);
</script>
<style scoped>
.editor-header {
display: flex;
align-items: center;
}
.editor-title {
color: var(--ctms-text-main);
font-size: 18px;
font-weight: 700;
}
.precaution-editor-form {
padding: 0 4px;
}
.form-group {
padding: 16px 0;
}
.form-group-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
color: var(--ctms-text-main);
font-size: 15px;
font-weight: 700;
}
.group-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.group-dot-basic {
background: var(--ctms-primary);
}
.group-dot-attachment {
background: #22c55e;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.full-width {
width: 100%;
}
</style>
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./PrecautionForm.vue"), "utf8");
describe("PrecautionForm project permissions", () => {
it("uses create permission for new records and update permission for edits", () => {
const source = readSource();
expect(source).toContain('const canCreatePrecaution = computed(() => can("precautions.create"))');
expect(source).toContain('const canUpdatePrecaution = computed(() => can("precautions.update"))');
expect(source).toContain("const canSavePrecaution = computed(() => (isEdit.value ? canUpdatePrecaution.value : canCreatePrecaution.value))");
expect(source).toContain("if (!canSavePrecaution.value)");
expect(source).not.toContain("canWritePrecautions");
});
it("uses selects for site and level fields", () => {
const source = readSource();
expect(source).toContain('<el-select v-model="form.site_name"');
expect(source).toContain('v-for="site in sites"');
expect(source).toContain(':value="site.name"');
expect(source).toContain('<el-select v-model="form.level"');
expect(source).toContain("const levelOptions = precautionLevelOptions");
expect(source).toContain('v-for="level in levelOptions"');
expect(source).not.toContain('<el-input v-model="form.site_name"');
expect(source).not.toContain('<el-input v-model="form.level"');
});
it("allows attachments to be selected before creating a precaution", () => {
const source = readSource();
expect(source).toContain("<AttachmentList");
expect(source).toContain('ref="attachmentPanelRef"');
expect(source).toContain('entity-type="precaution"');
expect(source).toContain(':entity-id="precautionId || \'\'"');
expect(source).toContain(':mode="\'upload\'"');
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(savedId)");
expect(source).not.toContain('v-if="isEdit && precautionId"');
expect(source).not.toContain("StateEmpty");
});
});
+34 -17
View File
@@ -11,13 +11,23 @@
<el-card class="unified-shell">
<el-form :model="form" label-width="120px">
<el-form-item :label="TEXT.common.fields.site" required>
<el-input v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
<el-select v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select" filterable class="full-width">
<el-option
v-for="site in sites"
:key="site.id || site.name"
:label="site.name"
:value="site.name"
:disabled="site.is_active === false"
/>
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.title" required>
<el-input v-model="form.title" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.title" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.level">
<el-input v-model="form.level" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input" />
<el-select v-model="form.level" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select" clearable class="full-width">
<el-option v-for="level in levelOptions" :key="level.value" :label="level.label" :value="level.value" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.content" required>
<el-input v-model="form.content" :disabled="isReadOnly" type="textarea" :rows="4" :placeholder="TEXT.common.placeholders.input" />
@@ -30,15 +40,13 @@
</el-card>
<AttachmentList
v-if="isEdit && precautionId"
ref="attachmentPanelRef"
:study-id="studyId"
entity-type="precaution"
:entity-id="precautionId"
:entity-id="precautionId || ''"
:readonly="isReadOnly"
:mode="'upload'"
/>
<el-card v-else class="hint-card unified-shell">
<StateEmpty :description="TEXT.modules.knowledgeNotes.uploadHint" />
</el-card>
</div>
</template>
@@ -50,16 +58,18 @@ import { useStudyStore } from "../../store/study";
import { createPrecaution, getPrecaution, updatePrecaution } from "../../api/precautions";
import { fetchSites } from "../../api/sites";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue";
import { usePermission } from "../../utils/permission";
import { TEXT } from "../../locales";
import { precautionLevelOptions } from "./precautionOptions";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const { can } = usePermission();
const saving = ref(false);
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
const sites = ref<any[]>([]);
const levelOptions = precautionLevelOptions;
const precautionId = computed(() => route.params.id as string | undefined);
const isEdit = computed(() => !!precautionId.value);
@@ -79,8 +89,10 @@ const siteActiveMap = computed(() => {
});
return map;
});
const canWritePrecautions = computed(() => can("precautions.write"));
const isReadOnly = computed(() => !canWritePrecautions.value || (isEdit.value && !!form.site_name && siteActiveMap.value[form.site_name] === false));
const canCreatePrecaution = computed(() => can("precautions.create"));
const canUpdatePrecaution = computed(() => can("precautions.update"));
const canSavePrecaution = computed(() => (isEdit.value ? canUpdatePrecaution.value : canCreatePrecaution.value));
const isReadOnly = computed(() => !canSavePrecaution.value || (isEdit.value && !!form.site_name && siteActiveMap.value[form.site_name] === false));
const loadSites = async () => {
if (!studyId.value) return;
@@ -109,8 +121,12 @@ const load = async () => {
const submit = async () => {
if (!studyId.value) return;
if (!canSavePrecaution.value) {
ElMessage.warning("权限不足");
return;
}
if (isReadOnly.value) {
ElMessage.warning(canWritePrecautions.value ? "中心已停用" : "权限不足");
ElMessage.warning("中心已停用");
return;
}
if (!form.site_name) {
@@ -129,15 +145,16 @@ const submit = async () => {
level: form.level || null,
content: form.content,
};
let savedId = precautionId.value || "";
if (isEdit.value && precautionId.value) {
await updatePrecaution(studyId.value, precautionId.value, payload);
ElMessage.success(TEXT.common.messages.saveSuccess);
router.push(`/knowledge/precautions/${precautionId.value}`);
} else {
const { data } = await createPrecaution(studyId.value, payload);
ElMessage.success(TEXT.common.messages.createSuccess);
router.push(`/knowledge/precautions/${data.id}`);
savedId = data.id;
}
await attachmentPanelRef.value?.uploadPending(savedId);
ElMessage.success(isEdit.value ? TEXT.common.messages.saveSuccess : TEXT.common.messages.createSuccess);
router.push(`/knowledge/precautions/${savedId}`);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
@@ -178,7 +195,7 @@ onMounted(async () => {
color: var(--ctms-text-secondary);
}
.hint-card {
padding: 12px;
.full-width {
width: 100%;
}
</style>
@@ -0,0 +1,5 @@
export const precautionLevelOptions = [
{ label: "低", value: "低" },
{ label: "中", value: "中" },
{ label: "高", value: "高" },
];
+31 -14
View File
@@ -5,13 +5,9 @@
<div class="unified-section-header">
<div class="ctms-section-title">{{ TEXT.modules.startupFeasibilityEthics.ethicsDetailTitle }}</div>
<div class="ctms-section-actions">
<el-button type="primary" :disabled="isInactiveSite(detail.site_id)" @click="goEdit">
<el-button v-if="canUpdateEthics" type="primary" :disabled="isInactiveSite(detail.site_id)" @click="openEditor">
{{ TEXT.common.actions.edit }}
</el-button>
<el-button @click="goBack">
<el-icon class="el-icon--left"><ArrowLeft /></el-icon>
{{ TEXT.common.actions.back }}
</el-button>
</div>
</div>
<el-descriptions :column="2" border class="ctms-descriptions">
@@ -33,17 +29,22 @@
entity-type="startup_ethics"
:entity-id="recordId"
:readonly="isInactiveSite(detail.site_id)"
:hide-uploader="true"
/>
</section>
</div>
<EthicsEditorDrawer
v-model="editorVisible"
:record-id="recordId"
@success="handleEditorSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useRoute } from "vue-router";
import { ElMessage } from "element-plus";
import { ArrowLeft } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import { useStudyStore } from "../../store/study";
import { getEthics } from "../../api/startup";
@@ -53,11 +54,14 @@ import { displayDate } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import type { StageStatus } from "../ia/project-overview/overview.adapter";
import { TEXT } from "../../locales";
import { usePermission } from "../../utils/permission";
import EthicsEditorDrawer from "./EthicsEditorDrawer.vue";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const { can } = usePermission();
const loading = ref(false);
const editorVisible = ref(false);
const recordId = route.params.recordId as string;
const studyId = study.currentStudy?.id || "";
const sites = ref<Site[]>([]);
@@ -66,6 +70,7 @@ const siteMap = computed(() => new Map(sites.value.map((site) => [site.id, site.
const siteActiveMap = computed(() => new Map(sites.value.map((site) => [site.id, !!site.is_active])));
const displaySite = (siteId?: string) => siteMap.value.get(siteId || "") || TEXT.common.fallback;
const isInactiveSite = (siteId?: string) => Boolean(siteId) && siteActiveMap.value.get(siteId || "") === false;
const canUpdateEthics = computed(() => can("startup.ethics.update"));
const detail = reactive<any>({
site_id: "",
@@ -134,6 +139,12 @@ const load = async () => {
try {
const { data } = await getEthics(studyId, recordId);
Object.assign(detail, data);
const siteName = displaySite(detail.site_id);
const hasSiteName = siteName !== TEXT.common.fallback;
study.setViewContext({
siteName,
pageTitle: detail.approval_no || (hasSiteName ? siteName : TEXT.modules.startupFeasibilityEthics.ethicsDetailTitle),
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
@@ -141,18 +152,24 @@ const load = async () => {
}
};
const goEdit = () => {
const openEditor = () => {
if (!canUpdateEthics.value) {
ElMessage.warning("权限不足");
return;
}
if (isInactiveSite(detail.site_id)) {
ElMessage.warning("中心已停用");
return;
}
router.push(`/startup/ethics/${recordId}/edit`);
editorVisible.value = true;
};
const goBack = () => router.push("/startup/feasibility-ethics");
onMounted(() => {
loadSites();
load();
const handleEditorSuccess = async () => {
await load();
};
onMounted(async () => {
await loadSites();
await load();
});
</script>
@@ -0,0 +1,277 @@
<template>
<el-drawer
v-if="modelValue"
:model-value="modelValue"
direction="rtl"
size="620px"
:close-on-click-modal="true"
:before-close="drawerDirtyGuard.beforeClose"
:show-close="false"
class="ethics-editor-drawer"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<div class="editor-header">
<div class="editor-title">{{ recordId ? TEXT.modules.startupFeasibilityEthics.ethicsEditTitle : TEXT.modules.startupFeasibilityEthics.ethicsNewTitle }}</div>
</div>
</template>
<el-form :model="form" label-position="top" class="startup-editor-form">
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-basic"></span>
{{ TEXT.modules.startupFeasibilityEthics.ethicsTab }}
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.site">
<el-select v-model="form.site_id" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select" clearable class="full-width">
<el-option v-for="site in siteOptions" :key="site.value" :label="site.label" :value="site.value" :disabled="site.disabled" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.approvalNo">
<el-input v-model="form.approval_no" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.approvalNo" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.submitDate">
<el-date-picker v-model="form.submit_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" class="full-width" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.acceptDate">
<el-date-picker v-model="form.accept_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" class="full-width" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.meetingDate">
<el-date-picker v-model="form.meeting_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" class="full-width" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.approvedDate">
<el-date-picker v-model="form.approved_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" class="full-width" />
</el-form-item>
</el-col>
</el-row>
</div>
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-attachment"></span>
{{ TEXT.common.labels.attachments }}
</div>
<AttachmentList
ref="attachmentPanelRef"
:study-id="studyId"
entity-type="startup_ethics"
:entity-id="recordId || ''"
:mode="'upload'"
:readonly="isReadOnly"
/>
</div>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="emit('update:modelValue', false)">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
</div>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { createEthics, getEthics, updateEthics } from "../../api/startup";
import { fetchSites } from "../../api/sites";
import { useStudyStore } from "../../store/study";
import type { Site } from "../../types/api";
import { TEXT } from "../../locales";
import { usePermission } from "../../utils/permission";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
const props = defineProps<{ modelValue: boolean; recordId?: string }>();
const emit = defineEmits<{
(event: "update:modelValue", value: boolean): void;
(event: "success", id: string): void;
}>();
const study = useStudyStore();
const { can } = usePermission();
const saving = ref(false);
const sites = ref<Site[]>([]);
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
const studyId = computed(() => study.currentStudy?.id || "");
const isEdit = computed(() => !!props.recordId);
const form = reactive({
site_id: "",
submit_date: "",
accept_date: "",
meeting_date: "",
approved_date: "",
approval_no: "",
});
const siteOptions = computed(() =>
sites.value.map((site) => ({ value: site.id, label: site.name || TEXT.common.fallback, disabled: !site.is_active }))
);
const siteActiveMap = computed(() => new Map(sites.value.map((site) => [site.id, !!site.is_active])));
const canCreateEthics = computed(() => can("startup.ethics.create"));
const canUpdateEthics = computed(() => can("startup.ethics.update"));
const canSaveEthics = computed(() => (isEdit.value ? canUpdateEthics.value : canCreateEthics.value));
const isInactiveSite = computed(() => isEdit.value && !!form.site_id && siteActiveMap.value.get(form.site_id) === false);
const isReadOnly = computed(() => !canSaveEthics.value || isInactiveSite.value);
const drawerDirtyGuard = useDrawerDirtyGuard(() => ({
form,
attachments: attachmentPanelRef.value?.pendingSnapshot() || [],
}));
const reset = () => {
form.site_id = "";
form.submit_date = "";
form.accept_date = "";
form.meeting_date = "";
form.approved_date = "";
form.approval_no = "";
};
const loadSites = async () => {
if (!studyId.value) return;
try {
const { data } = await fetchSites(studyId.value, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
}
};
const load = async () => {
reset();
await loadSites();
if (!isEdit.value || !studyId.value || !props.recordId) {
drawerDirtyGuard.syncBaseline();
return;
}
try {
const { data } = await getEthics(studyId.value, props.recordId);
Object.assign(form, {
site_id: data.site_id || "",
submit_date: data.submit_date || "",
accept_date: data.accept_date || "",
meeting_date: data.meeting_date || "",
approved_date: data.approved_date || "",
approval_no: data.approval_no || "",
});
drawerDirtyGuard.syncBaseline();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
}
};
const submit = async () => {
if (!studyId.value) return;
if (!canSaveEthics.value) {
ElMessage.warning("权限不足");
return;
}
if (isInactiveSite.value || (form.site_id && siteActiveMap.value.get(form.site_id) === false)) {
ElMessage.warning("中心已停用");
return;
}
saving.value = true;
try {
const payload = {
site_id: form.site_id || null,
submit_date: form.submit_date || null,
accept_date: form.accept_date || null,
meeting_date: form.meeting_date || null,
approved_date: form.approved_date || null,
approval_no: form.approval_no || null,
};
const id = props.recordId || (await createEthics(studyId.value, payload)).data.id;
if (props.recordId) {
await updateEthics(studyId.value, props.recordId, payload);
}
await attachmentPanelRef.value?.uploadPending(id);
ElMessage.success(props.recordId ? TEXT.common.messages.saveSuccess : TEXT.common.messages.createSuccess);
drawerDirtyGuard.syncBaseline();
emit("success", id);
emit("update:modelValue", false);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
watch(
() => [props.modelValue, props.recordId, studyId.value] as const,
([visible]) => {
if (visible) load();
},
{ immediate: true }
);
</script>
<style scoped>
.editor-header {
display: flex;
align-items: center;
}
.editor-title {
color: var(--ctms-text-main);
font-size: 18px;
font-weight: 700;
}
.startup-editor-form {
padding: 0 4px;
}
.form-group {
padding: 16px 0;
}
.form-group-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
color: var(--ctms-text-main);
font-size: 15px;
font-weight: 700;
}
.group-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.group-dot-basic {
background: var(--ctms-primary);
}
.group-dot-attachment {
background: #22c55e;
}
.full-width {
width: 100%;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>
+29 -19
View File
@@ -85,18 +85,17 @@
</el-form>
</section>
<section class="unified-section">
<template v-if="isEdit && recordId">
<div class="ctms-section-header">
<div class="ctms-section-title">{{ TEXT.common.labels.attachments }}</div>
</div>
<AttachmentList
:study-id="studyId"
entity-type="startup_ethics"
:entity-id="recordId"
:readonly="isReadOnly"
/>
</template>
<StateEmpty v-else :description="TEXT.modules.startupFeasibilityEthics.ethicsUploadHint" />
<div class="ctms-section-header">
<div class="ctms-section-title">{{ TEXT.common.labels.attachments }}</div>
</div>
<AttachmentList
ref="attachmentPanelRef"
:study-id="studyId"
entity-type="startup_ethics"
:entity-id="recordId || ''"
:readonly="isReadOnly"
:mode="'upload'"
/>
</section>
</div>
</div>
@@ -112,13 +111,15 @@ import { createEthics, getEthics, updateEthics } from "../../api/startup";
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<InstanceType<typeof AttachmentList> | null>(null);
const recordId = computed(() => route.params.recordId as string | undefined);
const isEdit = computed(() => !!recordId.value);
@@ -135,7 +136,11 @@ const siteActiveMap = computed(() => {
});
return map;
});
const isReadOnly = computed(() => isEdit.value && !!form.site_id && siteActiveMap.value[form.site_id] === false);
const canCreateEthics = computed(() => can("startup.ethics.create"));
const canUpdateEthics = computed(() => can("startup.ethics.update"));
const canSaveEthics = computed(() => (isEdit.value ? canUpdateEthics.value : canCreateEthics.value));
const isInactiveSite = computed(() => isEdit.value && !!form.site_id && siteActiveMap.value[form.site_id] === false);
const isReadOnly = computed(() => !canSaveEthics.value || isInactiveSite.value);
const form = reactive({
site_id: "",
@@ -175,7 +180,11 @@ const load = async () => {
const submit = async () => {
if (!studyId.value) return;
if (isReadOnly.value) {
if (!canSaveEthics.value) {
ElMessage.warning("权限不足");
return;
}
if (isInactiveSite.value) {
ElMessage.warning("中心已停用");
return;
}
@@ -193,15 +202,16 @@ const submit = async () => {
approved_date: form.approved_date || null,
approval_no: form.approval_no || null,
};
let savedId = recordId.value || "";
if (isEdit.value && recordId.value) {
await updateEthics(studyId.value, recordId.value, payload);
ElMessage.success(TEXT.common.messages.saveSuccess);
router.push(`/startup/ethics/${recordId.value}`);
} else {
const { data } = await createEthics(studyId.value, payload);
ElMessage.success(TEXT.common.messages.createSuccess);
router.push(`/startup/ethics/${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/ethics/${savedId}`);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
@@ -5,13 +5,9 @@
<div class="unified-section-header">
<div class="ctms-section-title">{{ TEXT.modules.startupFeasibilityEthics.feasibilityDetailTitle }}</div>
<div class="ctms-section-actions">
<el-button type="primary" :disabled="isInactiveSite(detail.site_id)" @click="goEdit">
<el-button v-if="canUpdateInitiation" type="primary" :disabled="isInactiveSite(detail.site_id)" @click="openEditor">
{{ TEXT.common.actions.edit }}
</el-button>
<el-button @click="goBack">
<el-icon class="el-icon--left"><ArrowLeft /></el-icon>
{{ TEXT.common.actions.back }}
</el-button>
</div>
</div>
<el-descriptions :column="2" border class="ctms-descriptions">
@@ -32,17 +28,22 @@
entity-type="startup_feasibility"
:entity-id="recordId"
:readonly="isInactiveSite(detail.site_id)"
:hide-uploader="true"
/>
</section>
</div>
<FeasibilityEditorDrawer
v-model="editorVisible"
:record-id="recordId"
@success="handleEditorSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useRoute } from "vue-router";
import { ElMessage } from "element-plus";
import { ArrowLeft } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import { useStudyStore } from "../../store/study";
import { getFeasibility } from "../../api/startup";
@@ -52,11 +53,14 @@ import { displayDate } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import type { StageStatus } from "../ia/project-overview/overview.adapter";
import { TEXT } from "../../locales";
import { usePermission } from "../../utils/permission";
import FeasibilityEditorDrawer from "./FeasibilityEditorDrawer.vue";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const { can } = usePermission();
const loading = ref(false);
const editorVisible = ref(false);
const recordId = route.params.recordId as string;
const studyId = study.currentStudy?.id || "";
const sites = ref<Site[]>([]);
@@ -65,6 +69,7 @@ const siteMap = computed(() => new Map(sites.value.map((site) => [site.id, site.
const siteActiveMap = computed(() => new Map(sites.value.map((site) => [site.id, !!site.is_active])));
const displaySite = (siteId?: string) => siteMap.value.get(siteId || "") || TEXT.common.fallback;
const isInactiveSite = (siteId?: string) => Boolean(siteId) && siteActiveMap.value.get(siteId || "") === false;
const canUpdateInitiation = computed(() => can("startup.initiation.update"));
const detail = reactive<any>({
site_id: "",
@@ -132,6 +137,12 @@ const load = async () => {
try {
const { data } = await getFeasibility(studyId, recordId);
Object.assign(detail, data);
const siteName = displaySite(detail.site_id);
const hasSiteName = siteName !== TEXT.common.fallback;
study.setViewContext({
siteName,
pageTitle: detail.project_no || (hasSiteName ? siteName : TEXT.modules.startupFeasibilityEthics.feasibilityDetailTitle),
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
@@ -139,18 +150,24 @@ const load = async () => {
}
};
const goEdit = () => {
const openEditor = () => {
if (!canUpdateInitiation.value) {
ElMessage.warning("权限不足");
return;
}
if (isInactiveSite(detail.site_id)) {
ElMessage.warning("中心已停用");
return;
}
router.push(`/startup/feasibility/${recordId}/edit`);
editorVisible.value = true;
};
const goBack = () => router.push("/startup/feasibility-ethics");
onMounted(() => {
loadSites();
load();
const handleEditorSuccess = async () => {
await load();
};
onMounted(async () => {
await loadSites();
await load();
});
</script>
@@ -0,0 +1,266 @@
<template>
<el-drawer
v-if="modelValue"
:model-value="modelValue"
direction="rtl"
size="620px"
:close-on-click-modal="true"
:before-close="drawerDirtyGuard.beforeClose"
:show-close="false"
class="feasibility-editor-drawer"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<div class="editor-header">
<div class="editor-title">{{ recordId ? TEXT.modules.startupFeasibilityEthics.feasibilityEditTitle : TEXT.modules.startupFeasibilityEthics.feasibilityNewTitle }}</div>
</div>
</template>
<el-form :model="form" label-position="top" class="startup-editor-form">
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-basic"></span>
{{ TEXT.modules.startupFeasibilityEthics.feasibilityTab }}
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.site">
<el-select v-model="form.site_id" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select" clearable class="full-width">
<el-option v-for="site in siteOptions" :key="site.value" :label="site.label" :value="site.value" :disabled="site.disabled" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.projectNo">
<el-input v-model="form.project_no" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectNo" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item :label="TEXT.common.fields.submitDate">
<el-date-picker v-model="form.submit_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" class="full-width" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item :label="TEXT.common.fields.acceptDate">
<el-date-picker v-model="form.accept_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" class="full-width" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item :label="TEXT.common.fields.approvedDate">
<el-date-picker v-model="form.approved_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" class="full-width" />
</el-form-item>
</el-col>
</el-row>
</div>
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-attachment"></span>
{{ TEXT.common.labels.attachments }}
</div>
<AttachmentList
ref="attachmentPanelRef"
:study-id="studyId"
entity-type="startup_feasibility"
:entity-id="recordId || ''"
:mode="'upload'"
:readonly="isReadOnly"
/>
</div>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="emit('update:modelValue', false)">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
</div>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { createFeasibility, getFeasibility, updateFeasibility } from "../../api/startup";
import { fetchSites } from "../../api/sites";
import { useStudyStore } from "../../store/study";
import type { Site } from "../../types/api";
import { TEXT } from "../../locales";
import { usePermission } from "../../utils/permission";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
const props = defineProps<{ modelValue: boolean; recordId?: string }>();
const emit = defineEmits<{
(event: "update:modelValue", value: boolean): void;
(event: "success", id: string): void;
}>();
const study = useStudyStore();
const { can } = usePermission();
const saving = ref(false);
const sites = ref<Site[]>([]);
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
const studyId = computed(() => study.currentStudy?.id || "");
const isEdit = computed(() => !!props.recordId);
const form = reactive({
site_id: "",
submit_date: "",
accept_date: "",
approved_date: "",
project_no: "",
});
const siteOptions = computed(() =>
sites.value.map((site) => ({ value: site.id, label: site.name || TEXT.common.fallback, disabled: !site.is_active }))
);
const siteActiveMap = computed(() => new Map(sites.value.map((site) => [site.id, !!site.is_active])));
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.get(form.site_id) === false);
const isReadOnly = computed(() => !canSaveInitiation.value || isInactiveSite.value);
const drawerDirtyGuard = useDrawerDirtyGuard(() => ({
form,
attachments: attachmentPanelRef.value?.pendingSnapshot() || [],
}));
const reset = () => {
form.site_id = "";
form.submit_date = "";
form.accept_date = "";
form.approved_date = "";
form.project_no = "";
};
const loadSites = async () => {
if (!studyId.value) return;
try {
const { data } = await fetchSites(studyId.value, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
}
};
const load = async () => {
reset();
await loadSites();
if (!isEdit.value || !studyId.value || !props.recordId) {
drawerDirtyGuard.syncBaseline();
return;
}
try {
const { data } = await getFeasibility(studyId.value, props.recordId);
Object.assign(form, {
site_id: data.site_id || "",
submit_date: data.submit_date || "",
accept_date: data.accept_date || "",
approved_date: data.approved_date || "",
project_no: data.project_no || "",
});
drawerDirtyGuard.syncBaseline();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
}
};
const submit = async () => {
if (!studyId.value) return;
if (!canSaveInitiation.value) {
ElMessage.warning("权限不足");
return;
}
if (isInactiveSite.value || (form.site_id && siteActiveMap.value.get(form.site_id) === false)) {
ElMessage.warning("中心已停用");
return;
}
saving.value = true;
try {
const payload = {
site_id: form.site_id || null,
submit_date: form.submit_date || null,
accept_date: form.accept_date || null,
approved_date: form.approved_date || null,
project_no: form.project_no || null,
};
const id = props.recordId || (await createFeasibility(studyId.value, payload)).data.id;
if (props.recordId) {
await updateFeasibility(studyId.value, props.recordId, payload);
}
await attachmentPanelRef.value?.uploadPending(id);
ElMessage.success(props.recordId ? TEXT.common.messages.saveSuccess : TEXT.common.messages.createSuccess);
drawerDirtyGuard.syncBaseline();
emit("success", id);
emit("update:modelValue", false);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
watch(
() => [props.modelValue, props.recordId, studyId.value] as const,
([visible]) => {
if (visible) load();
},
{ immediate: true }
);
</script>
<style scoped>
.editor-header {
display: flex;
align-items: center;
}
.editor-title {
color: var(--ctms-text-main);
font-size: 18px;
font-weight: 700;
}
.startup-editor-form {
padding: 0 4px;
}
.form-group {
padding: 16px 0;
}
.form-group-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
color: var(--ctms-text-main);
font-size: 15px;
font-weight: 700;
}
.group-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.group-dot-basic {
background: var(--ctms-primary);
}
.group-dot-attachment {
background: #22c55e;
}
.full-width {
width: 100%;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>
+29 -19
View File
@@ -73,18 +73,17 @@
</el-form>
</section>
<section class="unified-section">
<template v-if="isEdit && recordId">
<div class="ctms-section-header">
<div class="ctms-section-title">{{ TEXT.common.labels.attachments }}</div>
</div>
<AttachmentList
:study-id="studyId"
entity-type="startup_feasibility"
:entity-id="recordId"
:readonly="isReadOnly"
/>
</template>
<StateEmpty v-else :description="TEXT.modules.startupFeasibilityEthics.feasibilityUploadHint" />
<div class="ctms-section-header">
<div class="ctms-section-title">{{ TEXT.common.labels.attachments }}</div>
</div>
<AttachmentList
ref="attachmentPanelRef"
:study-id="studyId"
entity-type="startup_feasibility"
:entity-id="recordId || ''"
:readonly="isReadOnly"
:mode="'upload'"
/>
</section>
</div>
</div>
@@ -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<InstanceType<typeof AttachmentList> | 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 {
@@ -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("<AttachmentList");
expect(feasibilityForm).toContain('ref="attachmentPanelRef"');
expect(feasibilityForm).toContain('entity-type="startup_feasibility"');
expect(feasibilityForm).toContain(':entity-id="recordId || \'\'"');
expect(feasibilityForm).toContain(':mode="\'upload\'"');
expect(feasibilityForm).toContain("await attachmentPanelRef.value?.uploadPending(savedId)");
expect(feasibilityForm).not.toContain('v-if="isEdit && recordId"');
expect(feasibilityForm).not.toContain("StateEmpty");
expect(ethicsForm).toContain("<AttachmentList");
expect(ethicsForm).toContain('ref="attachmentPanelRef"');
expect(ethicsForm).toContain('entity-type="startup_ethics"');
expect(ethicsForm).toContain(':entity-id="recordId || \'\'"');
expect(ethicsForm).toContain(':mode="\'upload\'"');
expect(ethicsForm).toContain("await attachmentPanelRef.value?.uploadPending(savedId)");
expect(ethicsForm).not.toContain('v-if="isEdit && recordId"');
expect(ethicsForm).not.toContain("StateEmpty");
});
});
@@ -57,14 +57,16 @@ describe("SubjectDetail medication adherence", () => {
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");
});
});
+59 -125
View File
@@ -6,22 +6,9 @@
<p class="page-subtitle">{{ TEXT.modules.subjectManagement.screeningNo }}</p>
</div>
<div class="actions">
<el-button
v-if="subjectEditing"
type="primary"
:loading="subjectSaving"
:disabled="isReadOnly || !canUpdateSubject"
@click="saveSubjectEdit"
>
{{ TEXT.common.actions.save }}
</el-button>
<el-button v-if="subjectEditing" :disabled="isReadOnly" @click="cancelSubjectEdit">
{{ TEXT.common.actions.cancel }}
</el-button>
<el-button v-else-if="canUpdateSubject" type="primary" :disabled="isReadOnly" @click="startSubjectEdit">
<el-button v-if="canUpdateSubject" type="primary" :disabled="isReadOnly" @click="openSubjectEditor">
{{ TEXT.common.actions.edit }}
</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div>
</div>
@@ -33,63 +20,27 @@
<span>{{ displaySubjectStatus(detail) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.screeningDate">
<el-date-picker
v-if="subjectEditing"
v-model="subjectForm.screening_date"
type="date"
value-format="YYYY-MM-DD"
size="small"
/>
<span v-else>{{ displayDate(detail.screening_date) }}</span>
<span>{{ displayDate(detail.screening_date) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.consentDate">
<el-date-picker
v-if="subjectEditing"
v-model="subjectForm.consent_date"
type="date"
value-format="YYYY-MM-DD"
size="small"
/>
<span v-else>{{ displayDate(detail.consent_date) }}</span>
<span>{{ displayDate(detail.consent_date) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.enrollmentDate">
<el-date-picker
v-if="subjectEditing"
v-model="subjectForm.enrollment_date"
type="date"
value-format="YYYY-MM-DD"
size="small"
/>
<span v-else>{{ displayDate(detail.enrollment_date) }}</span>
<span>{{ displayDate(detail.enrollment_date) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.baselineDate">
<el-date-picker
v-if="subjectEditing"
v-model="subjectForm.baseline_date"
type="date"
value-format="YYYY-MM-DD"
size="small"
/>
<span v-else>{{ displayDate(detail.baseline_date) }}</span>
<span>{{ displayDate(detail.baseline_date) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.completionDate">
<el-date-picker
v-if="subjectEditing"
v-model="subjectForm.completion_date"
type="date"
value-format="YYYY-MM-DD"
size="small"
/>
<span v-else>{{ displayDate(detail.completion_date) }}</span>
<span>{{ displayDate(detail.completion_date) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.dropReason" :span="2">
<el-input v-if="subjectEditing" v-model="subjectForm.drop_reason" type="textarea" :rows="2" />
<span v-else>{{ detail.drop_reason || TEXT.common.fallback }}</span>
<span>{{ detail.drop_reason || TEXT.common.fallback }}</span>
</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card class="unified-shell subject-shell subject-tabs-shell">
<el-card v-if="readableTabs.length > 0" class="unified-shell subject-shell subject-tabs-shell">
<div class="subject-tabs-action">
<el-button
v-if="currentTabAction.allowed"
@@ -114,7 +65,7 @@
</el-button>
</div>
<el-tabs v-model="activeTab">
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.history" name="history">
<el-tab-pane v-if="canReadHistory" :label="TEXT.modules.subjectDetail.tabs.history" name="history">
<el-table :data="historyItems" v-loading="loadingHistory" style="width: 100%" class="subject-detail-table" table-layout="fixed">
<el-table-column prop="record_date" :label="TEXT.common.fields.recordDate" show-overflow-tooltip>
<template #default="scope">{{ displayDate(scope.row.record_date) }}</template>
@@ -150,7 +101,7 @@
</el-table>
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.visits" name="visits">
<el-tab-pane v-if="canReadVisit" :label="TEXT.modules.subjectDetail.tabs.visits" name="visits">
<el-table :data="visitItems" v-loading="loadingVisits" style="width: 100%" class="subject-detail-table" table-layout="fixed">
<el-table-column prop="visit_code" :label="TEXT.common.fields.visitCode" show-overflow-tooltip />
<el-table-column prop="planned_date" :label="TEXT.common.fields.plannedDate" show-overflow-tooltip>
@@ -237,7 +188,7 @@
</el-table>
</el-tab-pane>
<el-tab-pane label="用药依从性" name="medicationAdherence">
<el-tab-pane v-if="canReadVisit" label="用药依从性" name="medicationAdherence">
<div class="adherence-panel">
<el-descriptions :column="2" border>
<el-descriptions-item label="开始用药日期">
@@ -274,7 +225,7 @@
</div>
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.ae" name="ae">
<el-tab-pane v-if="canListAe" :label="TEXT.modules.subjectDetail.tabs.ae" name="ae">
<el-table :data="aeItems" v-loading="loadingAes" style="width: 100%" class="subject-detail-table" table-layout="fixed">
<el-table-column prop="onset_date" :label="TEXT.common.fields.occurDate" show-overflow-tooltip>
<template #default="scope">{{ displayDate(scope.row.onset_date) }}</template>
@@ -329,7 +280,7 @@
</el-table>
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.pd" name="pd">
<el-tab-pane v-if="canListPd" :label="TEXT.modules.subjectDetail.tabs.pd" name="pd">
<el-table :data="pdItems" v-loading="loadingPds" style="width: 100%" class="subject-detail-table" table-layout="fixed">
<el-table-column prop="pd_no" :label="TEXT.common.fields.pdNo" show-overflow-tooltip />
<el-table-column prop="pd_type" :label="TEXT.common.fields.pdType" show-overflow-tooltip />
@@ -374,6 +325,12 @@
</el-tabs>
</el-card>
<SubjectEditorDrawer
v-model="subjectEditorVisible"
:subject-id="subjectId"
@success="handleSubjectEditorSuccess"
/>
<el-dialog v-if="historyDialogVisible" append-to=".layout-main .content-wrapper" v-model="historyDialogVisible" :title="TEXT.modules.subjectDetail.dialogHistory" width="520px">
<el-form :model="historyForm" label-width="90px">
<el-form-item :label="TEXT.common.fields.recordDate">
@@ -522,7 +479,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
@@ -536,9 +493,9 @@ import { displayDate, displayEnum, displayText } from "../../utils/display";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
import { TEXT } from "../../locales";
import SubjectEditorDrawer from "./SubjectEditorDrawer.vue";
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const study = useStudyStore();
const subjectId = route.params.subjectId as string;
@@ -556,6 +513,7 @@ const resolveTabFromQuery = () => {
};
const loading = ref(false);
const subjectEditorVisible = ref(false);
const detail = reactive<any>({
subject_no: "",
site_id: "",
@@ -569,17 +527,6 @@ const detail = reactive<any>({
drop_reason: "",
});
const subjectEditing = ref(false);
const subjectSaving = ref(false);
const subjectForm = reactive({
screening_date: "",
enrollment_date: "",
baseline_date: "",
consent_date: "",
completion_date: "",
drop_reason: "",
});
const siteMap = ref<Record<string, string>>({});
const siteActiveMap = ref<Record<string, boolean>>({});
const isReadOnly = computed(() => !!detail.site_id && siteActiveMap.value[detail.site_id] === false);
@@ -590,6 +537,7 @@ const historyItems = ref<any[]>([]);
const loadingHistory = ref(false);
const historyDialogVisible = ref(false);
const historyEditingId = ref<string | null>(null);
const canReadHistory = computed(() => canUseApiPermission("subject_histories:read"));
const canCreateHistory = computed(() => canUseApiPermission("subject_histories:create"));
const canUpdateHistory = computed(() => canUseApiPermission("subject_histories:update"));
const canDeleteHistory = computed(() => canUseApiPermission("subject_histories:delete"));
@@ -606,6 +554,7 @@ const earlyTerminationDialogVisible = ref(false);
const earlyTerminationSaving = ref(false);
const visitEditingId = ref<string | null>(null);
const visitEditingRowId = ref<string | null>(null);
const canReadVisit = computed(() => canUseApiPermission("visits:list"));
const canCreateVisit = computed(() => canUseApiPermission("visits:create"));
const canUpdateVisit = computed(() => canUseApiPermission("visits:update"));
const canDeleteVisit = computed(() => canUseApiPermission("visits:delete"));
@@ -639,7 +588,7 @@ const aeItems = ref<any[]>([]);
const loadingAes = ref(false);
const aeDialogVisible = ref(false);
const aeEditingId = ref<string | null>(null);
const canListAe = computed(() => canUseApiPermission("subject_aes:list"));
const canListAe = computed(() => canUseApiPermission("subject_aes:read"));
const canCreateAe = computed(() => canUseApiPermission("subject_aes:create"));
const canUpdateAe = computed(() => canUseApiPermission("subject_aes:update"));
const canDeleteAe = computed(() => canUseApiPermission("subject_aes:delete"));
@@ -687,6 +636,12 @@ const canCreatePd = computed(() => canUseApiPermission("subject_pds:create"));
const canUpdatePd = computed(() => canUseApiPermission("subject_pds:update"));
const canDeletePd = computed(() => canUseApiPermission("subject_pds:delete"));
const canSavePd = computed(() => (pdEditingId.value ? canUpdatePd.value : canCreatePd.value));
const readableTabs = computed(() => [
...(canReadHistory.value ? ["history"] : []),
...(canReadVisit.value ? ["visits", "medicationAdherence"] : []),
...(canListAe.value ? ["ae"] : []),
...(canListPd.value ? ["pd"] : []),
]);
const pdTypeOptions = ["知情同意", "排除标准", "药物使用", "入排标准", "访视流程", "检验检查", "其他"];
const pdLevelOptions = Object.entries(TEXT.enums.pdLevel).map(([value, label]) => ({ value, label }));
const pdStatusOptions = Object.entries(TEXT.enums.pdStatus).map(([value, label]) => ({ value, label }));
@@ -756,6 +711,10 @@ const loadSubject = async () => {
try {
const { data } = await getSubject(studyId, subjectId);
Object.assign(detail, data);
study.setViewContext({
siteName: siteMap.value[detail.site_id] || TEXT.common.fallback,
pageTitle: detail.subject_no || TEXT.modules.subjectManagement.screeningNo,
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
@@ -763,7 +722,7 @@ const loadSubject = async () => {
}
};
const startSubjectEdit = () => {
const openSubjectEditor = () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
@@ -772,53 +731,20 @@ const startSubjectEdit = () => {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
subjectForm.screening_date = detail.screening_date || "";
subjectForm.consent_date = detail.consent_date || "";
subjectForm.enrollment_date = detail.enrollment_date || "";
subjectForm.baseline_date = detail.baseline_date || "";
subjectForm.completion_date = detail.completion_date || "";
subjectForm.drop_reason = detail.drop_reason || "";
subjectEditing.value = true;
subjectEditorVisible.value = true;
};
const cancelSubjectEdit = () => {
subjectEditing.value = false;
};
const saveSubjectEdit = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!canUpdateSubject.value) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
if (!studyId || !subjectId) return;
subjectSaving.value = true;
try {
const payload = {
screening_date: subjectForm.screening_date || null,
consent_date: subjectForm.consent_date || null,
enrollment_date: subjectForm.enrollment_date || null,
baseline_date: subjectForm.baseline_date || null,
completion_date: subjectForm.completion_date || null,
drop_reason: subjectForm.drop_reason || null,
};
await updateSubject(studyId, subjectId, payload);
ElMessage.success(TEXT.common.messages.saveSuccess);
subjectEditing.value = false;
await loadSubject();
await loadVisits();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
subjectSaving.value = false;
}
const handleSubjectEditorSuccess = async () => {
await loadSubject();
await loadVisits();
};
const loadHistories = async () => {
if (!studyId || !subjectId) return;
if (!canReadHistory.value) {
historyItems.value = [];
return;
}
loadingHistory.value = true;
try {
const { data } = (await listSubjectHistories(studyId, subjectId)) as any;
@@ -896,6 +822,10 @@ const removeHistory = async (row: any) => {
const loadVisits = async () => {
if (!studyId || !subjectId) return;
if (!canReadVisit.value) {
visitItems.value = [];
return;
}
loadingVisits.value = true;
try {
const { data } = (await fetchVisits(studyId, subjectId)) as any;
@@ -1510,14 +1440,18 @@ const removePd = async (row: any) => {
}
};
const goBack = () => router.push("/subjects");
watch(
() => route.query.tab,
() => {
activeTab.value = resolveTabFromQuery();
const syncActiveTab = () => {
const requested = resolveTabFromQuery();
if (readableTabs.value.includes(requested)) {
activeTab.value = requested;
return;
}
);
if (readableTabs.value.length > 0) {
activeTab.value = readableTabs.value[0];
}
};
watch([() => route.query.tab, readableTabs], syncActiveTab, { immediate: true });
onMounted(async () => {
await loadSites();
@@ -0,0 +1,297 @@
<template>
<el-drawer
v-if="modelValue"
:model-value="modelValue"
direction="rtl"
size="620px"
:close-on-click-modal="true"
:before-close="drawerDirtyGuard.beforeClose"
:show-close="false"
class="subject-editor-drawer"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<div class="editor-header">
<div class="editor-title">{{ subjectId ? TEXT.common.actions.edit : TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }}</div>
</div>
</template>
<el-form :model="form" label-position="top" class="subject-editor-form">
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-basic"></span>
参与者标识
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.modules.subjectManagement.screeningNo" required>
<el-input v-model="form.subject_no" :disabled="isEdit || isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.modules.subjectManagement.screeningNo" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.site" required>
<el-select v-model="form.site_id" :disabled="isEdit || isReadOnly" :placeholder="TEXT.common.placeholders.select" class="full-width">
<el-option v-for="site in sites" :key="site.id" :label="site.name" :value="site.id" :disabled="!site.is_active" />
</el-select>
</el-form-item>
</el-col>
</el-row>
</div>
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-date"></span>
关键日期
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.screeningDate">
<el-date-picker v-model="form.screening_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" :disabled="isEdit || isReadOnly" class="full-width" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.consentDate">
<el-date-picker v-model="form.consent_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" :disabled="isReadOnly" class="full-width" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.enrollmentDate">
<el-date-picker v-model="form.enrollment_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" :disabled="isReadOnly" class="full-width" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.baselineDate">
<el-date-picker v-model="form.baseline_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" :disabled="isReadOnly" class="full-width" />
</el-form-item>
</el-col>
</el-row>
<template v-if="isEdit">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.completionDate">
<el-date-picker v-model="form.completion_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" :disabled="isReadOnly" class="full-width" />
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="TEXT.common.fields.dropReason">
<el-input v-model="form.drop_reason" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" :disabled="isReadOnly" />
</el-form-item>
</template>
</div>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="emit('update:modelValue', false)">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
</div>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import { createSubject, getSubject, updateSubject } from "../../api/subjects";
import { fetchSites } from "../../api/sites";
import { TEXT } from "../../locales";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
const props = defineProps<{ modelValue: boolean; subjectId?: string }>();
const emit = defineEmits<{
(event: "update:modelValue", value: boolean): void;
(event: "success", id: string): void;
}>();
const auth = useAuthStore();
const study = useStudyStore();
const saving = ref(false);
const sites = ref<any[]>([]);
const studyId = computed(() => study.currentStudy?.id || "");
const isEdit = computed(() => !!props.subjectId);
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
const canMutate = computed(() => {
if (isSystemAdmin(auth.user)) return true;
const role = projectRole.value;
if (!role) return false;
const operationKey = isEdit.value ? "subjects:update" : "subjects:create";
return isApiPermissionAllowed(study.currentPermissions?.[role]?.[operationKey]);
});
const form = reactive({
subject_no: "",
site_id: "",
screening_date: "",
consent_date: "",
enrollment_date: "",
baseline_date: "",
completion_date: "",
drop_reason: "",
});
const siteActiveMap = computed(() => new Map(sites.value.map((site) => [site.id, !!site.is_active])));
const isReadOnly = computed(() => !canMutate.value || (isEdit.value && !!form.site_id && siteActiveMap.value.get(form.site_id) === false));
const drawerDirtyGuard = useDrawerDirtyGuard(() => form);
const reset = () => {
form.subject_no = "";
form.site_id = "";
form.screening_date = "";
form.consent_date = "";
form.enrollment_date = "";
form.baseline_date = "";
form.completion_date = "";
form.drop_reason = "";
};
const loadSites = async () => {
if (!studyId.value) return;
try {
const { data } = await fetchSites(studyId.value, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const load = async () => {
reset();
await loadSites();
if (!isEdit.value || !studyId.value || !props.subjectId) {
drawerDirtyGuard.syncBaseline();
return;
}
try {
const { data } = await getSubject(studyId.value, props.subjectId);
Object.assign(form, {
subject_no: data.subject_no || "",
site_id: data.site_id || "",
screening_date: data.screening_date || "",
consent_date: data.consent_date || "",
enrollment_date: data.enrollment_date || "",
baseline_date: data.baseline_date || "",
completion_date: data.completion_date || "",
drop_reason: data.drop_reason || "",
});
drawerDirtyGuard.syncBaseline();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
}
};
const submit = async () => {
if (!canMutate.value) {
ElMessage.warning("权限不足");
return;
}
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId.value) return;
saving.value = true;
try {
let id = props.subjectId || "";
if (props.subjectId) {
await updateSubject(studyId.value, props.subjectId, {
consent_date: form.consent_date || null,
enrollment_date: form.enrollment_date || null,
baseline_date: form.baseline_date || null,
completion_date: form.completion_date || null,
drop_reason: form.drop_reason || null,
});
} else {
const { data } = await createSubject(studyId.value, {
subject_no: form.subject_no,
site_id: form.site_id,
screening_date: form.screening_date || null,
consent_date: form.consent_date || null,
enrollment_date: form.enrollment_date || null,
baseline_date: form.baseline_date || null,
});
id = data.id;
}
ElMessage.success(props.subjectId ? TEXT.common.messages.saveSuccess : TEXT.common.messages.createSuccess);
drawerDirtyGuard.syncBaseline();
emit("success", id);
emit("update:modelValue", false);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
watch(
() => [props.modelValue, props.subjectId, studyId.value] as const,
([visible]) => {
if (visible) load();
},
{ immediate: true }
);
</script>
<style scoped>
.editor-header {
display: flex;
align-items: center;
}
.editor-title {
color: var(--ctms-text-main);
font-size: 18px;
font-weight: 700;
}
.subject-editor-form {
padding: 0 4px;
}
.form-group {
padding: 16px 0;
}
.form-group + .form-group {
border-top: 1px solid #edf2f7;
}
.form-group-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
color: var(--ctms-text-main);
font-size: 15px;
font-weight: 700;
}
.group-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.group-dot-basic {
background: var(--ctms-primary);
}
.group-dot-date {
background: #22c55e;
}
.full-width {
width: 100%;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>