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
+555 -103
View File
@@ -471,55 +471,12 @@
</el-tab-pane>
<el-tab-pane v-if="showStep1SummaryTab" label="方案摘要" name="summary">
<el-form v-if="canEditStep1Summary" label-width="120px" class="detail-form" label-position="top">
<el-row :gutter="24">
<el-col :span="6">
<el-form-item :label="TEXT.common.fields.visitTotal">
<el-input-number v-model="form.visit_total" :min="1" :max="50" :step="1" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item :label="TEXT.common.fields.visitIntervalDays">
<el-input-number v-model="form.visit_interval_days" :min="1" :max="365" :step="1" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item :label="TEXT.common.fields.visitWindowStartOffset">
<el-input-number v-model="form.visit_window_start_offset" :min="-365" :max="365" :step="1" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item :label="TEXT.common.fields.visitWindowEndOffset">
<el-input-number v-model="form.visit_window_end_offset" :min="-365" :max="365" :step="1" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="方案摘要说明">
<el-input v-model="form.summary_note" type="textarea" :rows="3" placeholder="用于展示项目信息概览说明" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="研究目标摘要">
<el-input v-model="form.objective_note" type="textarea" :rows="3" placeholder="用于展示研究目标摘要" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<div v-else class="info-grid">
<div
v-for="item in summaryInfoItems"
:key="item.label"
class="info-item"
:class="{ 'is-updated': item.updated && shouldShowPreviewUpdateMark }"
>
<span class="info-label">{{ item.label }}</span>
<span class="info-value-wrap">
<span class="info-value">{{ item.value || "-" }}</span>
<div class="info-group summary-display-group">
<div class="info-group-title-row">
<div class="info-group-title">访视计划</div>
<div class="info-group-title-actions">
<el-tag
v-if="item.updated && shouldShowPreviewUpdateMark"
v-if="isProjectSnapshotFieldUpdated('visit_schedule') && shouldShowPreviewUpdateMark"
size="small"
type="warning"
effect="plain"
@@ -527,7 +484,33 @@
>
已更新
</el-tag>
</span>
<el-button
v-if="!isPublishedView"
type="primary"
class="info-group-edit-btn"
:disabled="!canEditSetup"
@click="startStep1SectionEdit('summary')"
>
编辑
</el-button>
</div>
</div>
<div class="visit-schedule-display">
<div class="visit-schedule-display-head">
<span>访视</span>
<span>基线后天数</span>
<span>访视窗</span>
</div>
<div
v-for="(row, index) in summaryVisitScheduleRows"
:key="`${row.visit_code || 'visit'}-${index}`"
class="visit-schedule-display-row"
>
<span class="visit-schedule-display-code">{{ row.visit_code || "-" }}</span>
<span>{{ row.baseline_offset_days }} </span>
<span>{{ formatVisitWindow(row) }}</span>
</div>
<div v-if="!summaryVisitScheduleRows.length" class="visit-schedule-display-empty">暂无访视计划</div>
</div>
</div>
</el-tab-pane>
@@ -1661,6 +1644,100 @@
</template>
</el-dialog>
<el-dialog
v-model="visitScheduleDialogVisible"
title="编辑访视计划"
width="min(860px, calc(100vw - 32px))"
:close-on-click-modal="false"
:show-close="false"
class="visit-schedule-dialog"
>
<el-form label-width="120px" class="detail-form visit-schedule-dialog-form" label-position="top">
<div class="summary-section-bar">
<el-button plain size="small" @click="addVisitScheduleRow">添加访视</el-button>
</div>
<div class="visit-schedule-editor">
<div class="visit-schedule-head">
<span>访视</span>
<span>基线后天数</span>
<span>窗口前</span>
<span>窗口后</span>
<span />
</div>
<div
v-for="(row, index) in form.visit_schedule"
:key="`${row.visit_code || 'visit'}-${index}`"
class="visit-schedule-row"
:class="{
'is-dragging': visitScheduleDragIndex === index,
'insert-before': isVisitScheduleInsertTarget(index, 'before'),
'insert-after': isVisitScheduleInsertTarget(index, 'after'),
}"
draggable="true"
aria-label="拖拽调整访视顺序"
@dragstart="handleVisitScheduleDragStart(index, $event)"
@dragover.prevent="handleVisitScheduleDragOver(index, $event)"
@drop.prevent="handleVisitScheduleDrop(index)"
@dragenter.prevent="handleVisitScheduleDragOver(index, $event)"
@dragend="handleVisitScheduleDragEnd"
>
<div class="visit-field visit-code-field">
<span class="visit-field-label">访视</span>
<el-select
v-model="row.visit_code"
filterable
allow-create
default-first-option
placeholder="选择或输入访视"
class="w-full"
@change="handleVisitScheduleCodeChange(index, $event)"
>
<el-option-group label="常规访视">
<el-option label="常规访视(自动生成 Vn" :value="regularVisitOptionValue" />
<el-option label="安全性随访(自动生成 Vn" :value="safetyFollowUpVisitOptionValue" />
</el-option-group>
<el-option-group label="特殊访视">
<el-option
v-for="option in specialVisitOptions"
:key="option"
:label="option"
:value="option"
/>
</el-option-group>
</el-select>
</div>
<div class="visit-field">
<span class="visit-field-label">基线后天数</span>
<el-input-number v-model="row.baseline_offset_days" :min="0" :max="3650" :step="1" controls-position="right" class="w-full" />
</div>
<div class="visit-field">
<span class="visit-field-label">窗口前</span>
<el-input-number v-model="row.window_before_days" :min="0" :max="365" :step="1" controls-position="right" class="w-full" />
</div>
<div class="visit-field">
<span class="visit-field-label">窗口后</span>
<el-input-number v-model="row.window_after_days" :min="0" :max="365" :step="1" controls-position="right" class="w-full" />
</div>
<el-button
:icon="Delete"
text
type="danger"
class="visit-schedule-remove"
@click="removeVisitScheduleRow(index)"
/>
</div>
</div>
</el-form>
<template #footer>
<div class="visit-schedule-dialog-footer">
<el-button :disabled="drawerConfirmLoading || submitting" @click="cancelVisitScheduleDialogEdit">取消</el-button>
<el-button type="primary" :loading="drawerConfirmLoading || submitting" :disabled="!canEditSetup" @click="saveVisitScheduleDialogEdit">
保存
</el-button>
</div>
</template>
</el-dialog>
<el-drawer
v-if="projectMilestoneEditorVisible"
v-model="projectMilestoneEditorVisible"
@@ -1984,6 +2061,7 @@ import type {
StudySetupConfigResponse,
StudySetupConfigVersionItem,
StudySetupConfigUpsertPayload,
VisitScheduleItem,
} from "../../types/setupConfig";
import StateLoading from "../../components/StateLoading.vue";
import { useStudyStore } from "../../store/study";
@@ -2145,10 +2223,16 @@ const infoTab = ref<"basic" | "summary">("basic");
const step1EditSection = ref<Step1EditSection>("all");
const isEditing = ref(false);
const drawerClosing = ref(false);
const visitScheduleDialogVisible = ref(false);
const drawerVisible = computed(() => {
if (visitScheduleDialogVisible.value) return false;
if (activeStep.value === 2 || activeStep.value === 4) return false;
return isEditing.value || drawerClosing.value;
});
const visitScheduleDragIndex = ref<number | null>(null);
const visitScheduleDragOverIndex = ref<number | null>(null);
type VisitScheduleInsertPosition = "before" | "after";
const visitScheduleInsertPosition = ref<VisitScheduleInsertPosition>("before");
const drawerCloseTimer = ref<number | undefined>(undefined);
const submitting = ref(false);
const formRef = ref<FormInstance>();
@@ -2311,14 +2395,148 @@ const form = ref({
phase: "",
status: "DRAFT",
scope: "国内",
summary_note: "",
objective_note: "",
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,
visit_schedule: [] as VisitScheduleItem[],
});
const formBaselineSnapshot = ref("");
const regularVisitOptionValue = "__REGULAR_VISIT__";
const safetyFollowUpVisitOptionValue = "__SAFETY_FOLLOW_UP_VISIT__";
const specialVisitOptions = ["筛选访视", "基线访视", "终止治疗", "提前终止"];
const getNextRegularVisitCode = (excludeIndex?: number): string => {
const maxRegularIndex = form.value.visit_schedule.reduce((maxIndex, row, rowIndex) => {
if (rowIndex === excludeIndex) return maxIndex;
const match = /^V(\d+)$/.exec((row.visit_code || "").trim());
if (!match) return maxIndex;
return Math.max(maxIndex, Number(match[1]));
}, 0);
return `V${maxRegularIndex + 1}`;
};
const getNextSafetyFollowUpVisitCode = (excludeIndex?: number): string => {
const maxSafetyFollowUpIndex = form.value.visit_schedule.reduce((maxIndex, row, rowIndex) => {
if (rowIndex === excludeIndex) return maxIndex;
const match = /^安全性随访V(\d+)$/.exec((row.visit_code || "").trim());
if (!match) return maxIndex;
return Math.max(maxIndex, Number(match[1]));
}, 0);
return `安全性随访V${maxSafetyFollowUpIndex + 1}`;
};
const hasDuplicateVisitCode = (visitCode: string, currentIndex: number): boolean => {
const normalizedCode = visitCode.trim();
if (!normalizedCode) return false;
return form.value.visit_schedule.some(
(row, rowIndex) => rowIndex !== currentIndex && (row.visit_code || "").trim() === normalizedCode
);
};
const normalizeVisitSchedule = (rows: VisitScheduleItem[] | undefined | null): VisitScheduleItem[] =>
(rows || []).map((row) => ({
visit_code: (row.visit_code || "").trim(),
baseline_offset_days: Number(row.baseline_offset_days || 0),
window_before_days: Number(row.window_before_days || 0),
window_after_days: Number(row.window_after_days || 0),
}));
const addVisitScheduleRow = () => {
form.value.visit_schedule.push({
visit_code: getNextRegularVisitCode(),
baseline_offset_days: 0,
window_before_days: 0,
window_after_days: 0,
});
};
const removeVisitScheduleRow = (index: number) => {
form.value.visit_schedule.splice(index, 1);
};
const handleVisitScheduleCodeChange = (index: number, value: string) => {
const row = form.value.visit_schedule[index];
if (!row) return;
let nextVisitCode = String(value || "").trim();
if (nextVisitCode === regularVisitOptionValue) {
nextVisitCode = getNextRegularVisitCode(index);
} else if (nextVisitCode === safetyFollowUpVisitOptionValue) {
nextVisitCode = getNextSafetyFollowUpVisitCode(index);
}
if (hasDuplicateVisitCode(nextVisitCode, index)) {
row.visit_code = "";
ElMessage.warning(`访视已存在:${nextVisitCode}`);
return;
}
row.visit_code = nextVisitCode;
};
const handleVisitScheduleDragStart = (index: number, event: DragEvent) => {
visitScheduleDragIndex.value = index;
visitScheduleDragOverIndex.value = index;
visitScheduleInsertPosition.value = "before";
event.dataTransfer?.setData("text/plain", String(index));
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
}
};
const handleVisitScheduleDragOver = (index: number, event: DragEvent) => {
if (visitScheduleDragIndex.value === null) return;
const target = event.currentTarget as HTMLElement | null;
if (target) {
const rect = target.getBoundingClientRect();
visitScheduleInsertPosition.value = event.clientY < rect.top + rect.height / 2 ? "before" : "after";
}
visitScheduleDragOverIndex.value = index;
};
const handleVisitScheduleDrop = (targetIndex: number) => {
const sourceIndex = visitScheduleDragIndex.value;
if (sourceIndex === null) {
handleVisitScheduleDragEnd();
return;
}
let insertIndex = visitScheduleInsertPosition.value === "after" ? targetIndex + 1 : targetIndex;
if (sourceIndex === targetIndex || sourceIndex + 1 === insertIndex) {
handleVisitScheduleDragEnd();
return;
}
const nextRows = [...form.value.visit_schedule];
const [movedRow] = nextRows.splice(sourceIndex, 1);
if (!movedRow) {
handleVisitScheduleDragEnd();
return;
}
if (sourceIndex < insertIndex) {
insertIndex -= 1;
}
nextRows.splice(insertIndex, 0, movedRow);
form.value.visit_schedule = nextRows;
handleVisitScheduleDragEnd();
};
const handleVisitScheduleDragEnd = () => {
visitScheduleDragIndex.value = null;
visitScheduleDragOverIndex.value = null;
visitScheduleInsertPosition.value = "before";
};
const isVisitScheduleInsertTarget = (index: number, position: VisitScheduleInsertPosition) =>
visitScheduleDragIndex.value !== null &&
visitScheduleDragIndex.value !== index &&
visitScheduleDragOverIndex.value === index &&
visitScheduleInsertPosition.value === position;
const formatVisitSchedule = (rows: VisitScheduleItem[] | undefined | null): string => {
const normalized = normalizeVisitSchedule(rows);
if (!normalized.length) return "-";
return normalized
.map(
(row) =>
`${row.visit_code || "-"}:基线+${row.baseline_offset_days}天,窗口-${row.window_before_days}/+${row.window_after_days}`
)
.join("");
};
const formatVisitWindow = (row: VisitScheduleItem): string => `-${row.window_before_days} / +${row.window_after_days}`;
const setupDraft = reactive<SetupConfigDraft>({
projectMilestones: [],
@@ -2354,9 +2572,6 @@ const canEditSetup = computed(
const canMutateDraft = () => canEditSetup.value && !isPublishedView.value;
const canEditStep1BasicGroup = (group: Exclude<Step1EditSection, "all" | "summary">) =>
activeStep.value === 0 && isEditing.value && (step1EditSection.value === "all" || step1EditSection.value === group);
const canEditStep1Summary = computed(
() => activeStep.value === 0 && isEditing.value && (step1EditSection.value === "all" || step1EditSection.value === "summary")
);
const isStep1BasicScopedEdit = computed(
() => activeStep.value === 0 && isEditing.value && ["project", "research", "execution"].includes(step1EditSection.value)
);
@@ -2370,7 +2585,7 @@ const stepActionMode = computed<StepActionMode>(() => {
case 2:
return "edit";
case 0:
return infoTab.value === "summary" ? "edit" : "none";
return "none";
case 1:
return "project-milestone";
case 3:
@@ -2483,14 +2698,9 @@ const serializeFormForCompare = (): string =>
plan_start_date: "",
plan_end_date: "",
planned_enrollment_count: null,
summary_note: form.value.summary_note || "",
objective_note: form.value.objective_note || "",
phase: form.value.phase || "",
status: form.value.status || "",
visit_interval_days: form.value.visit_interval_days,
visit_total: form.value.visit_total,
visit_window_start_offset: form.value.visit_window_start_offset,
visit_window_end_offset: form.value.visit_window_end_offset,
visit_schedule: normalizeVisitSchedule(form.value.visit_schedule),
});
const serializeSetupForCompare = (): string => JSON.stringify(clone(setupDraft));
const buildProjectPublishSnapshotFromStudy = (study: Partial<Study> | null | undefined): ProjectPublishSnapshot => ({
@@ -2512,13 +2722,8 @@ const buildProjectPublishSnapshotFromStudy = (study: Partial<Study> | null | und
plan_end_date: study?.plan_end_date || "",
planned_site_count: study?.planned_site_count ?? null,
planned_enrollment_count: study?.planned_enrollment_count ?? null,
summary_note: study?.summary_note || "",
objective_note: study?.objective_note || "",
status: study?.status || "",
visit_interval_days: study?.visit_interval_days ?? null,
visit_total: study?.visit_total ?? null,
visit_window_start_offset: study?.visit_window_start_offset ?? null,
visit_window_end_offset: study?.visit_window_end_offset ?? null,
visit_schedule: normalizeVisitSchedule(study?.visit_schedule),
});
const buildProjectPublishSnapshot = (): ProjectPublishSnapshot =>
buildProjectPublishSnapshotFromStudy({
@@ -2540,13 +2745,8 @@ const buildProjectPublishSnapshot = (): ProjectPublishSnapshot =>
plan_end_date: form.value.plan_end_date,
planned_site_count: form.value.planned_site_count,
planned_enrollment_count: form.value.planned_enrollment_count,
summary_note: form.value.summary_note,
objective_note: form.value.objective_note,
status: form.value.status,
visit_interval_days: form.value.visit_interval_days,
visit_total: form.value.visit_total,
visit_window_start_offset: form.value.visit_window_start_offset,
visit_window_end_offset: form.value.visit_window_end_offset,
visit_schedule: normalizeVisitSchedule(form.value.visit_schedule),
});
const serializeProjectForPublishCompare = (): string => JSON.stringify(buildProjectPublishSnapshot());
const hasFormUnsavedChanges = computed(() => Boolean(formBaselineSnapshot.value && serializeFormForCompare() !== formBaselineSnapshot.value));
@@ -2774,14 +2974,7 @@ const executionInfoItems = computed<DisplayInfoItem[]>(() => [
},
]);
const summaryInfoItems = computed<DisplayInfoItem[]>(() => [
{ label: "访视总数", value: toDisplayNumber(currentProjectPublishSnapshot.value.visit_total), updated: isProjectSnapshotFieldUpdated("visit_total") },
{ label: "访视间隔(天)", value: toDisplayNumber(currentProjectPublishSnapshot.value.visit_interval_days), updated: isProjectSnapshotFieldUpdated("visit_interval_days") },
{ label: "窗口期起始偏移", value: toDisplayNumber(currentProjectPublishSnapshot.value.visit_window_start_offset), updated: isProjectSnapshotFieldUpdated("visit_window_start_offset") },
{ label: "窗口期结束偏移", value: toDisplayNumber(currentProjectPublishSnapshot.value.visit_window_end_offset), updated: isProjectSnapshotFieldUpdated("visit_window_end_offset") },
{ label: "方案摘要说明", value: currentProjectPublishSnapshot.value.summary_note || "-", updated: isProjectSnapshotFieldUpdated("summary_note") },
{ label: "研究目标摘要", value: currentProjectPublishSnapshot.value.objective_note || "-", updated: isProjectSnapshotFieldUpdated("objective_note") },
]);
const summaryVisitScheduleRows = computed<VisitScheduleItem[]>(() => normalizeVisitSchedule(currentProjectPublishSnapshot.value.visit_schedule));
const rules = ref<FormRules>({
code: [{ required: true, message: requiredMessage(TEXT.common.fields.projectCode), trigger: "blur" }],
@@ -4283,12 +4476,7 @@ const syncForm = (data: Study | null) => {
form.value.phase = data?.phase || "";
form.value.status = data?.status || "DRAFT";
form.value.scope = "国内";
form.value.summary_note = data?.summary_note || "";
form.value.objective_note = data?.objective_note || "";
form.value.visit_interval_days = data?.visit_interval_days ?? null;
form.value.visit_total = data?.visit_total ?? null;
form.value.visit_window_start_offset = data?.visit_window_start_offset ?? null;
form.value.visit_window_end_offset = data?.visit_window_end_offset ?? null;
form.value.visit_schedule = normalizeVisitSchedule(data?.visit_schedule);
formBaselineSnapshot.value = serializeFormForCompare();
};
@@ -4498,6 +4686,9 @@ const startStep1SectionEdit = (section: Exclude<Step1EditSection, "all">) => {
startEdit();
if (isEditing.value) {
step1EditSection.value = section;
if (section === "summary") {
visitScheduleDialogVisible.value = true;
}
}
};
@@ -4512,12 +4703,17 @@ const cancelEdit = () => {
}
isEditing.value = false;
step1EditSection.value = "all";
visitScheduleDialogVisible.value = false;
clearSetupValidationErrors();
formBaselineSnapshot.value = serializeFormForCompare();
};
const closeEditDrawer = () => {
if (!isEditing.value || drawerClosing.value) return;
if (visitScheduleDialogVisible.value) {
cancelEdit();
return;
}
drawerClosing.value = true;
drawerCloseTimer.value = window.setTimeout(() => {
cancelEdit();
@@ -4528,6 +4724,15 @@ const closeEditDrawer = () => {
const closeEditDrawerAfterSave = (onClosed?: () => void) => {
if (!isEditing.value || drawerClosing.value) return;
if (visitScheduleDialogVisible.value) {
visitScheduleDialogVisible.value = false;
isEditing.value = false;
step1EditSection.value = "all";
clearSetupValidationErrors();
formBaselineSnapshot.value = serializeFormForCompare();
onClosed?.();
return;
}
drawerClosing.value = true;
drawerCloseTimer.value = window.setTimeout(() => {
isEditing.value = false;
@@ -4540,6 +4745,32 @@ const closeEditDrawerAfterSave = (onClosed?: () => void) => {
}, 220);
};
const cancelVisitScheduleDialogEdit = () => {
cancelEdit();
};
const saveVisitScheduleDialogEdit = async () => {
if (!canEditSetup.value) return;
drawerConfirmLoading.value = true;
try {
const projectChanged = refreshProjectDirtyState();
if (projectChanged) {
persistProjectDraftToLocal();
} else {
clearLocalProjectDraft();
markServerSyncedIfNoLocalChanges();
}
formBaselineSnapshot.value = serializeFormForCompare();
visitScheduleDialogVisible.value = false;
isEditing.value = false;
step1EditSection.value = "all";
clearSetupValidationErrors();
ElMessage.success("访视计划已保存");
} finally {
drawerConfirmLoading.value = false;
}
};
const handleDrawerConfirm = async () => {
if (!canEditSetup.value) return;
drawerConfirmLoading.value = true;
@@ -4603,14 +4834,9 @@ const saveEdit = async (closeAfterSave = true, showSuccessMessage = true): Promi
plan_end_date: form.value.plan_end_date || null,
planned_site_count: form.value.planned_site_count,
planned_enrollment_count: form.value.planned_enrollment_count,
summary_note: form.value.summary_note?.trim() || null,
objective_note: form.value.objective_note?.trim() || null,
phase: form.value.phase?.trim() || null,
status: form.value.status,
visit_interval_days: form.value.visit_interval_days,
visit_total: form.value.visit_total,
visit_window_start_offset: form.value.visit_window_start_offset,
visit_window_end_offset: form.value.visit_window_end_offset,
visit_schedule: normalizeVisitSchedule(form.value.visit_schedule),
});
project.value = data as Study;
syncFormFromProjectWithoutSetupLink(project.value);
@@ -5225,12 +5451,7 @@ const downloadVersionRaw = (versionItem: StudySetupConfigVersionItem) => {
["项目状态", projectSnapshot?.status ? statusLabel(projectSnapshot.status) : "-"],
["计划中心数", projectSnapshot?.planned_site_count ?? "-"],
["计划入组数", projectSnapshot?.planned_enrollment_count ?? "-"],
["访视总数", projectSnapshot?.visit_total ?? "-"],
["访视间隔(天)", projectSnapshot?.visit_interval_days ?? "-"],
["窗口期起始偏移", projectSnapshot?.visit_window_start_offset ?? "-"],
["窗口期结束偏移", projectSnapshot?.visit_window_end_offset ?? "-"],
["方案摘要说明", projectSnapshot?.summary_note || "-"],
["研究目标摘要", projectSnapshot?.objective_note || "-"],
["访视计划", formatVisitSchedule(projectSnapshot?.visit_schedule)],
];
const step2Headers = ["里程碑", "计划日期", "开始日期", "结束日期", "耗时(天)", "负责人", "状态", "备注"];
@@ -6962,6 +7183,7 @@ onBeforeUnmount(() => {
}
.setup-section {
position: relative;
padding: 8px 12px 12px;
border-bottom: 1px solid #edf2f8;
background: #ffffff;
@@ -7559,6 +7781,12 @@ onBeforeUnmount(() => {
gap: 8px;
}
.info-group-title-actions {
display: flex;
align-items: center;
gap: 10px;
}
.info-group-edit-btn {
min-width: 64px;
height: 28px;
@@ -7674,6 +7902,7 @@ onBeforeUnmount(() => {
.setup-info-tabs {
margin-top: -8px;
position: relative;
}
.setup-section :deep(.el-tabs__item) {
@@ -8017,6 +8246,202 @@ onBeforeUnmount(() => {
margin-bottom: 8px;
}
.summary-section-bar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin: 10px 0 8px;
}
.summary-display-group {
padding-top: 16px;
}
.visit-schedule-display {
width: 100%;
border: 1px solid #e1e9f3;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.visit-schedule-display-head,
.visit-schedule-display-row {
display: grid;
grid-template-columns: minmax(160px, 0.8fr) minmax(160px, 0.8fr) minmax(220px, 1fr);
gap: 16px;
align-items: center;
}
.visit-schedule-display-head {
min-height: 40px;
padding: 0 14px;
background: #f3f7fc;
border-bottom: 1px solid #dce6f2;
color: #35516f;
font-size: 13px;
font-weight: 800;
}
.visit-schedule-display-row {
min-height: 42px;
padding: 0 14px;
border-bottom: 1px solid #edf2f7;
color: #334155;
font-size: 13px;
}
.visit-schedule-display-row:last-of-type {
border-bottom: none;
}
.visit-schedule-display-code {
color: #0f172a;
font-weight: 700;
}
.visit-schedule-display-empty {
padding: 18px 14px;
color: #94a3b8;
font-size: 13px;
text-align: center;
}
.visit-schedule-editor {
width: 100%;
border: 1px solid #e1e9f3;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.visit-schedule-head,
.visit-schedule-row {
display: grid;
grid-template-columns: minmax(220px, 0.9fr) minmax(210px, 1fr) minmax(150px, 0.75fr) minmax(150px, 0.75fr) 48px;
gap: 12px;
align-items: center;
}
.visit-schedule-head {
min-height: 40px;
padding: 0 12px;
background: #f3f7fc;
border-bottom: 1px solid #dce6f2;
color: #35516f;
font-size: 13px;
font-weight: 800;
}
.visit-schedule-row {
position: relative;
padding: 9px 12px;
border-bottom: 1px solid #edf2f7;
cursor: grab;
transition: background-color 0.16s ease, box-shadow 0.16s ease, opacity 0.16s ease;
}
.visit-schedule-row:active {
cursor: grabbing;
}
.visit-schedule-row:last-child {
border-bottom: none;
}
.visit-schedule-row.is-dragging {
background: #f8fbff;
opacity: 0.62;
}
.visit-schedule-row.insert-before::before,
.visit-schedule-row.insert-after::after {
position: absolute;
left: 12px;
right: 12px;
z-index: 2;
height: 3px;
border-radius: 999px;
background: #2f80ed;
box-shadow: 0 0 0 3px rgba(47, 128, 237, 0.14);
content: "";
}
.visit-schedule-row.insert-before::before {
top: -2px;
}
.visit-schedule-row.insert-after::after {
bottom: -2px;
}
.visit-schedule-row.insert-before,
.visit-schedule-row.insert-after {
background: #f8fbff;
}
.visit-code-field {
min-width: 0;
}
.visit-field {
min-width: 0;
}
.visit-field-label {
display: none;
margin-bottom: 5px;
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.visit-schedule-remove {
justify-self: center;
width: 32px;
height: 32px;
}
.visit-schedule-add {
margin-top: 0;
}
.visit-schedule-dialog :deep(.el-dialog) {
border-radius: 10px;
}
.visit-schedule-dialog :deep(.el-dialog__header) {
padding: 18px 20px 12px;
margin: 0;
border-bottom: 1px solid #e6edf7;
}
.visit-schedule-dialog :deep(.el-dialog__title) {
color: #0f172a;
font-size: 18px;
font-weight: 700;
}
.visit-schedule-dialog :deep(.el-dialog__body) {
padding: 14px 20px 18px;
}
.visit-schedule-dialog :deep(.el-dialog__footer) {
padding: 12px 20px;
border-top: 1px solid #e6edf7;
}
.visit-schedule-dialog-form .summary-section-bar {
margin-top: 0;
}
.visit-schedule-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.conflict-readable-diff :deep(.el-table th.el-table__cell) {
background: #f7faff;
color: #2f4672;
@@ -8040,6 +8465,33 @@ onBeforeUnmount(() => {
grid-template-columns: 1fr;
}
.visit-schedule-head {
display: none;
}
.visit-schedule-display-head {
display: none;
}
.visit-schedule-display-row {
grid-template-columns: 1fr;
gap: 4px;
padding: 10px 14px;
}
.visit-schedule-row {
grid-template-columns: 1fr;
gap: 10px;
}
.visit-field-label {
display: block;
}
.visit-schedule-remove {
justify-self: flex-end;
}
.flow-label {
font-size: 14px;
}