feat: harden auth and study workflows

- replace plaintext login and unlock requests with RSA-OAEP/AES-GCM encrypted payloads

- add login challenge replay protection, production RSA key validation, and auth tests

- wire compose to environment-driven dev/prod settings without committing local secrets

- update setup-config smoke scripts and Postman docs for encrypted login

- add visit schedule migrations/tests and update study/subject setup workflows
This commit is contained in:
Cheng Zhou
2026-05-08 22:13:12 +08:00
parent a7bbcaa5dc
commit 74feca4467
47 changed files with 2423 additions and 534 deletions
+100 -38
View File
@@ -26,7 +26,7 @@
</div>
<el-card class="unified-shell subject-shell" v-loading="loading">
<el-descriptions :column="2" border>
<el-descriptions class="subject-overview-descriptions" :column="2" border>
<el-descriptions-item :label="TEXT.modules.subjectManagement.screeningNo">{{ 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">
@@ -59,6 +59,16 @@
/>
<span v-else>{{ 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>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.completionDate">
<el-date-picker
v-if="subjectEditing"
@@ -76,14 +86,14 @@
</el-descriptions>
</el-card>
<el-card class="unified-shell subject-shell">
<el-card class="unified-shell subject-shell subject-tabs-shell">
<div class="subject-tabs-action">
<el-button type="primary" :disabled="isReadOnly" @click="currentTabAction.onClick">
{{ currentTabAction.label }}
</el-button>
</div>
<el-tabs v-model="activeTab">
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.history" name="history">
<div class="tab-actions">
<el-button type="primary" :disabled="isReadOnly" @click="openHistoryDialog()">
{{ TEXT.common.actions.newHistory }}
</el-button>
</div>
<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>
@@ -118,11 +128,6 @@
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.visits" name="visits">
<div class="tab-actions">
<el-button type="primary" :disabled="isReadOnly" @click="openVisitDialog()">
{{ TEXT.common.actions.newVisit }}
</el-button>
</div>
<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>
@@ -201,11 +206,6 @@
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.ae" name="ae">
<div class="tab-actions">
<el-button type="primary" :disabled="isReadOnly" @click="openAeDialog()">
{{ TEXT.common.actions.newAe }}
</el-button>
</div>
<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>
@@ -259,11 +259,6 @@
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.pd" name="pd">
<div class="tab-actions">
<el-button type="primary" :disabled="isReadOnly" @click="openPdDialog()">
{{ TEXT.modules.subjectDetail.newPd }}
</el-button>
</div>
<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 />
@@ -455,6 +450,7 @@ const detail = reactive<any>({
screening_date: "",
consent_date: "",
enrollment_date: "",
baseline_date: "",
completion_date: "",
drop_reason: "",
});
@@ -464,6 +460,7 @@ const subjectSaving = ref(false);
const subjectForm = reactive({
status: "SCREENING",
enrollment_date: "",
baseline_date: "",
consent_date: "",
completion_date: "",
drop_reason: "",
@@ -615,6 +612,7 @@ const startSubjectEdit = () => {
subjectForm.status = detail.status || "SCREENING";
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;
@@ -636,13 +634,15 @@ const saveSubjectEdit = async () => {
status: subjectForm.status || 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;
loadSubject();
await loadSubject();
await loadVisits();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
@@ -720,15 +720,7 @@ const loadVisits = async () => {
try {
const { data } = (await fetchVisits(studyId, subjectId)) as any;
const items = Array.isArray(data) ? data : data.items || [];
visitItems.value = items.sort((a: any, b: any) => {
const getNum = (code: any) => {
const text = String(code || "");
if (!text.startsWith("V")) return Number.POSITIVE_INFINITY;
const num = Number(text.slice(1));
return Number.isFinite(num) ? num : Number.POSITIVE_INFINITY;
};
return getNum(a?.visit_code) - getNum(b?.visit_code);
});
visitItems.value = items;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
@@ -1036,6 +1028,20 @@ const openPdDialog = (row?: any) => {
pdDialogVisible.value = true;
};
const currentTabAction = computed(() => {
switch (activeTab.value) {
case "visits":
return { label: TEXT.common.actions.newVisit, onClick: () => openVisitDialog() };
case "ae":
return { label: TEXT.common.actions.newAe, onClick: () => openAeDialog() };
case "pd":
return { label: TEXT.modules.subjectDetail.newPd, onClick: () => openPdDialog() };
case "history":
default:
return { label: TEXT.common.actions.newHistory, onClick: () => openHistoryDialog() };
}
});
const savePd = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
@@ -1123,6 +1129,68 @@ onMounted(async () => {
padding: 0;
}
.subject-tabs-shell {
margin-top: 12px;
border-top: 8px solid #f3f6fb;
}
.subject-tabs-shell {
position: relative;
}
.subject-tabs-shell :deep(.el-tabs__header) {
margin: 0;
padding: 0 160px 0 0;
background: #ffffff;
border-top: 1px solid #e5edf6;
border-bottom: 1px solid #e5edf6;
}
.subject-tabs-shell :deep(.el-tabs__nav-wrap) {
padding-left: 0;
}
.subject-tabs-shell :deep(.el-tabs__item) {
height: 48px;
line-height: 48px;
font-weight: 700;
}
.subject-tabs-shell :deep(.el-tabs__content) {
padding-top: 0;
background: #ffffff;
}
.subject-tabs-action {
position: absolute;
top: 8px;
right: 0;
z-index: 2;
display: flex;
align-items: center;
}
.subject-overview-descriptions :deep(.el-descriptions__table) {
table-layout: fixed;
}
.subject-overview-descriptions :deep(.el-descriptions__label) {
width: 140px;
min-width: 140px;
color: #344258;
font-weight: 700;
}
.subject-overview-descriptions :deep(.el-descriptions__content) {
width: calc(50% - 140px);
min-width: 0;
}
.subject-overview-descriptions :deep(.el-descriptions__cell) {
height: 42px;
vertical-align: middle;
}
.page-title {
margin: 0;
font-size: 22px;
@@ -1140,12 +1208,6 @@ onMounted(async () => {
gap: 8px;
}
.tab-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 10px;
}
.subject-detail-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
+265 -89
View File
@@ -1,86 +1,120 @@
<template>
<div class="page">
<div class="page-header unified-action-bar">
<div>
<h1 class="page-title">{{ isEdit ? TEXT.modules.subjectForm.titleEdit : TEXT.modules.subjectForm.titleNew }}</h1>
<p class="page-subtitle">{{ TEXT.modules.subjectForm.subtitle }}</p>
</div>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div>
<div class="page subject-form-page">
<el-card class="unified-shell subject-form-shell">
<el-form :model="form" label-position="top" class="subject-form">
<div class="form-layout">
<section class="form-section form-section-primary">
<div class="section-heading">
<span class="section-kicker">Subject</span>
<h2>参与者标识</h2>
</div>
<div class="field-grid">
<el-form-item :label="TEXT.modules.subjectManagement.screeningNo" required class="field-span-2">
<el-input
v-model="form.subject_no"
:disabled="isEdit || isReadOnly"
:placeholder="TEXT.common.placeholders.input + TEXT.modules.subjectManagement.screeningNo"
size="large"
/>
</el-form-item>
<el-form-item :label="TEXT.common.fields.site" required class="field-span-2">
<el-select v-model="form.site_id" :disabled="isEdit || isReadOnly" :placeholder="TEXT.common.placeholders.select" size="large">
<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-form-item v-if="isEdit" :label="TEXT.common.fields.status" class="field-span-2">
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" :disabled="isReadOnly" size="large">
<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>
</el-form-item>
</div>
</section>
<el-card class="unified-shell">
<el-form :model="form" label-width="120px">
<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-form-item :label="TEXT.common.fields.site" required>
<el-select v-model="form.site_id" :disabled="isEdit || isReadOnly" :placeholder="TEXT.common.placeholders.select">
<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-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"
/>
</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"
:disabled="isReadOnly"
/>
</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" :disabled="isReadOnly">
<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>
</el-form-item>
<el-form-item v-if="isEdit" :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"
/>
</el-form-item>
<el-form-item v-if="isEdit" :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"
/>
</el-form-item>
<el-form-item v-if="isEdit" :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>
<el-form-item>
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">
<section class="form-section">
<div class="section-heading">
<span class="section-kicker">Timeline</span>
<h2>关键日期</h2>
</div>
<div class="field-grid date-grid">
<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"
size="large"
/>
</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"
:disabled="isReadOnly"
size="large"
/>
</el-form-item>
<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"
size="large"
/>
</el-form-item>
<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"
size="large"
/>
</el-form-item>
<el-form-item v-if="isEdit" :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"
size="large"
/>
</el-form-item>
</div>
</section>
<section v-if="isEdit" class="form-section field-span-all">
<div class="section-heading compact-heading">
<span class="section-kicker">Status</span>
<h2>退出信息</h2>
</div>
<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>
</section>
</div>
<div class="form-actions">
<el-button class="form-cancel-btn" @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" class="form-submit-btn" :loading="saving" :disabled="isReadOnly" @click="submit">
{{ TEXT.common.actions.save }}
</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</el-form-item>
</div>
</el-form>
</el-card>
</div>
@@ -120,6 +154,7 @@ const form = reactive({
consent_date: "",
status: "SCREENING",
enrollment_date: "",
baseline_date: "",
completion_date: "",
drop_reason: "",
});
@@ -145,6 +180,7 @@ const load = async () => {
consent_date: data.consent_date || "",
status: data.status || "SCREENING",
enrollment_date: data.enrollment_date || "",
baseline_date: data.baseline_date || "",
completion_date: data.completion_date || "",
drop_reason: data.drop_reason || "",
});
@@ -166,6 +202,7 @@ const submit = async () => {
status: form.status || null,
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,
};
@@ -178,6 +215,8 @@ const submit = async () => {
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,
};
const { data } = await createSubject(studyId.value, payload);
ElMessage.success(TEXT.common.messages.createSuccess);
@@ -199,27 +238,164 @@ onMounted(async () => {
</script>
<style scoped>
.page {
.subject-form-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-header {
.subject-form-shell {
overflow: hidden;
border: 1px solid #e5edf6;
border-radius: 18px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.05);
}
.subject-form-shell :deep(.el-card__body) {
padding: 0;
}
.subject-form {
background: #ffffff;
}
.form-layout {
display: grid;
grid-template-columns: minmax(320px, 0.78fr) minmax(420px, 1.22fr);
gap: 0;
}
.form-section {
padding: 26px 28px 22px;
border-bottom: 1px solid #edf2f7;
}
.form-section-primary {
border-right: 1px solid #edf2f7;
background: linear-gradient(180deg, #f8fbff 0%, #ffffff 72%);
}
.field-span-all {
grid-column: 1 / -1;
}
.section-heading {
display: flex;
justify-content: space-between;
align-items: flex-end;
flex-direction: column;
gap: 4px;
margin-bottom: 18px;
}
.page-title {
.compact-heading {
margin-bottom: 12px;
}
.section-kicker {
color: #3b82f6;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.section-heading h2 {
margin: 0;
font-size: 22px;
font-weight: 600;
color: #102033;
font-size: 17px;
font-weight: 800;
}
.page-subtitle {
margin: 6px 0 0;
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px 18px;
}
.date-grid {
align-items: start;
}
.field-span-2 {
grid-column: 1 / -1;
}
.subject-form :deep(.el-form-item) {
margin-bottom: 0;
}
.subject-form :deep(.el-form-item__label) {
margin-bottom: 7px;
color: #53657f;
font-size: 13px;
color: var(--ctms-text-secondary);
font-weight: 750;
line-height: 1.2;
}
.subject-form :deep(.el-input__wrapper),
.subject-form :deep(.el-select__wrapper) {
min-height: 44px;
border-radius: 12px;
box-shadow: 0 0 0 1px #dfe7f1 inset;
transition: box-shadow 0.16s ease, background-color 0.16s ease;
}
.subject-form :deep(.el-input__wrapper:hover),
.subject-form :deep(.el-select__wrapper:hover) {
box-shadow: 0 0 0 1px #b8c8dc inset;
}
.subject-form :deep(.el-input__wrapper.is-focus),
.subject-form :deep(.el-select__wrapper.is-focused) {
box-shadow: 0 0 0 1px #3b82f6 inset, 0 0 0 3px rgba(59, 130, 246, 0.12);
}
.subject-form :deep(.el-date-editor),
.subject-form :deep(.el-select),
.subject-form :deep(.el-input) {
width: 100%;
}
.form-actions {
position: sticky;
bottom: 0;
z-index: 1;
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 28px;
border-top: 1px solid #e5edf6;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(10px);
}
.form-submit-btn,
.form-cancel-btn {
min-width: 88px;
height: 40px;
border-radius: 12px;
font-weight: 800;
}
.form-submit-btn {
background: #415b76;
border-color: #415b76;
}
@media (max-width: 960px) {
.form-layout {
grid-template-columns: 1fr;
}
.form-section-primary {
border-right: none;
}
.field-grid {
grid-template-columns: 1fr;
}
.form-actions {
padding: 14px 18px;
}
}
</style>