项目概览、参与者管理初步优化(未接入真实数据)

This commit is contained in:
Cheng Zhou
2026-01-13 10:10:28 +08:00
parent 1338fa7e2b
commit b3cd8e03f2
37 changed files with 1785 additions and 118 deletions
+3
View File
@@ -0,0 +1,3 @@
import { apiGet } from "./axios";
export const fetchProjectOverview = (studyId: string) => apiGet(`/api/v1/studies/${studyId}/overview`);
+24 -23
View File
@@ -84,7 +84,7 @@ export const TEXT = {
case: "例",
},
fields: {
subjectNo: "受试者编号",
subjectNo: "参与者编号",
site: "分中心",
status: "状态",
keyword: "关键词",
@@ -109,17 +109,21 @@ export const TEXT = {
category: "分类",
question: "问题",
screeningDate: "筛选日期",
consentDate: "知情日期",
enrollmentDate: "入组日期",
completionDate: "完成日期",
dropReason: "退出原因",
recordDate: "日期",
content: "内容",
visitCode: "访视编号",
visitName: "访视名称",
plannedDate: "计划日期",
actualDate: "实际日期",
plannedDate: "计划访视",
actualDate: "实际访视",
windowStart: "窗口开始",
windowEnd: "窗口结束",
visitTotal: "访视总次数",
visitIntervalDays: "访视间隔(天)",
visitWindowStartOffset: "窗口开始偏移(天)",
visitWindowEndOffset: "窗口结束偏移(天)",
notes: "备注",
event: "事件",
onsetDate: "发生日期",
@@ -204,7 +208,7 @@ export const TEXT = {
},
eventDict: {
AE_CLOSED: { label: "关闭 AE(不良事件)", actionText: "关闭了 AE", targetLabel: "不良事件" },
SUBJECT_STATUS_CHANGED: { label: "受试者状态变更", actionText: "变更了受试者状态", targetLabel: "受试者" },
SUBJECT_STATUS_CHANGED: { label: "参与者状态变更", actionText: "变更了参与者状态", targetLabel: "参与者" },
FINANCE_STATUS_CHANGED: { label: "费用状态变更", actionText: "变更了费用状态", targetLabel: "费用记录" },
ADMIN_RESET_PASSWORD: { label: "重置用户密码", actionText: "重置了用户密码", targetLabel: "用户账号" },
ISSUE_STATUS_CHANGED: { label: "风险/问题状态变更", actionText: "更新了风险/问题状态", targetLabel: "风险/问题" },
@@ -236,7 +240,7 @@ export const TEXT = {
drugShipments: "运输/流向",
startupFeasibilityEthics: "立项与伦理",
startupMeetingAuth: "启动与授权",
subjects: "受试者管理",
subjects: "参与者管理",
monitoring: "监查",
audit: "稽查",
knowledge: "知识库",
@@ -367,18 +371,18 @@ export const TEXT = {
trainingDetailSubtitle: "查看人员培训与授权信息",
},
subjectManagement: {
title: "受试者管理",
subtitle: "受试者、病史、访视、AE 综合管理",
subjectLabel: "受试者",
empty: "暂无受试者记录",
title: "参与者管理",
subtitle: "参与者、病史、访视、AE 综合管理",
subjectLabel: "参与者",
empty: "暂无参与者记录",
},
subjectForm: {
titleEdit: "编辑受试者",
titleNew: "新增受试者",
subtitle: "维护受试者基本信息",
titleEdit: "编辑参与者",
titleNew: "新增参与者",
subtitle: "维护参与者基本信息",
},
subjectDetail: {
title: "受试者详情",
title: "参与者详情",
subtitle: "查看病史、访视与 AE 信息",
tabs: {
history: "病史",
@@ -527,6 +531,7 @@ export const TEXT = {
createdAt: "创建时间",
newTitle: "新建项目",
editTitle: "编辑项目",
visitTemplate: "访视模板",
sponsorPlaceholder: "申办方(可选)",
protocolPlaceholder: "方案号(可选)",
phasePlaceholder: "研究分期(可选)",
@@ -644,9 +649,9 @@ export const TEXT = {
loadFailed: "工作台数据加载失败",
},
permissions: {
subjectEnroll: "仅 PM/CRA 可更新受试者状态",
subjectComplete: "仅 PM/CRA 可更新受试者状态",
subjectDrop: "仅 PM/CRA 可更新受试者状态",
subjectEnroll: "仅 PM/CRA 可更新参与者状态",
subjectComplete: "仅 PM/CRA 可更新参与者状态",
subjectDrop: "仅 PM/CRA 可更新参与者状态",
aeClose: "仅 PM/PV/ADMIN 可关闭 AE",
faqEdit: "仅 PM/ADMIN 可维护 FAQ",
faqCreate: "仅项目成员可新建 FAQ",
@@ -699,14 +704,10 @@ export const TEXT = {
PLANNED: "计划中",
DONE: "已完成",
MISSED: "未完成",
OVERDUE: "超窗",
LOST: "失访",
CANCELLED: "已取消",
},
visitName: {
Screening: "筛选访视",
Baseline: "基线访视",
"Follow-up 1": "随访 1",
"Follow-up 2": "随访 2",
},
aeSeriousness: {
SERIOUS: "严重",
NON_SERIOUS: "一般",
+5 -1
View File
@@ -1,6 +1,9 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import ElementPlus from "element-plus";
import zhCn from "element-plus/es/locale/lang/zh-cn";
import dayjs from "dayjs";
import "dayjs/locale/zh-cn";
import "element-plus/dist/index.css";
import "./styles/main.css";
@@ -12,7 +15,8 @@ const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.use(router);
app.use(ElementPlus);
dayjs.locale("zh-cn");
app.use(ElementPlus, { locale: zhCn });
// 初始化项目上下文
const studyStore = useStudyStore();
+4
View File
@@ -59,6 +59,10 @@ export interface Study {
protocol_no?: string | null;
phase?: string | null;
status: string;
visit_interval_days?: number | null;
visit_total?: number | null;
visit_window_start_offset?: number | null;
visit_window_end_offset?: number | null;
created_by?: string | null;
created_at?: string;
role_in_study?: string | null;
+1 -1
View File
@@ -64,5 +64,5 @@ export const displayDateTime = (value?: string | number | Date | null) => {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(
date.getMinutes()
)}`;
)}:${pad(date.getSeconds())}`;
};
+33
View File
@@ -23,6 +23,19 @@
<el-option :label="TEXT.enums.projectStatus.CLOSED" value="CLOSED" />
</el-select>
</el-form-item>
<el-divider content-position="left">{{ TEXT.modules.adminProjects.visitTemplate }}</el-divider>
<el-form-item :label="TEXT.common.fields.visitTotal">
<el-input-number v-model="form.visit_total" :min="1" :max="50" :step="1" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.visitIntervalDays">
<el-input-number v-model="form.visit_interval_days" :min="1" :max="365" :step="1" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.visitWindowStartOffset">
<el-input-number v-model="form.visit_window_start_offset" :min="-365" :max="365" :step="1" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.visitWindowEndOffset">
<el-input-number v-model="form.visit_window_end_offset" :min="-365" :max="365" :step="1" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visibleProxy = false">{{ TEXT.common.actions.cancel }}</el-button>
@@ -62,6 +75,10 @@ const form = reactive({
protocol_no: "",
phase: "",
status: "DRAFT",
visit_interval_days: null as number | null,
visit_total: null as number | null,
visit_window_start_offset: null as number | null,
visit_window_end_offset: null as number | null,
});
const rules = reactive<FormRules>({
@@ -77,6 +94,10 @@ const resetForm = () => {
form.protocol_no = "";
form.phase = "";
form.status = "DRAFT";
form.visit_interval_days = null;
form.visit_total = null;
form.visit_window_start_offset = null;
form.visit_window_end_offset = null;
};
watch(
@@ -91,6 +112,10 @@ watch(
form.protocol_no = props.project.protocol_no || "";
form.phase = props.project.phase || "";
form.status = props.project.status || "DRAFT";
form.visit_interval_days = props.project.visit_interval_days ?? null;
form.visit_total = props.project.visit_total ?? null;
form.visit_window_start_offset = props.project.visit_window_start_offset ?? null;
form.visit_window_end_offset = props.project.visit_window_end_offset ?? null;
}
}
}
@@ -108,6 +133,10 @@ const onSubmit = async () => {
protocol_no: form.protocol_no,
phase: form.phase,
status: form.status,
visit_interval_days: form.visit_interval_days,
visit_total: form.visit_total,
visit_window_start_offset: form.visit_window_start_offset,
visit_window_end_offset: form.visit_window_end_offset,
});
ElMessage.success(TEXT.modules.adminProjects.updateSuccess);
} else {
@@ -118,6 +147,10 @@ const onSubmit = async () => {
protocol_no: form.protocol_no,
phase: form.phase,
status: form.status,
visit_interval_days: form.visit_interval_days,
visit_total: form.visit_total,
visit_window_start_offset: form.visit_window_start_offset,
visit_window_end_offset: form.visit_window_end_offset,
});
ElMessage.success(TEXT.modules.adminProjects.createSuccess);
}
+321 -2
View File
@@ -1,7 +1,326 @@
<template>
<StudyHome />
<div class="page">
<div v-if="study.currentStudy" class="page-body">
<div class="page-header">
<div>
<h1 class="page-title">项目概览</h1>
<p class="page-subtitle">多中心项目整体进度与入组情况</p>
<p class="study-meta">
<span class="study-name">{{ study.currentStudy.name }}</span>
<span class="study-divider">/</span>
<span class="study-code">{{ study.currentStudy.code }}</span>
</p>
</div>
<div class="header-meta">
<el-tag v-if="usingDemo" effect="plain" type="info" class="demo-tag">示例数据</el-tag>
<div class="updated-at">更新{{ nowLabel }}</div>
<el-button size="small" @click="loadOverview">刷新</el-button>
</div>
</div>
<el-card>
<template #header>
<div class="card-header">
<div>
<div class="card-title">中心整体进度</div>
</div>
<div class="progress-legend">
<span class="legend-item"><span class="legend-dot completed"></span>已完成</span>
<span class="legend-item"><span class="legend-dot active"></span>进行中</span>
<span class="legend-item"><span class="legend-dot pending"></span>未开始</span>
<span class="legend-item"><span class="legend-dot blocked"></span>阻塞/延期</span>
</div>
</div>
</template>
<StateLoading v-if="loading" :rows="6" />
<StateEmpty
v-else-if="centers.length === 0"
title="暂无中心进度"
description="当前项目未配置中心或暂无进度数据"
/>
<div v-else class="progress-list">
<CenterProgressRow
v-for="(center, index) in centers"
:key="center.center_id || center.center_name || index"
:center="center"
/>
</div>
</el-card>
<el-card>
<template #header>
<div class="card-header">
<div>
<div class="card-title">入组进度</div>
<div class="card-subtitle">
{{ enrollmentSummary }}
</div>
</div>
<el-radio-group v-model="chartMode" size="small" class="mode-switch">
<el-radio-button label="center">按中心</el-radio-button>
<el-radio-button label="month">按月份</el-radio-button>
</el-radio-group>
</div>
</template>
<EnrollmentBarChart
:mode="chartMode"
:items="chartItems"
:loading="loading"
:empty-text="chartEmptyText"
/>
</el-card>
</div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
</div>
</template>
<script setup lang="ts">
import StudyHome from "../StudyHome.vue";
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { useStudyStore } from "../../store/study";
import { TEXT } from "../../locales";
import { displayDateTime } from "../../utils/display";
import { fetchProjectOverview } from "../../api/overview";
import StateEmpty from "../../components/StateEmpty.vue";
import StateLoading from "../../components/StateLoading.vue";
import CenterProgressRow from "./project-overview/CenterProgressRow.vue";
import EnrollmentBarChart, { type EnrollmentBarItem } from "./project-overview/EnrollmentBarChart.vue";
import { adaptProjectOverview, type ProjectOverviewViewModel } from "./project-overview/overview.adapter";
import { overviewMock } from "./project-overview/overview.mock";
const study = useStudyStore();
const loading = ref(false);
const usingDemo = ref(false);
const overview = ref<ProjectOverviewViewModel | null>(null);
const chartMode = ref<"center" | "month">("center");
const preferApi = String(import.meta.env.VITE_USE_OVERVIEW_API || "").toLowerCase() === "true";
const centers = computed(() => overview.value?.centers || []);
const nowLabel = ref(displayDateTime(new Date()));
let clockTimer: number | undefined;
const startClock = () => {
if (clockTimer) return;
clockTimer = window.setInterval(() => {
nowLabel.value = displayDateTime(new Date());
}, 1000);
};
const stopClock = () => {
if (clockTimer) {
window.clearInterval(clockTimer);
clockTimer = undefined;
}
};
const enrollmentSummary = computed(() => {
const summary = overview.value?.summary;
if (!summary) return "目标与实际入组情况概览";
return `已入组 ${summary.total_actual} / 目标 ${summary.total_target}`;
});
const chartItems = computed<EnrollmentBarItem[]>(() => {
if (!overview.value) return [];
if (chartMode.value === "center") {
return overview.value.centers.map((center, index) => ({
key: center.center_id || center.center_name || `center-${index}`,
label: center.center_name || TEXT.common.fallback,
actual: center.enrollment_actual,
target: center.enrollment_target,
}));
}
return overview.value.months.map((month) => ({
key: month.month,
label: month.month,
actual: month.count,
}));
});
const chartEmptyText = computed(() =>
chartMode.value === "center" ? "暂无中心入组数据" : "暂无月度入组数据"
);
const loadOverview = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
loading.value = true;
try {
if (preferApi) {
const { data } = await fetchProjectOverview(studyId);
overview.value = adaptProjectOverview(data);
usingDemo.value = false;
return;
}
overview.value = adaptProjectOverview(overviewMock);
usingDemo.value = true;
} catch {
overview.value = adaptProjectOverview(overviewMock);
usingDemo.value = true;
} finally {
loading.value = false;
}
};
const reset = () => {
overview.value = null;
usingDemo.value = false;
};
onMounted(() => {
loadOverview();
startClock();
});
onUnmounted(() => {
stopClock();
});
watch(
() => study.currentStudy?.id,
() => {
reset();
loadOverview();
}
);
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-body {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16px;
flex-wrap: wrap;
}
.page-title {
margin: 0;
font-size: 24px;
font-weight: 600;
color: var(--ctms-text-main);
}
.page-subtitle {
margin: 6px 0 0;
font-size: 13px;
color: var(--ctms-text-secondary);
}
.study-meta {
margin: 6px 0 0;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--ctms-text-secondary);
}
.study-divider {
opacity: 0.3;
}
.header-meta {
display: flex;
align-items: center;
gap: 10px;
color: var(--ctms-text-secondary);
font-size: 12px;
}
.demo-tag {
border-color: var(--ctms-border-color);
color: var(--ctms-text-secondary);
}
.updated-at {
white-space: nowrap;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: var(--ctms-text-main);
}
.card-subtitle {
font-size: 12px;
color: var(--ctms-text-secondary);
margin-top: 4px;
}
.progress-legend {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: var(--ctms-text-secondary);
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 999px;
border: 2px solid var(--ctms-border-color);
background-color: #ffffff;
}
.legend-dot.completed {
background-color: var(--ctms-success);
border-color: var(--ctms-success);
}
.legend-dot.active {
border-color: var(--ctms-primary);
box-shadow: 0 0 0 2px rgba(63, 93, 117, 0.18);
}
.legend-dot.pending {
background-color: #f8fafc;
}
.legend-dot.blocked {
border-color: var(--ctms-danger);
background-color: rgba(194, 75, 75, 0.12);
}
.progress-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.mode-switch :deep(.el-radio-button__inner) {
padding: 4px 12px;
}
@media (max-width: 1024px) {
}
</style>
@@ -0,0 +1,177 @@
<template>
<div class="center-row">
<div class="center-meta">
<div class="center-name">{{ centerName }}</div>
<div class="center-enrollment">
<span class="enrollment-label">入组</span>
<span class="enrollment-value">{{ enrollmentLabel }}</span>
</div>
</div>
<div class="center-timeline">
<div v-for="(stage, index) in stages" :key="stage.key" class="timeline-segment">
<StageNode :label="stage.label" :status="stage.status" :completed-at="stage.completedAt" />
<div
v-if="index < stages.length - 1"
class="stage-connector"
:class="connectorClass(stage.status)"
></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import StageNode from "./StageNode.vue";
import { STAGE_ORDER, type CenterOverview, type StageStatus } from "./overview.adapter";
import { TEXT } from "../../../locales";
const props = defineProps<{
center: CenterOverview;
}>();
const centerName = computed(() => props.center.center_name || TEXT.common.fallback);
const enrollmentLabel = computed(() => {
const actual = props.center.enrollment_actual ?? 0;
const target = props.center.enrollment_target ?? 0;
if (!target) return `${actual}`;
return `${actual} / ${target}`;
});
const stages = computed(() =>
STAGE_ORDER.map((stage) => ({
...stage,
status: props.center[stage.key] || "NOT_STARTED",
completedAt: props.center[stage.completedKey],
}))
);
const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase()}`;
</script>
<style scoped>
.center-row {
display: grid;
grid-template-columns: 180px 1fr;
gap: 16px;
padding: 14px 0;
border-bottom: 1px dashed var(--ctms-border-color);
}
.center-row:last-child {
border-bottom: none;
}
.center-meta {
display: flex;
flex-direction: column;
gap: 6px;
}
.center-name {
font-size: 14px;
font-weight: 600;
color: var(--ctms-text-main);
}
.center-enrollment {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--ctms-text-secondary);
}
.enrollment-label {
padding: 2px 8px;
border-radius: 999px;
background-color: var(--ctms-bg-muted);
color: var(--ctms-text-secondary);
font-weight: 600;
}
.center-timeline {
display: flex;
align-items: flex-start;
gap: 8px;
overflow-x: auto;
padding-bottom: 4px;
width: 100%;
min-width: 0;
}
.timeline-segment {
display: flex;
align-items: flex-start;
gap: 8px;
flex: 1 1 0;
min-width: 0;
}
.timeline-segment:last-child {
flex: 0 0 auto;
}
.stage-connector {
flex: 1 1 0;
min-width: 48px;
width: auto;
position: relative;
height: 2px;
background-color: var(--ctms-border-color);
border-radius: 999px;
margin-top: 6px;
}
.stage-connector::after {
content: "";
position: absolute;
right: -2px;
top: 50%;
width: 6px;
height: 6px;
border-top: 2px solid var(--ctms-border-color);
border-right: 2px solid var(--ctms-border-color);
transform: translateY(-50%) rotate(45deg);
}
.connector-completed {
background-color: var(--ctms-success);
}
.connector-in_progress {
background-color: var(--ctms-primary);
}
.connector-blocked {
background-color: var(--ctms-danger);
}
.connector-completed::after {
border-top-color: var(--ctms-success);
border-right-color: var(--ctms-success);
}
.connector-in_progress::after {
border-top-color: var(--ctms-primary);
border-right-color: var(--ctms-primary);
}
.connector-blocked::after {
border-top-color: var(--ctms-danger);
border-right-color: var(--ctms-danger);
}
@media (max-width: 1024px) {
.center-row {
grid-template-columns: 1fr;
}
.center-meta {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
}
</style>
@@ -0,0 +1,304 @@
<template>
<div class="enrollment-chart">
<StateLoading v-if="loading" :rows="5" />
<StateEmpty v-else-if="items.length === 0" :description="emptyText" />
<div v-else class="chart-body">
<div class="chart-scroll">
<div class="chart-plot">
<svg class="chart-svg" :viewBox="`0 0 ${chartWidth} ${chartHeight}`" preserveAspectRatio="xMidYMid meet">
<defs>
<linearGradient :id="gradientId" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#f1f5f9" />
<stop offset="100%" stop-color="#e2e8f0" />
</linearGradient>
<filter :id="shadowId" x="-20%" y="-20%" width="140%" height="160%">
<feDropShadow dx="0" dy="8" stdDeviation="6" flood-color="#3f5d75" flood-opacity="0.18" />
</filter>
</defs>
<rect
class="chart-frame"
x="0.5"
:y="frameTop"
:width="chartWidth - 1"
:height="frameHeight"
rx="12"
/>
<g class="chart-axis">
<line
class="axis-line"
:x1="axisLeft"
:y1="axisTop"
:x2="axisLeft"
:y2="axisBottom"
/>
<line
class="axis-line"
:x1="axisLeft"
:y1="axisBottom"
:x2="axisRight"
:y2="axisBottom"
/>
<g v-for="tick in yTicks" :key="tick.value" class="axis-tick">
<text :x="axisLeft - 12" :y="tickY(tick.value)" class="tick-label">
{{ formatNumber(tick.value) }}
</text>
<line
class="tick-line"
:class="{ minor: tick.minor }"
:x1="axisLeft - 8"
:y1="tickY(tick.value)"
:x2="axisLeft"
:y2="tickY(tick.value)"
/>
</g>
</g>
<g class="chart-bars">
<g v-for="(item, index) in items" :key="item.key" class="bar-group">
<title>{{ item.label }}</title>
<rect
v-if="showTarget"
class="bar-target"
:x="barLeft(index)"
:y="barTop(item.target || 0)"
:width="barWidth"
:height="barHeight(item.target || 0)"
rx="8"
:fill="`url(#${gradientId})`"
/>
<rect
class="bar-actual"
:x="barLeft(index)"
:y="barTop(item.actual)"
:width="barWidth"
:height="barHeight(item.actual)"
rx="8"
:filter="`url(#${shadowId})`"
/>
<text :x="barCenter(index)" :y="valueY(item)" class="bar-value">
<tspan class="bar-value-actual">{{ formatNumber(item.actual) }}</tspan>
<tspan v-if="showTarget" class="bar-value-divider" dx="4">/</tspan>
<tspan v-if="showTarget" class="bar-value-target" dx="4">{{ formatNumber(item.target || 0) }}</tspan>
</text>
<text :x="barCenter(index)" :y="labelY" class="bar-label">
{{ truncateLabel(item.label) }}
</text>
</g>
</g>
</svg>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import StateLoading from "../../../components/StateLoading.vue";
import StateEmpty from "../../../components/StateEmpty.vue";
export type ChartMode = "center" | "month";
export interface EnrollmentBarItem {
key: string;
label: string;
actual: number;
target?: number;
}
const props = withDefaults(
defineProps<{
mode: ChartMode;
items: EnrollmentBarItem[];
loading?: boolean;
emptyText?: string;
}>(),
{
loading: false,
emptyText: "暂无入组数据",
}
);
const showTarget = computed(() => props.mode === "center");
const chartWidth = 960;
const chartHeight = 300;
const axisPadding = {
top: 24,
right: 24,
bottom: 52,
left: 72,
};
const gradientId = `enroll-target-${Math.random().toString(36).slice(2, 8)}`;
const shadowId = `enroll-shadow-${Math.random().toString(36).slice(2, 8)}`;
const maxValue = computed(() => {
if (!props.items.length) return 1;
return props.items.reduce((max, item) => {
const candidate = showTarget.value ? Math.max(item.actual, item.target || 0) : item.actual;
return Math.max(max, candidate);
}, 1);
});
const yTicks = computed(() => {
const max = maxValue.value;
const majorStep = Math.max(1, Math.ceil(max / 4));
const top = majorStep * 4;
const ticks: Array<{ value: number; minor: boolean }> = [];
for (let i = 0; i <= 4; i += 1) {
const value = top - i * majorStep;
ticks.push({ value, minor: false });
if (i < 4) {
ticks.push({ value: value - Math.round(majorStep / 2), minor: true });
}
}
return ticks.filter((tick) => tick.value >= 0).sort((a, b) => b.value - a.value);
});
const axisMax = computed(() => Math.max(1, yTicks.value[0]?.value ?? 1));
const plotWidth = computed(() => chartWidth - axisPadding.left - axisPadding.right);
const plotHeight = computed(() => chartHeight - axisPadding.top - axisPadding.bottom);
const axisLeft = axisPadding.left;
const axisRight = chartWidth - axisPadding.right;
const axisTop = axisPadding.top;
const axisBottom = chartHeight - axisPadding.bottom;
const labelY = axisBottom + 18;
const valueGap = 8;
const valuePadding = 8;
const bandWidth = computed(() => (props.items.length ? plotWidth.value / props.items.length : plotWidth.value));
const barWidth = computed(() => Math.min(48, bandWidth.value * 0.6));
const barCenter = (index: number) => axisLeft + bandWidth.value * index + bandWidth.value / 2;
const barLeft = (index: number) => barCenter(index) - barWidth.value / 2;
const barHeight = (value: number) => {
if (value <= 0) return 0;
return (value / axisMax.value) * plotHeight.value;
};
const barTop = (value: number) => axisTop + plotHeight.value - barHeight(value);
const tickY = (value: number) => axisTop + ((axisMax.value - value) / axisMax.value) * plotHeight.value;
const valueY = (item: EnrollmentBarItem) => {
const anchor = showTarget.value ? Math.max(item.actual, item.target || 0) : item.actual;
return barTop(anchor) - valueGap;
};
const minValueY = computed(() => {
if (!props.items.length) return axisTop;
return Math.min(
...props.items.map((item) => {
const anchor = showTarget.value ? Math.max(item.actual, item.target || 0) : item.actual;
return barTop(anchor) - valueGap;
})
);
});
const extraTop = computed(() => Math.max(0, valuePadding - minValueY.value));
const frameTop = computed(() => 0.5 - extraTop.value);
const frameHeight = computed(() => chartHeight - 1 + extraTop.value);
const truncateLabel = (label: string) => {
if (label.length <= 8) return label;
return `${label.slice(0, 7)}...`;
};
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
</script>
<style scoped>
.enrollment-chart {
width: 100%;
}
.chart-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.chart-scroll {
overflow: hidden;
padding-bottom: 8px;
}
.chart-plot {
border-radius: 12px;
padding: 0;
background-color: transparent;
position: relative;
aspect-ratio: 16 / 5;
min-height: 220px;
}
.chart-svg {
width: 100%;
height: 100%;
display: block;
overflow: visible;
}
.chart-frame {
fill: #ffffff;
stroke: #000000;
stroke-width: 1px;
}
.axis-line,
.tick-line {
stroke: #000000;
stroke-width: 1px;
}
.tick-label {
font-size: 11px;
fill: #000000;
dominant-baseline: middle;
text-anchor: end;
}
.bar-target {
stroke: #e2e8f0;
stroke-width: 1px;
}
.bar-actual {
fill: var(--ctms-primary);
}
.bar-value {
font-size: 12px;
fill: var(--ctms-text-main);
font-weight: 600;
text-anchor: middle;
}
.bar-value-actual {
fill: var(--ctms-text-main);
font-weight: 600;
}
.bar-value-divider,
.bar-value-target {
fill: var(--ctms-text-disabled);
font-weight: 600;
}
.bar-label {
font-size: 12px;
fill: var(--ctms-text-regular);
text-anchor: middle;
dominant-baseline: hanging;
}
.chart-footnote {
font-size: 12px;
color: var(--ctms-text-secondary);
text-align: right;
}
@media (max-width: 768px) {
.bar-label {
font-size: 11px;
}
}
</style>
@@ -0,0 +1,107 @@
<template>
<div class="stage-node" :class="statusClass" :title="tooltip">
<div class="stage-dot"></div>
<div class="stage-label">{{ label }}</div>
<div v-if="completionLabel" class="stage-date">{{ completionLabel }}</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { StageStatus } from "./overview.adapter";
import { displayDate, displayFallback } from "../../../utils/display";
const props = defineProps<{
label: string;
status: StageStatus;
completedAt?: string;
}>();
const statusLabelMap: Record<StageStatus, string> = {
COMPLETED: "已完成",
IN_PROGRESS: "进行中",
NOT_STARTED: "未开始",
BLOCKED: "阻塞/延期",
};
const statusLabel = computed(() => statusLabelMap[props.status] || "未开始");
const completionLabel = computed(() => {
if (props.status !== "COMPLETED") return "";
const label = displayDate(props.completedAt);
return label === displayFallback ? "" : label;
});
const tooltip = computed(() => {
if (!completionLabel.value) return `${props.label} · ${statusLabel.value}`;
return `${props.label} · ${statusLabel.value} · ${completionLabel.value}`;
});
const statusClass = computed(() => `stage-${props.status.toLowerCase()}`);
</script>
<style scoped>
.stage-node {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
min-width: 64px;
}
.stage-dot {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid var(--ctms-border-color);
background-color: #ffffff;
transition: var(--ctms-transition);
}
.stage-label {
font-size: 12px;
color: var(--ctms-text-secondary);
text-align: center;
white-space: nowrap;
}
.stage-date {
font-size: 11px;
color: var(--ctms-text-disabled);
text-align: center;
white-space: nowrap;
}
.stage-completed .stage-dot {
background-color: var(--ctms-success);
border-color: var(--ctms-success);
}
.stage-in_progress .stage-dot {
border-color: var(--ctms-primary);
box-shadow: 0 0 0 3px rgba(63, 93, 117, 0.18);
background-color: #ffffff;
}
.stage-not_started .stage-dot {
border-color: var(--ctms-border-color);
background-color: #f8fafc;
}
.stage-blocked .stage-dot {
border-color: var(--ctms-danger);
background-color: rgba(194, 75, 75, 0.12);
}
.stage-completed .stage-label {
color: var(--ctms-text-main);
font-weight: 600;
}
.stage-in_progress .stage-label {
color: var(--ctms-primary);
font-weight: 600;
}
.stage-blocked .stage-label {
color: var(--ctms-danger);
font-weight: 600;
}
</style>
@@ -0,0 +1,160 @@
export type StageStatus = "NOT_STARTED" | "IN_PROGRESS" | "COMPLETED" | "BLOCKED";
export type StageKey =
| "ethics_status"
| "startup_status"
| "enrollment_status"
| "inspection_status"
| "closeout_status";
export type StageCompletionKey =
| "ethics_completed_at"
| "startup_completed_at"
| "enrollment_completed_at"
| "inspection_completed_at"
| "closeout_completed_at";
export interface CenterOverview {
center_id: string;
center_name: string;
ethics_status: StageStatus;
startup_status: StageStatus;
enrollment_status: StageStatus;
inspection_status: StageStatus;
closeout_status: StageStatus;
enrollment_target: number;
enrollment_actual: number;
ethics_completed_at?: string;
startup_completed_at?: string;
enrollment_completed_at?: string;
inspection_completed_at?: string;
closeout_completed_at?: string;
}
export interface EnrollmentByMonth {
month: string;
count: number;
}
export interface ProjectOverviewResponse {
study_id?: string;
updated_at?: string;
centers?: CenterOverview[];
enrollment_by_month?: EnrollmentByMonth[];
}
export interface ProjectOverviewViewModel {
study_id: string;
updated_at: string;
centers: CenterOverview[];
months: EnrollmentByMonth[];
summary: {
total_target: number;
total_actual: number;
};
}
export const STAGE_ORDER: Array<{
key: StageKey;
label: string;
completedKey: StageCompletionKey;
}> = [
{ key: "ethics_status", label: "伦理审批", completedKey: "ethics_completed_at" },
{ key: "startup_status", label: "启动", completedKey: "startup_completed_at" },
{ key: "enrollment_status", label: "入组", completedKey: "enrollment_completed_at" },
{ key: "inspection_status", label: "稽查", completedKey: "inspection_completed_at" },
{ key: "closeout_status", label: "关中心", completedKey: "closeout_completed_at" },
];
const STATUS_ALIAS: Record<string, StageStatus> = {
NOT_STARTED: "NOT_STARTED",
PENDING: "NOT_STARTED",
TODO: "NOT_STARTED",
IN_PROGRESS: "IN_PROGRESS",
ACTIVE: "IN_PROGRESS",
ONGOING: "IN_PROGRESS",
COMPLETED: "COMPLETED",
DONE: "COMPLETED",
FINISHED: "COMPLETED",
BLOCKED: "BLOCKED",
DELAYED: "BLOCKED",
HOLD: "BLOCKED",
};
const toString = (value: unknown) => (typeof value === "string" ? value : "");
const toNumber = (value: unknown) => {
const num = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(num) || num < 0) return 0;
return num;
};
const normalizeStatus = (value: unknown): StageStatus => {
if (typeof value !== "string") return "NOT_STARTED";
return STATUS_ALIAS[value.toUpperCase()] || "NOT_STARTED";
};
export const adaptProjectOverview = (raw: unknown): ProjectOverviewViewModel => {
const payload = raw && typeof raw === "object" ? (raw as ProjectOverviewResponse) : {};
const centersRaw = Array.isArray(payload.centers) ? payload.centers : [];
const centers = centersRaw.map((center) => {
const enrollmentStatus = normalizeStatus((center as any).enrollment_status);
const enrollmentActualRaw = toNumber((center as any).enrollment_actual);
const hasEnrollmentStage = enrollmentStatus === "IN_PROGRESS" || enrollmentStatus === "COMPLETED";
return {
center_id: toString((center as any).center_id || (center as any).id),
center_name: toString((center as any).center_name || (center as any).name),
ethics_status: normalizeStatus((center as any).ethics_status),
startup_status: normalizeStatus((center as any).startup_status),
enrollment_status: enrollmentStatus,
inspection_status: normalizeStatus((center as any).inspection_status),
closeout_status: normalizeStatus((center as any).closeout_status),
enrollment_target: toNumber((center as any).enrollment_target),
enrollment_actual: hasEnrollmentStage ? enrollmentActualRaw : 0,
ethics_completed_at: toString((center as any).ethics_completed_at),
startup_completed_at: toString((center as any).startup_completed_at),
enrollment_completed_at: toString((center as any).enrollment_completed_at),
inspection_completed_at: toString((center as any).inspection_completed_at),
closeout_completed_at: toString((center as any).closeout_completed_at),
};
});
let monthsRaw = Array.isArray(payload.enrollment_by_month) ? payload.enrollment_by_month : [];
if (monthsRaw.length === 0) {
const merged: Record<string, number> = {};
centersRaw.forEach((center: any) => {
const list = Array.isArray(center.enrollment_by_month) ? center.enrollment_by_month : [];
list.forEach((item: any) => {
const month = toString(item.month);
if (!month) return;
merged[month] = (merged[month] || 0) + toNumber(item.count);
});
});
monthsRaw = Object.entries(merged).map(([month, count]) => ({ month, count }));
}
const months = monthsRaw
.map((item) => ({
month: toString((item as any).month),
count: toNumber((item as any).count),
}))
.filter((item) => item.month)
.sort((a, b) => a.month.localeCompare(b.month));
const summary = centers.reduce(
(acc, center) => {
acc.total_target += center.enrollment_target;
acc.total_actual += center.enrollment_actual;
return acc;
},
{ total_target: 0, total_actual: 0 }
);
return {
study_id: toString((payload as any).study_id),
updated_at: toString((payload as any).updated_at),
centers,
months,
summary,
};
};
@@ -0,0 +1,80 @@
import type { ProjectOverviewResponse } from "./overview.adapter";
// demo / overview only
export const overviewMock: ProjectOverviewResponse = {
study_id: "demo-overview",
updated_at: "2025-06-18",
centers: [
{
center_id: "center-001",
center_name: "中心01",
ethics_status: "COMPLETED",
ethics_completed_at: "2025-01-12",
startup_status: "COMPLETED",
startup_completed_at: "2025-02-03",
enrollment_status: "IN_PROGRESS",
inspection_status: "NOT_STARTED",
closeout_status: "NOT_STARTED",
enrollment_target: 80,
enrollment_actual: 52,
},
{
center_id: "center-002",
center_name: "中心02",
ethics_status: "COMPLETED",
ethics_completed_at: "2025-02-08",
startup_status: "IN_PROGRESS",
enrollment_status: "NOT_STARTED",
inspection_status: "NOT_STARTED",
closeout_status: "NOT_STARTED",
enrollment_target: 60,
enrollment_actual: 0,
},
{
center_id: "center-003",
center_name: "中心03",
ethics_status: "COMPLETED",
ethics_completed_at: "2024-12-28",
startup_status: "COMPLETED",
startup_completed_at: "2025-01-20",
enrollment_status: "COMPLETED",
enrollment_completed_at: "2025-04-05",
inspection_status: "IN_PROGRESS",
closeout_status: "NOT_STARTED",
enrollment_target: 70,
enrollment_actual: 70,
},
{
center_id: "center-004",
center_name: "中心04",
ethics_status: "COMPLETED",
ethics_completed_at: "2025-01-15",
startup_status: "BLOCKED",
enrollment_status: "NOT_STARTED",
inspection_status: "NOT_STARTED",
closeout_status: "NOT_STARTED",
enrollment_target: 50,
enrollment_actual: 0,
},
{
center_id: "center-005",
center_name: "中心05",
ethics_status: "IN_PROGRESS",
startup_status: "NOT_STARTED",
enrollment_status: "NOT_STARTED",
inspection_status: "NOT_STARTED",
closeout_status: "NOT_STARTED",
enrollment_target: 40,
enrollment_actual: 0,
},
],
enrollment_by_month: [
{ month: "2025-01", count: 12 },
{ month: "2025-02", count: 18 },
{ month: "2025-03", count: 24 },
{ month: "2025-04", count: 28 },
{ month: "2025-05", count: 32 },
{ month: "2025-06", count: 30 },
],
};
+217 -41
View File
@@ -6,8 +6,14 @@
<p class="page-subtitle">{{ TEXT.modules.subjectDetail.subtitle }}</p>
</div>
<div class="actions">
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
<template v-if="subjectEditing">
<el-button type="primary" :loading="subjectSaving" @click="saveSubjectEdit">{{ TEXT.common.actions.save }}</el-button>
<el-button @click="cancelSubjectEdit">{{ TEXT.common.actions.cancel }}</el-button>
</template>
<template v-else>
<el-button type="primary" @click="startSubjectEdit">{{ TEXT.common.actions.edit }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</template>
</div>
</div>
@@ -15,11 +21,50 @@
<el-descriptions :column="2" border>
<el-descriptions-item :label="TEXT.common.fields.subjectNo">{{ detail.subject_no || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.site">{{ siteMap[detail.site_id] || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.status">{{ displayEnum(TEXT.enums.subjectStatus, detail.status) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.status">
<el-select v-if="subjectEditing" v-model="subjectForm.status" size="small">
<el-option :label="TEXT.enums.subjectStatus.SCREENING" value="SCREENING" />
<el-option :label="TEXT.enums.subjectStatus.ENROLLED" value="ENROLLED" />
<el-option :label="TEXT.enums.subjectStatus.COMPLETED" value="COMPLETED" />
<el-option :label="TEXT.enums.subjectStatus.DROPPED" value="DROPPED" />
</el-select>
<span v-else>{{ displayEnum(TEXT.enums.subjectStatus, detail.status) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.screeningDate">{{ displayDate(detail.screening_date) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.enrollmentDate">{{ displayDate(detail.enrollment_date) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.completionDate">{{ displayDate(detail.completion_date) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.dropReason" :span="2">{{ detail.drop_reason || TEXT.common.fallback }}</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>
</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>
</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>
</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>
</el-descriptions-item>
</el-descriptions>
</el-card>
@@ -50,24 +95,53 @@
</div>
<el-table :data="visitItems" v-loading="loadingVisits" style="width: 100%">
<el-table-column prop="visit_code" :label="TEXT.common.fields.visitCode" width="120" />
<el-table-column prop="visit_name" :label="TEXT.common.fields.visitName" min-width="140">
<template #default="scope">
{{ displayText(scope.row.visit_name, TEXT.enums.visitName) }}
</template>
</el-table-column>
<el-table-column prop="planned_date" :label="TEXT.common.fields.plannedDate" width="140">
<el-table-column prop="planned_date" :label="TEXT.common.fields.plannedDate" min-width="180">
<template #default="scope">{{ displayDate(scope.row.planned_date) }}</template>
</el-table-column>
<el-table-column prop="actual_date" :label="TEXT.common.fields.actualDate" width="140">
<template #default="scope">{{ displayDate(scope.row.actual_date) }}</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
<template #default="scope">{{ displayEnum(TEXT.enums.visitStatus, scope.row.status) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="200">
<el-table-column prop="actual_date" :label="TEXT.common.fields.actualDate" min-width="180">
<template #default="scope">
<el-button link type="primary" size="small" @click="openVisitDialog(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
<el-button link type="danger" size="small" @click="removeVisit(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
<el-date-picker
v-if="visitEditingRowId === scope.row.id"
v-model="visitRowDraft.actual_date"
type="date"
value-format="YYYY-MM-DD"
size="small"
/>
<span v-else>{{ displayDate(scope.row.actual_date) }}</span>
</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status" min-width="120">
<template #default="scope">
<el-tag
:type="getVisitStatusTagType(getVisitStatus(scope.row, visitEditingRowId === scope.row.id ? visitRowDraft.actual_date : undefined))"
effect="light"
>
{{ displayEnum(TEXT.enums.visitStatus, getVisitStatus(scope.row, visitEditingRowId === scope.row.id ? visitRowDraft.actual_date : undefined)) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="notes" :label="TEXT.common.fields.notes" min-width="200">
<template #default="scope">
<el-input
v-if="visitEditingRowId === scope.row.id"
v-model="visitRowDraft.notes"
size="small"
type="textarea"
:rows="1"
/>
<span v-else>{{ scope.row.notes || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" min-width="160">
<template #default="scope">
<template v-if="visitEditingRowId === scope.row.id">
<el-button link type="primary" size="small" @click="saveVisitRow(scope.row)">{{ TEXT.common.actions.save }}</el-button>
<el-button link size="small" @click="cancelVisitRow">{{ TEXT.common.actions.cancel }}</el-button>
</template>
<template v-else>
<el-button link type="primary" size="small" @click="startVisitRowEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
<el-button link type="danger" size="small" @click="removeVisit(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
</template>
</template>
</el-table-column>
</el-table>
@@ -122,9 +196,6 @@
<el-form-item :label="TEXT.common.fields.visitCode" required>
<el-input v-model="visitForm.visit_code" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.visitName" required>
<el-input v-model="visitForm.visit_name" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.plannedDate">
<el-date-picker v-model="visitForm.planned_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
@@ -139,13 +210,6 @@
<el-form-item :label="TEXT.common.fields.actualDate">
<el-date-picker v-model="visitForm.actual_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.status">
<el-select v-model="visitForm.status">
<el-option :label="TEXT.enums.visitStatus.PLANNED" value="PLANNED" />
<el-option :label="TEXT.enums.visitStatus.DONE" value="DONE" />
<el-option :label="TEXT.enums.visitStatus.MISSED" value="MISSED" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.remark">
<el-input v-model="visitForm.notes" type="textarea" :rows="3" />
</el-form-item>
@@ -201,12 +265,12 @@ import { onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { useStudyStore } from "../../store/study";
import { getSubject } from "../../api/subjects";
import { getSubject, updateSubject } from "../../api/subjects";
import { fetchSites } from "../../api/sites";
import { listSubjectHistories, createSubjectHistory, updateSubjectHistory, deleteSubjectHistory } from "../../api/subjectHistories";
import { fetchVisits, createVisit, updateVisit, deleteVisit } from "../../api/visits";
import { fetchAes, createAe, updateAe, deleteAe } from "../../api/aes";
import { displayDate, displayEnum, displayText } from "../../utils/display";
import { displayDate, displayEnum } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
@@ -222,11 +286,22 @@ const detail = reactive<any>({
site_id: "",
status: "",
screening_date: "",
consent_date: "",
enrollment_date: "",
completion_date: "",
drop_reason: "",
});
const subjectEditing = ref(false);
const subjectSaving = ref(false);
const subjectForm = reactive({
status: "SCREENING",
enrollment_date: "",
consent_date: "",
completion_date: "",
drop_reason: "",
});
const siteMap = ref<Record<string, string>>({});
const activeTab = ref("history");
@@ -243,14 +318,17 @@ const visitItems = ref<any[]>([]);
const loadingVisits = ref(false);
const visitDialogVisible = ref(false);
const visitEditingId = ref<string | null>(null);
const visitEditingRowId = ref<string | null>(null);
const visitRowDraft = reactive({
actual_date: "",
notes: "",
});
const visitForm = reactive<any>({
visit_code: "",
visit_name: "",
planned_date: "",
window_start: "",
window_end: "",
actual_date: "",
status: "PLANNED",
notes: "",
});
@@ -295,6 +373,41 @@ const loadSubject = async () => {
}
};
const startSubjectEdit = () => {
subjectForm.status = detail.status || "SCREENING";
subjectForm.consent_date = detail.consent_date || "";
subjectForm.enrollment_date = detail.enrollment_date || "";
subjectForm.completion_date = detail.completion_date || "";
subjectForm.drop_reason = detail.drop_reason || "";
subjectEditing.value = true;
};
const cancelSubjectEdit = () => {
subjectEditing.value = false;
};
const saveSubjectEdit = async () => {
if (!studyId || !subjectId) return;
subjectSaving.value = true;
try {
const payload = {
status: subjectForm.status || null,
consent_date: subjectForm.consent_date || null,
enrollment_date: subjectForm.enrollment_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;
loadSubject();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
subjectSaving.value = false;
}
};
const loadHistories = async () => {
if (!studyId || !subjectId) return;
loadingHistory.value = true;
@@ -364,28 +477,93 @@ const openVisitDialog = (row?: any) => {
visitEditingId.value = row?.id || null;
if (row) {
visitForm.actual_date = row.actual_date || "";
visitForm.status = row.status || "PLANNED";
visitForm.notes = row.notes || "";
} else {
visitForm.visit_code = "";
visitForm.visit_name = "";
visitForm.planned_date = "";
visitForm.window_start = "";
visitForm.window_end = "";
visitForm.actual_date = "";
visitForm.status = "PLANNED";
visitForm.notes = "";
}
visitDialogVisible.value = true;
};
const startVisitRowEdit = (row: any) => {
visitEditingRowId.value = row?.id || null;
visitRowDraft.actual_date = row?.actual_date || "";
visitRowDraft.notes = row?.notes || "";
};
const cancelVisitRow = () => {
visitEditingRowId.value = null;
};
const saveVisitRow = async (row: any) => {
if (!studyId || !subjectId || !row?.id) return;
try {
const payload = {
actual_date: visitRowDraft.actual_date || null,
notes: visitRowDraft.notes || null,
};
await updateVisit(studyId, subjectId, row.id, payload);
visitEditingRowId.value = null;
loadVisits();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
}
};
const parseDate = (value?: string | null) => {
if (!value) return null;
const date = new Date(value);
if (isNaN(date.getTime())) return null;
return date;
};
const getVisitStatus = (row: any, actualDateOverride?: string) => {
if (row?.status === "CANCELLED") return "CANCELLED";
const actualDate = parseDate(actualDateOverride ?? row?.actual_date);
const windowStart = parseDate(row?.window_start);
const windowEnd = parseDate(row?.window_end);
const plannedDate = parseDate(row?.planned_date);
if (actualDate) {
if ((windowStart && actualDate < windowStart) || (windowEnd && actualDate > windowEnd)) {
return "OVERDUE";
}
return "DONE";
}
const today = new Date();
const deadline = windowEnd || plannedDate;
if (deadline && today > deadline) {
return "LOST";
}
return "PLANNED";
};
const getVisitStatusTagType = (status: string) => {
switch (status) {
case "DONE":
return "success";
case "OVERDUE":
return "warning";
case "LOST":
return "danger";
case "CANCELLED":
return "info";
default:
return "info";
}
};
const saveVisit = async () => {
if (!studyId || !subjectId) return;
try {
if (visitEditingId.value) {
const payload = {
actual_date: visitForm.actual_date || null,
status: visitForm.status || null,
notes: visitForm.notes || null,
};
await updateVisit(studyId, subjectId, visitEditingId.value, payload);
@@ -393,7 +571,6 @@ const saveVisit = async () => {
const payload = {
subject_id: subjectId,
visit_code: visitForm.visit_code,
visit_name: visitForm.visit_name,
planned_date: visitForm.planned_date || null,
window_start: visitForm.window_start || null,
window_end: visitForm.window_end || null,
@@ -484,7 +661,6 @@ const removeAe = async (row: any) => {
}
};
const goEdit = () => router.push(`/subjects/${subjectId}/edit`);
const goBack = () => router.push("/subjects");
onMounted(async () => {
@@ -27,6 +27,14 @@
:disabled="isEdit"
/>
</el-form-item>
<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"
/>
</el-form-item>
<el-form-item v-if="isEdit" :label="TEXT.common.fields.status">
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select">
<el-option :label="TEXT.enums.subjectStatus.SCREENING" value="SCREENING" />
@@ -76,6 +84,7 @@ const form = reactive({
subject_no: "",
site_id: "",
screening_date: "",
consent_date: "",
status: "SCREENING",
enrollment_date: "",
completion_date: "",
@@ -100,6 +109,7 @@ const load = async () => {
subject_no: data.subject_no || "",
site_id: data.site_id || "",
screening_date: data.screening_date || "",
consent_date: data.consent_date || "",
status: data.status || "SCREENING",
enrollment_date: data.enrollment_date || "",
completion_date: data.completion_date || "",
@@ -117,6 +127,7 @@ const submit = async () => {
if (isEdit.value && subjectId.value) {
const payload = {
status: form.status || null,
consent_date: form.consent_date || null,
enrollment_date: form.enrollment_date || null,
completion_date: form.completion_date || null,
drop_reason: form.drop_reason || null,
@@ -129,6 +140,7 @@ const submit = async () => {
subject_no: form.subject_no,
site_id: form.site_id,
screening_date: form.screening_date || null,
consent_date: form.consent_date || null,
};
const { data } = await createSubject(studyId.value, payload);
ElMessage.success(TEXT.common.messages.createSuccess);