224 lines
7.1 KiB
Vue
224 lines
7.1 KiB
Vue
<template>
|
|
<div class="ctms-page" v-if="ae">
|
|
<div class="ctms-page-header">
|
|
<div>
|
|
<h1 class="ctms-page-title">不良事件</h1>
|
|
</div>
|
|
<div class="ctms-page-actions">
|
|
<template v-for="action in availableActions" :key="action.key">
|
|
<PermissionAction action="ae.close">
|
|
<el-button
|
|
size="small"
|
|
:type="action.danger ? 'danger' : 'primary'"
|
|
:link="action.danger"
|
|
@click="onAction(action)"
|
|
>
|
|
{{ action.label }}
|
|
</el-button>
|
|
</PermissionAction>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<el-card class="ctms-section-card">
|
|
<template #header>
|
|
<div>
|
|
<div class="ctms-section-title">基本信息</div>
|
|
</div>
|
|
</template>
|
|
<el-descriptions :column="2" border class="detail-descriptions">
|
|
<el-descriptions-item label="受试者">{{ subjectName(ae.subject_id) }}</el-descriptions-item>
|
|
<el-descriptions-item label="严重性">{{ ae.seriousness }}</el-descriptions-item>
|
|
<el-descriptions-item label="严重程度">{{ ae.severity }}</el-descriptions-item>
|
|
<el-descriptions-item label="发生日期">{{ ae.onset_date }}</el-descriptions-item>
|
|
<el-descriptions-item label="状态">
|
|
<el-tag :type="stateColor(aeState)" effect="plain">{{ stateLabel(aeState) }}</el-tag>
|
|
</el-descriptions-item>
|
|
<el-descriptions-item label="描述">{{ ae.description }}</el-descriptions-item>
|
|
</el-descriptions>
|
|
</el-card>
|
|
|
|
<el-card class="ctms-section-card">
|
|
<template #header>
|
|
<div>
|
|
<div class="ctms-section-title">协作记录</div>
|
|
</div>
|
|
</template>
|
|
<el-tabs class="detail-tabs">
|
|
<el-tab-pane label="评论">
|
|
<CommentList :study-id="studyId" entity-type="aes" :entity-id="ae.id" :can-comment="true" />
|
|
</el-tab-pane>
|
|
<el-tab-pane label="附件">
|
|
<AttachmentList
|
|
:study-id="studyId"
|
|
entity-type="aes"
|
|
:entity-id="ae.id"
|
|
:show-uploader="true"
|
|
/>
|
|
</el-tab-pane>
|
|
</el-tabs>
|
|
</el-card>
|
|
</div>
|
|
<div v-else class="ctms-page">
|
|
<StateLoading :rows="4" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, ref } from "vue";
|
|
import { useRoute } from "vue-router";
|
|
import { ElMessage, ElMessageBox } from "element-plus";
|
|
import { fetchAes, updateAe } from "../api/aes";
|
|
import { fetchSubjects } from "../api/subjects";
|
|
import { useStudyStore } from "../store/study";
|
|
import { useAuthStore } from "../store/auth";
|
|
import CommentList from "../components/CommentList.vue";
|
|
import AttachmentList from "../components/attachments/AttachmentList.vue";
|
|
import PermissionAction from "../components/PermissionAction.vue";
|
|
import StateLoading from "../components/StateLoading.vue";
|
|
import { usePermission } from "../utils/permission";
|
|
import { aeMachine, getAvailableActions } from "../state-machine";
|
|
import type { ActionConfig } from "../state-machine";
|
|
import { statusDict, getDictColor, getDictLabel } from "../dictionaries";
|
|
import { evaluateAction } from "../guards/actionGuard";
|
|
import { logAudit } from "../audit";
|
|
|
|
const route = useRoute();
|
|
const study = useStudyStore();
|
|
const auth = useAuthStore();
|
|
|
|
const ae = ref<any | null>(null);
|
|
const studyId = computed(() => study.currentStudy?.id || "");
|
|
const subjects = ref<any[]>([]);
|
|
|
|
const { can } = usePermission();
|
|
const aeState = computed(() => (ae.value?.status as string) || "NEW");
|
|
const availableActions = computed(() => getAvailableActions(aeMachine, aeState.value));
|
|
const stateLabel = (v?: string | null) => getDictLabel(statusDict, v || "");
|
|
const stateColor = (v?: string | null) => getDictColor(statusDict, v || "") || "info";
|
|
|
|
const enrichOverdue = (item: any) => {
|
|
if (!item) return item;
|
|
return {
|
|
...item,
|
|
is_overdue:
|
|
item.is_overdue ??
|
|
(item.report_due_date && item.status !== "CLOSED" && new Date() > new Date(item.report_due_date)),
|
|
};
|
|
};
|
|
|
|
const load = async () => {
|
|
if (!study.currentStudy) return;
|
|
try {
|
|
const { data } = await fetchAes(study.currentStudy.id, { skip: 0, limit: 500 });
|
|
const list = data.items || data || [];
|
|
ae.value = enrichOverdue(list.find((item: any) => item.id === route.params.aeId));
|
|
} catch (e: any) {
|
|
ElMessage.error(e?.response?.data?.message || "AE 加载失败");
|
|
}
|
|
};
|
|
|
|
const loadSubjects = async () => {
|
|
if (!study.currentStudy) return;
|
|
try {
|
|
const { data } = await fetchSubjects(study.currentStudy.id, { skip: 0, limit: 500 });
|
|
subjects.value = data.items || data || [];
|
|
} catch {
|
|
subjects.value = [];
|
|
}
|
|
};
|
|
|
|
const subjectLabel = computed(() => {
|
|
const map = subjects.value.reduce<Record<string, string>>((acc, cur) => {
|
|
if (cur?.id) acc[cur.id] = cur.subject_no || cur.id;
|
|
return acc;
|
|
}, {});
|
|
return map;
|
|
});
|
|
|
|
const subjectName = (id?: string | null) => {
|
|
if (!id) return "—";
|
|
return subjectLabel.value[id] || id;
|
|
};
|
|
|
|
|
|
const onAction = async (action: ActionConfig) => {
|
|
if (!study.currentStudy || !ae.value) return;
|
|
const prevStatus = ae.value.status;
|
|
if (prevStatus === action.to) {
|
|
ElMessage.warning("当前状态不允许该操作");
|
|
logAudit("INVALID_STATE_TRANSITION_ATTEMPT", {
|
|
targetId: ae.value.id,
|
|
targetName: ae.value.term,
|
|
before: { status: prevStatus },
|
|
after: { status: action.to },
|
|
severity: "warning",
|
|
});
|
|
return;
|
|
}
|
|
const decision = evaluateAction({
|
|
actorRole: auth.user?.role || null,
|
|
requiredPermission: "ae.close",
|
|
stateMachine: aeMachine,
|
|
currentState: ae.value.status,
|
|
actionKey: action.key,
|
|
target: { id: ae.value.id },
|
|
});
|
|
if (!decision.allowed) {
|
|
ElMessage.warning(decision.reason || "当前不可执行该操作");
|
|
logAudit(decision.auditType, {
|
|
targetId: ae.value.id,
|
|
targetName: ae.value.term,
|
|
reason: decision.reason,
|
|
severity: decision.severity,
|
|
});
|
|
return;
|
|
}
|
|
if (action.confirm) {
|
|
const ok = await ElMessageBox.confirm(action.confirmText || "确认执行该操作?", "提示").catch(() => null);
|
|
if (!ok) return;
|
|
}
|
|
try {
|
|
const resp = await updateAe(study.currentStudy.id, ae.value.id, { status: action.to });
|
|
ElMessage.success("状态已更新");
|
|
ae.value = enrichOverdue(resp.data);
|
|
logAudit("AE_CLOSED", {
|
|
targetId: ae.value.id,
|
|
targetName: ae.value.term,
|
|
before: { status: prevStatus },
|
|
after: { status: action.to },
|
|
severity: "normal",
|
|
});
|
|
await load();
|
|
} catch (e: any) {
|
|
ElMessage.error(e?.response?.data?.message || "更新失败");
|
|
logAudit("AE_CLOSED", {
|
|
targetId: ae.value.id,
|
|
targetName: ae.value.term,
|
|
before: { status: prevStatus },
|
|
after: { status: action.to },
|
|
severity: "warning",
|
|
reason: e?.response?.data?.message,
|
|
});
|
|
}
|
|
};
|
|
|
|
onMounted(async () => {
|
|
if (!auth.user && auth.token) {
|
|
await auth.fetchMe().catch(() => {});
|
|
}
|
|
await loadSubjects();
|
|
load();
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.detail-descriptions :deep(.el-descriptions__label) {
|
|
color: var(--ctms-text-secondary);
|
|
}
|
|
|
|
.detail-tabs {
|
|
margin-top: -8px;
|
|
}
|
|
</style>
|