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