统一业务页面权限与抽屉编辑
1、将 FAQ 分类和问题维护改为抽屉交互,并按分类、问题、回复的细粒度权限控制入口。 2、将注意事项、可行性和伦理记录接入详情页抽屉编辑,创建时支持先保存主记录再上传附件。 3、将参与者基础信息编辑改为抽屉,详情页按可读权限显示病史、访视、AE 和 PD 标签页。 4、补充业务页面权限一致性测试,覆盖停用中心、无权限入口和面包屑上下文。
This commit is contained in:
@@ -50,7 +50,7 @@ async def create_category(
|
||||
action="CREATE_FAQ_CATEGORY",
|
||||
detail=f"FAQ 分类 {category.name} 已创建",
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
operator_role=await get_operator_role_label(db, payload.study_id, current_user),
|
||||
)
|
||||
return CategoryRead.model_validate(category)
|
||||
|
||||
@@ -112,7 +112,7 @@ async def update_category(
|
||||
action="UPDATE_FAQ_CATEGORY",
|
||||
detail=f"FAQ 分类 {category_id} 已更新",
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
operator_role=await get_operator_role_label(db, updated.study_id, current_user),
|
||||
)
|
||||
return CategoryRead.model_validate(updated)
|
||||
|
||||
@@ -150,5 +150,5 @@ async def delete_category(
|
||||
action="DELETE_FAQ_CATEGORY",
|
||||
detail=f"FAQ 分类 {category_id} 已删除",
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
operator_role=await get_operator_role_label(db, category.study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -74,7 +74,7 @@ async def create_faq(
|
||||
action="CREATE_FAQ_ITEM",
|
||||
detail="FAQ 已创建",
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
operator_role=await get_operator_role_label(db, payload.study_id, current_user),
|
||||
)
|
||||
return FaqRead.model_validate(item)
|
||||
|
||||
@@ -200,7 +200,7 @@ async def update_faq(
|
||||
action=action,
|
||||
detail=detail,
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
operator_role=await get_operator_role_label(db, item.study_id, current_user),
|
||||
)
|
||||
return FaqRead.model_validate(updated)
|
||||
|
||||
@@ -356,7 +356,7 @@ async def create_reply(
|
||||
action="CREATE_FAQ_REPLY",
|
||||
detail="FAQ 已回复",
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
operator_role=await get_operator_role_label(db, item.study_id, current_user),
|
||||
)
|
||||
data = FaqReplyRead.model_validate(reply)
|
||||
if quote:
|
||||
@@ -401,7 +401,7 @@ async def delete_faq(
|
||||
action="DELETE_FAQ_ITEM",
|
||||
detail="FAQ 已删除",
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
operator_role=await get_operator_role_label(db, item.study_id, current_user),
|
||||
)
|
||||
|
||||
|
||||
@@ -454,5 +454,5 @@ async def delete_reply(
|
||||
action="DELETE_FAQ_REPLY",
|
||||
detail="FAQ 回复已删除",
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
operator_role=await get_operator_role_label(db, item.study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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")');
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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: "高" },
|
||||
];
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user