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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user