feat: refine subject visits and project workflows
Add early termination visit workflow with ordering, non-applicable visit handling, visit window display, and medication adherence support. Extend monitoring visit issue template fields, site scoping, setup draft project info handling, login security UI, attachment behavior, and related tests/migrations.
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readProjectDetail = () => readFileSync(resolve(__dirname, "./ProjectDetail.vue"), "utf8");
|
||||
|
||||
describe("ProjectDetail setup draft publish workflow", () => {
|
||||
it("marks the draft server-synced after a successful server draft save", () => {
|
||||
const source = readProjectDetail();
|
||||
const functionIndex = source.indexOf("const persistSetupDraft = async");
|
||||
const successIndex = source.indexOf("applySetupResponseMeta(data);", functionIndex);
|
||||
const syncedIndex = source.indexOf("markSetupServerSynced();", successIndex);
|
||||
|
||||
expect(functionIndex).toBeGreaterThan(-1);
|
||||
expect(successIndex).toBeGreaterThan(-1);
|
||||
expect(syncedIndex).toBeGreaterThan(successIndex);
|
||||
});
|
||||
|
||||
it("reconciles stale dirty flags before opening the publish dialog", () => {
|
||||
const source = readProjectDetail();
|
||||
const functionIndex = source.indexOf("const publishConfigNow = async");
|
||||
const reconcileIndex = source.indexOf("reconcileDraftSyncStateBeforePublish();", functionIndex);
|
||||
const unsavedGuardIndex = source.indexOf("if (hasSetupDraftUnsavedChanges.value)", functionIndex);
|
||||
|
||||
expect(functionIndex).toBeGreaterThan(-1);
|
||||
expect(reconcileIndex).toBeGreaterThan(functionIndex);
|
||||
expect(unsavedGuardIndex).toBeGreaterThan(reconcileIndex);
|
||||
});
|
||||
|
||||
it("uses an empty project snapshot as the first publish diff baseline", () => {
|
||||
const source = readProjectDetail();
|
||||
const computedIndex = source.indexOf("const projectPublishCompareBase = computed");
|
||||
const emptyBaselineIndex = source.indexOf("return isFirstPublish.value ? emptyProjectInfoDraft() : null;", computedIndex);
|
||||
|
||||
expect(computedIndex).toBeGreaterThan(-1);
|
||||
expect(emptyBaselineIndex).toBeGreaterThan(computedIndex);
|
||||
});
|
||||
|
||||
it("clears the full setup draft including project info", () => {
|
||||
const source = readProjectDetail();
|
||||
const functionIndex = source.indexOf("const handleClearDraft = async");
|
||||
const confirmTextIndex = source.indexOf("确认清空当前立项配置草稿的所有数据?该操作不会影响已发布版本。", functionIndex);
|
||||
const applyServerDataIndex = source.indexOf("applySetupDraft(clone(data.data));", functionIndex);
|
||||
const clearProjectDraftIndex = source.indexOf("clearLocalProjectDraft();", applyServerDataIndex);
|
||||
|
||||
expect(functionIndex).toBeGreaterThan(-1);
|
||||
expect(confirmTextIndex).toBeGreaterThan(functionIndex);
|
||||
expect(applyServerDataIndex).toBeGreaterThan(confirmTextIndex);
|
||||
expect(clearProjectDraftIndex).toBeGreaterThan(applyServerDataIndex);
|
||||
expect(source.indexOf("applySetupDraft(createDefaultSetupDraft(siteOptions.value));", functionIndex)).toBe(-1);
|
||||
});
|
||||
|
||||
it("keeps early termination out of regular visit schedule options", () => {
|
||||
const source = readProjectDetail();
|
||||
|
||||
expect(source).toContain('const specialVisitOptions = ["筛选访视", "基线访视", "终止治疗"];');
|
||||
expect(source).toContain("提前终止不作为常规计划访视配置");
|
||||
});
|
||||
});
|
||||
@@ -1696,7 +1696,7 @@
|
||||
<el-option label="常规访视(自动生成 Vn)" :value="regularVisitOptionValue" />
|
||||
<el-option label="安全性随访(自动生成 Vn)" :value="safetyFollowUpVisitOptionValue" />
|
||||
</el-option-group>
|
||||
<el-option-group label="特殊访视">
|
||||
<el-option-group label="计划特殊访视">
|
||||
<el-option
|
||||
v-for="option in specialVisitOptions"
|
||||
:key="option"
|
||||
@@ -1725,6 +1725,12 @@
|
||||
class="visit-schedule-remove"
|
||||
@click="removeVisitScheduleRow(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="visit-special-rule">
|
||||
<div class="visit-special-rule-title">特殊访视规则</div>
|
||||
<div class="visit-special-rule-body">
|
||||
提前终止不作为常规计划访视配置;受试者发生提前终止时,在访视页通过“提前终止”单独录入,系统会自动关闭后续未完成计划访视。
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
@@ -2044,7 +2050,6 @@ import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "elem
|
||||
import { ArrowLeft, ArrowRight, Promotion, User, OfficeBuilding, Lock, SuccessFilled, ArrowDown, Close, List, EditPen, Connection, FolderOpened, Clock, RefreshLeft, DocumentCopy, Download, Delete } from "@element-plus/icons-vue";
|
||||
import {
|
||||
fetchStudyDetail,
|
||||
updateStudy,
|
||||
} from "../../api/studies";
|
||||
import { listMembers } from "../../api/members";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
@@ -2240,7 +2245,6 @@ const draftReady = ref(false);
|
||||
const suppressDraftWatch = ref(false);
|
||||
const autoSaveTimer = ref<number | undefined>(undefined);
|
||||
const setupDirtySinceLastPersist = ref(false);
|
||||
const projectDirtySinceLastPersist = ref(false);
|
||||
const suppressEnrollmentFieldSync = ref(false);
|
||||
const enrollmentPlanCycle = ref<EnrollmentCycle>("month");
|
||||
const enrollmentTargetsByCycle = ref<Record<EnrollmentCycle, Record<string, number>>>({
|
||||
@@ -2358,9 +2362,7 @@ const publishedProjectSnapshot = ref<ProjectPublishSnapshot | null>(null);
|
||||
const projectPublishCompareFallback = ref<ProjectPublishSnapshot | null>(null);
|
||||
const projectSnapshotAtLoad = ref<ProjectPublishSnapshot | null>(null);
|
||||
const setupServerSnapshot = ref("");
|
||||
const projectServerSnapshot = ref("");
|
||||
const setupLocalDraftSavedAt = ref("");
|
||||
const projectLocalDraftSavedAt = ref("");
|
||||
const setupSaveMeta = ref<{ updatedAt: string; savedBy: string | null; serverSynced: boolean }>({
|
||||
updatedAt: "",
|
||||
savedBy: null,
|
||||
@@ -2400,7 +2402,7 @@ const form = ref({
|
||||
const formBaselineSnapshot = ref("");
|
||||
const regularVisitOptionValue = "__REGULAR_VISIT__";
|
||||
const safetyFollowUpVisitOptionValue = "__SAFETY_FOLLOW_UP_VISIT__";
|
||||
const specialVisitOptions = ["筛选访视", "基线访视", "终止治疗", "提前终止"];
|
||||
const specialVisitOptions = ["筛选访视", "基线访视", "终止治疗"];
|
||||
|
||||
const getNextRegularVisitCode = (excludeIndex?: number): string => {
|
||||
const maxRegularIndex = form.value.visit_schedule.reduce((maxIndex, row, rowIndex) => {
|
||||
@@ -2538,7 +2540,31 @@ const formatVisitSchedule = (rows: VisitScheduleItem[] | undefined | null): stri
|
||||
|
||||
const formatVisitWindow = (row: VisitScheduleItem): string => `-${row.window_before_days} / +${row.window_after_days} 天`;
|
||||
|
||||
const emptyProjectInfoDraft = (): ProjectPublishSnapshot => ({
|
||||
code: "",
|
||||
name: "",
|
||||
project_full_name: "",
|
||||
sponsor: "",
|
||||
protocol_no: "",
|
||||
lead_unit: "",
|
||||
principal_investigator: "",
|
||||
main_pm: "",
|
||||
research_analysis: "",
|
||||
research_product: "",
|
||||
control_product: "",
|
||||
indication: "",
|
||||
research_population: "",
|
||||
research_design: "",
|
||||
plan_start_date: "",
|
||||
plan_end_date: "",
|
||||
planned_site_count: null,
|
||||
planned_enrollment_count: null,
|
||||
status: "",
|
||||
visit_schedule: [],
|
||||
});
|
||||
|
||||
const setupDraft = reactive<SetupConfigDraft>({
|
||||
projectInfo: emptyProjectInfoDraft(),
|
||||
projectMilestones: [],
|
||||
enrollmentPlan: {
|
||||
totalTarget: 0,
|
||||
@@ -2628,10 +2654,10 @@ const currentStepTitle = computed(() => `第${activeStep.value + 1}步:${steps
|
||||
const setupPublishedVersionText = computed(() => {
|
||||
return setupPublishedVersion.value || "v0";
|
||||
});
|
||||
const hasProjectPendingChanges = computed(() => hasFormUnsavedChanges.value || projectDirtySinceLastPersist.value);
|
||||
const hasProjectPendingChanges = computed(() => hasFormUnsavedChanges.value);
|
||||
const draftSyncStatus = computed<DraftSyncStatus>(() => {
|
||||
const formDirty = Boolean(formBaselineSnapshot.value && serializeFormForCompare() !== formBaselineSnapshot.value);
|
||||
if (formDirty || setupDirtySinceLastPersist.value || projectDirtySinceLastPersist.value || Boolean(autoSaveTimer.value)) {
|
||||
if (formDirty || setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value)) {
|
||||
return "DIRTY_UNSAVED";
|
||||
}
|
||||
return setupSaveMeta.value.serverSynced ? "SYNCED" : "LOCAL_ONLY";
|
||||
@@ -2702,7 +2728,14 @@ const serializeFormForCompare = (): string =>
|
||||
status: form.value.status || "",
|
||||
visit_schedule: normalizeVisitSchedule(form.value.visit_schedule),
|
||||
});
|
||||
const serializeSetupForCompare = (): string => JSON.stringify(clone(setupDraft));
|
||||
const setupDraftForPersistence = (): SetupConfigDraft => ({
|
||||
...clone(setupDraft),
|
||||
projectInfo: buildProjectPublishSnapshot(),
|
||||
});
|
||||
const syncProjectInfoIntoSetupDraft = () => {
|
||||
setupDraft.projectInfo = buildProjectPublishSnapshot();
|
||||
};
|
||||
const serializeSetupForCompare = (): string => JSON.stringify(setupDraftForPersistence());
|
||||
const buildProjectPublishSnapshotFromStudy = (study: Partial<Study> | null | undefined): ProjectPublishSnapshot => ({
|
||||
code: study?.code || "",
|
||||
name: study?.name || "",
|
||||
@@ -2750,8 +2783,6 @@ const buildProjectPublishSnapshot = (): ProjectPublishSnapshot =>
|
||||
});
|
||||
const serializeProjectForPublishCompare = (): string => JSON.stringify(buildProjectPublishSnapshot());
|
||||
const hasFormUnsavedChanges = computed(() => Boolean(formBaselineSnapshot.value && serializeFormForCompare() !== formBaselineSnapshot.value));
|
||||
const hasProjectDiffFromServer = (): boolean =>
|
||||
Boolean(projectServerSnapshot.value && serializeFormForCompare() !== projectServerSnapshot.value);
|
||||
const hasSetupDiffFromServer = (): boolean =>
|
||||
Boolean(setupServerSnapshot.value && serializeSetupForCompare() !== setupServerSnapshot.value);
|
||||
const clearPendingAutoSave = () => {
|
||||
@@ -2759,24 +2790,30 @@ const clearPendingAutoSave = () => {
|
||||
window.clearTimeout(autoSaveTimer.value);
|
||||
autoSaveTimer.value = undefined;
|
||||
};
|
||||
const refreshProjectDirtyState = (): boolean => {
|
||||
projectDirtySinceLastPersist.value = hasProjectDiffFromServer();
|
||||
return projectDirtySinceLastPersist.value;
|
||||
};
|
||||
const refreshSetupDirtyState = (): boolean => {
|
||||
setupDirtySinceLastPersist.value = hasSetupDiffFromServer();
|
||||
return setupDirtySinceLastPersist.value;
|
||||
};
|
||||
const markServerSyncedIfNoLocalChanges = () => {
|
||||
if (projectDirtySinceLastPersist.value || setupDirtySinceLastPersist.value || autoSaveTimer.value) return;
|
||||
if (setupDirtySinceLastPersist.value || autoSaveTimer.value) return;
|
||||
if (setupSaveMeta.value.serverSynced) return;
|
||||
setupSaveMeta.value = {
|
||||
...setupSaveMeta.value,
|
||||
serverSynced: true,
|
||||
};
|
||||
};
|
||||
const reconcileDraftSyncStateBeforePublish = () => {
|
||||
const changed = refreshSetupDirtyState();
|
||||
if (changed) return;
|
||||
clearPendingAutoSave();
|
||||
markSetupSynced();
|
||||
if (setupSaveMeta.value.serverSynced) {
|
||||
clearLocalSetupDraft();
|
||||
clearLocalProjectDraft();
|
||||
}
|
||||
};
|
||||
const hasUnsavedChanges = computed(
|
||||
() => hasProjectPendingChanges.value || setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value)
|
||||
() => hasFormUnsavedChanges.value || setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value)
|
||||
);
|
||||
const hasSetupDraftUnsavedChanges = computed(() => setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value));
|
||||
const hasLeaveGuardChanges = computed(
|
||||
@@ -2999,6 +3036,13 @@ const clearSetupValidationErrors = () => {
|
||||
const markSetupSynced = () => {
|
||||
setupDirtySinceLastPersist.value = false;
|
||||
};
|
||||
const markSetupServerSynced = () => {
|
||||
clearPendingAutoSave();
|
||||
setupServerSnapshot.value = serializeSetupForCompare();
|
||||
markSetupSynced();
|
||||
clearLocalSetupDraft();
|
||||
clearLocalProjectDraft();
|
||||
};
|
||||
const updateLastServerSaveMeta = (updatedAt?: string | null, savedBy?: string | null) => {
|
||||
lastServerSaveMeta.value = {
|
||||
updatedAt: formatDisplayTime(updatedAt) || lastServerSaveMeta.value.updatedAt || "-",
|
||||
@@ -3918,6 +3962,7 @@ watch(
|
||||
|
||||
const createDefaultSetupDraft = (_sites: Site[]): SetupConfigDraft => {
|
||||
return {
|
||||
projectInfo: buildProjectPublishSnapshot(),
|
||||
projectMilestones: [],
|
||||
enrollmentPlan: {
|
||||
totalTarget: 0,
|
||||
@@ -3943,6 +3988,7 @@ const isSetupDraftShape = (value: unknown): value is SetupConfigDraft => {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const data = value as Record<string, unknown>;
|
||||
return (
|
||||
(!Object.prototype.hasOwnProperty.call(data, "projectInfo") || isProjectPublishSnapshotShape(data.projectInfo)) &&
|
||||
Array.isArray(data.projectMilestones) &&
|
||||
!!data.enrollmentPlan &&
|
||||
Array.isArray(data.siteMilestones) &&
|
||||
@@ -3963,14 +4009,43 @@ const isProjectPublishSnapshotShape = (value: unknown): value is ProjectPublishS
|
||||
);
|
||||
};
|
||||
|
||||
const applyProjectInfoDraft = (snapshot: ProjectPublishSnapshot) => {
|
||||
suppressEnrollmentFieldSync.value = true;
|
||||
form.value.code = snapshot.code || "";
|
||||
form.value.name = snapshot.name || "";
|
||||
form.value.project_full_name = snapshot.project_full_name || "";
|
||||
form.value.sponsor = snapshot.sponsor || "";
|
||||
form.value.protocol_no = snapshot.protocol_no || "";
|
||||
form.value.lead_unit = snapshot.lead_unit || "";
|
||||
form.value.principal_investigator = snapshot.principal_investigator || "";
|
||||
form.value.main_pm = snapshot.main_pm || "";
|
||||
form.value.research_analysis = snapshot.research_analysis || "";
|
||||
form.value.research_product = snapshot.research_product || "";
|
||||
form.value.control_product = snapshot.control_product || "";
|
||||
form.value.indication = snapshot.indication || "";
|
||||
form.value.research_population = snapshot.research_population || "";
|
||||
form.value.research_design = snapshot.research_design || "";
|
||||
form.value.plan_start_date = snapshot.plan_start_date || "";
|
||||
form.value.plan_end_date = snapshot.plan_end_date || "";
|
||||
form.value.planned_site_count = snapshot.planned_site_count ?? null;
|
||||
form.value.planned_enrollment_count = snapshot.planned_enrollment_count ?? null;
|
||||
form.value.status = snapshot.status || "DRAFT";
|
||||
form.value.visit_schedule = normalizeVisitSchedule(snapshot.visit_schedule);
|
||||
suppressEnrollmentFieldSync.value = false;
|
||||
};
|
||||
|
||||
const applySetupDraft = (draft: SetupConfigDraft) => {
|
||||
suppressDraftWatch.value = true;
|
||||
const projectInfo = isProjectPublishSnapshotShape(draft.projectInfo) ? clone(draft.projectInfo) : buildProjectPublishSnapshot();
|
||||
setupDraft.projectInfo = projectInfo;
|
||||
setupDraft.projectMilestones = normalizeProjectMilestoneRows(clone(draft.projectMilestones));
|
||||
setupDraft.enrollmentPlan = clone(draft.enrollmentPlan);
|
||||
setupDraft.siteMilestones = clone(draft.siteMilestones);
|
||||
setupDraft.siteEnrollmentPlans = normalizeSiteEnrollmentPlans(clone(draft.siteEnrollmentPlans));
|
||||
setupDraft.monitoringStrategies = clone(draft.monitoringStrategies);
|
||||
setupDraft.centerConfirm = clone(draft.centerConfirm);
|
||||
applyProjectInfoDraft(projectInfo);
|
||||
formBaselineSnapshot.value = serializeFormForCompare();
|
||||
suppressDraftWatch.value = false;
|
||||
};
|
||||
|
||||
@@ -3995,7 +4070,7 @@ const persistSetupDraftToLocal = () => {
|
||||
storageKey.value,
|
||||
JSON.stringify({
|
||||
version: DRAFT_VERSION,
|
||||
data: clone(setupDraft),
|
||||
data: setupDraftForPersistence(),
|
||||
savedAt,
|
||||
savedAtMs: nowMs(),
|
||||
ttlMs: LOCAL_DRAFT_TTL_MS,
|
||||
@@ -4020,7 +4095,7 @@ const cacheSetupDraftToLocal = () => {
|
||||
storageKey.value,
|
||||
JSON.stringify({
|
||||
version: DRAFT_VERSION,
|
||||
data: clone(setupDraft),
|
||||
data: setupDraftForPersistence(),
|
||||
savedAt,
|
||||
savedAtMs: nowMs(),
|
||||
ttlMs: LOCAL_DRAFT_TTL_MS,
|
||||
@@ -4033,78 +4108,9 @@ const cacheSetupDraftToLocal = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadLocalProjectDraft = (): LocalDraftLoadResult<Record<string, unknown>> | null => {
|
||||
try {
|
||||
const raw = localStorage.getItem(projectDraftStorageKey.value);
|
||||
if (!raw) return null;
|
||||
return parseLocalDraftEnvelope(raw, (candidate) => {
|
||||
if (!candidate || typeof candidate !== "object") return null;
|
||||
return clone(candidate as Record<string, unknown>);
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const applyProjectDraft = (draft: Record<string, unknown>) => {
|
||||
const keys = Object.keys(form.value) as Array<keyof typeof form.value>;
|
||||
keys.forEach((key) => {
|
||||
if (Object.prototype.hasOwnProperty.call(draft, key)) {
|
||||
(form.value[key] as any) = draft[key as string] as any;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const persistProjectDraftToLocal = () => {
|
||||
try {
|
||||
const savedAt = nowString();
|
||||
localStorage.setItem(
|
||||
projectDraftStorageKey.value,
|
||||
JSON.stringify({
|
||||
version: DRAFT_VERSION,
|
||||
data: clone(form.value),
|
||||
savedAt,
|
||||
savedAtMs: nowMs(),
|
||||
ttlMs: LOCAL_DRAFT_TTL_MS,
|
||||
})
|
||||
);
|
||||
projectLocalDraftSavedAt.value = savedAt;
|
||||
projectDirtySinceLastPersist.value = Boolean(
|
||||
projectServerSnapshot.value && serializeFormForCompare() !== projectServerSnapshot.value
|
||||
);
|
||||
setupSaveMeta.value = {
|
||||
updatedAt: savedAt,
|
||||
savedBy: authStore.user?.full_name || authStore.user?.username || authStore.user?.email || "当前用户",
|
||||
serverSynced: false,
|
||||
};
|
||||
} catch {
|
||||
// ignore local storage errors
|
||||
}
|
||||
};
|
||||
|
||||
const cacheProjectDraftToLocal = () => {
|
||||
try {
|
||||
const savedAt = nowString();
|
||||
localStorage.setItem(
|
||||
projectDraftStorageKey.value,
|
||||
JSON.stringify({
|
||||
version: DRAFT_VERSION,
|
||||
data: clone(form.value),
|
||||
savedAt,
|
||||
savedAtMs: nowMs(),
|
||||
ttlMs: LOCAL_DRAFT_TTL_MS,
|
||||
})
|
||||
);
|
||||
projectLocalDraftSavedAt.value = savedAt;
|
||||
} catch {
|
||||
// ignore local storage errors
|
||||
}
|
||||
};
|
||||
|
||||
const clearLocalProjectDraft = () => {
|
||||
try {
|
||||
localStorage.removeItem(projectDraftStorageKey.value);
|
||||
projectLocalDraftSavedAt.value = "";
|
||||
} catch {
|
||||
// ignore local storage errors
|
||||
}
|
||||
@@ -4154,7 +4160,10 @@ const loadSetupDraftFromServer = async (): Promise<SetupConfigDraft | null> => {
|
||||
savedBy: data.saved_by_name || resolveSavedByDisplay(data.saved_by),
|
||||
serverSynced: true,
|
||||
};
|
||||
return clone(data.data);
|
||||
return {
|
||||
...clone(data.data),
|
||||
projectInfo: isProjectPublishSnapshotShape(data.data.projectInfo) ? clone(data.data.projectInfo) : buildProjectPublishSnapshot(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -4177,6 +4186,7 @@ const setupModuleLabels: Array<{ key: keyof SetupConfigDraft; label: string }> =
|
||||
{ key: "centerConfirm", label: "中心确认" },
|
||||
];
|
||||
const emptySetupDraft: SetupConfigDraft = {
|
||||
projectInfo: emptyProjectInfoDraft(),
|
||||
projectMilestones: [],
|
||||
enrollmentPlan: {
|
||||
totalTarget: 0,
|
||||
@@ -4199,7 +4209,8 @@ const hasBranchPublishDiff = computed(() => {
|
||||
});
|
||||
const projectPublishCompareBase = computed<ProjectPublishSnapshot | null>(() => {
|
||||
const base = resolveProjectPublishCompareBase(publishedProjectSnapshot.value, projectPublishCompareFallback.value);
|
||||
return base ? clone(base) : null;
|
||||
if (base) return clone(base);
|
||||
return isFirstPublish.value ? emptyProjectInfoDraft() : null;
|
||||
});
|
||||
const hasProjectPublishDiff = computed(() => {
|
||||
return hasProjectSnapshotDiff(buildProjectPublishSnapshot(), projectPublishCompareBase.value);
|
||||
@@ -4278,7 +4289,7 @@ const refreshSetupDraftFromServer = async (options?: { silent?: boolean }) => {
|
||||
if (!latest) return false;
|
||||
applySetupDraft(latest);
|
||||
clearSetupValidationErrors();
|
||||
markSetupSynced();
|
||||
markSetupServerSynced();
|
||||
if (!options?.silent) {
|
||||
ElMessage.info("已刷新服务端最新配置");
|
||||
}
|
||||
@@ -4292,26 +4303,20 @@ const persistSetupDraft = async (
|
||||
if (!project.value) return { serverSynced: false, retryable: false };
|
||||
const payload: StudySetupConfigUpsertPayload = {
|
||||
expected_version: setupRevision.value,
|
||||
data: clone(setupDraft),
|
||||
data: setupDraftForPersistence(),
|
||||
force_draft: Boolean(options?.forceDraft),
|
||||
};
|
||||
try {
|
||||
const { data } = await upsertSetupConfig(project.value.id, payload);
|
||||
applySetupResponseMeta(data);
|
||||
clearSetupValidationErrors();
|
||||
markSetupSynced();
|
||||
setupServerSnapshot.value = serializeSetupForCompare();
|
||||
clearLocalSetupDraft();
|
||||
markSetupServerSynced();
|
||||
setupSaveMeta.value = {
|
||||
updatedAt: formatDisplayTime(data.updated_at),
|
||||
savedBy: data.saved_by_name || resolveSavedByDisplay(data.saved_by),
|
||||
serverSynced: true,
|
||||
};
|
||||
updateLastServerSaveMeta(data.updated_at, data.saved_by_name || resolveSavedByDisplay(data.saved_by));
|
||||
if (autoSaveTimer.value) {
|
||||
window.clearTimeout(autoSaveTimer.value);
|
||||
autoSaveTimer.value = undefined;
|
||||
}
|
||||
return { serverSynced: true, retryable: false };
|
||||
} catch (e: any) {
|
||||
if (handleValidationFailure(e, "配置保存失败,请修正后重试")) {
|
||||
@@ -4582,37 +4587,9 @@ const loadProject = async () => {
|
||||
project.value = data as Study;
|
||||
syncFormFromProjectWithoutSetupLink(project.value);
|
||||
projectSnapshotAtLoad.value = buildProjectPublishSnapshot();
|
||||
projectServerSnapshot.value = serializeFormForCompare();
|
||||
const serverProjectSnapshot = serializeFormForCompare();
|
||||
const localProjectDraft = loadLocalProjectDraft();
|
||||
if (localProjectDraft) {
|
||||
const shouldRestore = await confirmRestoreLocalDraft("恢复项目基础信息草稿", localProjectDraft.savedAt, {
|
||||
expired: localProjectDraft.expired,
|
||||
});
|
||||
if (shouldRestore) {
|
||||
applyProjectDraft(localProjectDraft.data);
|
||||
const localSnapshot = serializeFormForCompare();
|
||||
if (localSnapshot !== serverProjectSnapshot) {
|
||||
projectDirtySinceLastPersist.value = true;
|
||||
projectLocalDraftSavedAt.value = localProjectDraft.savedAt;
|
||||
setupSaveMeta.value = {
|
||||
updatedAt: localProjectDraft.savedAt || nowString(),
|
||||
savedBy: authStore.user?.full_name || authStore.user?.username || authStore.user?.email || "当前用户",
|
||||
serverSynced: false,
|
||||
};
|
||||
} else {
|
||||
clearLocalProjectDraft();
|
||||
projectDirtySinceLastPersist.value = false;
|
||||
}
|
||||
} else {
|
||||
clearLocalProjectDraft();
|
||||
projectDirtySinceLastPersist.value = false;
|
||||
}
|
||||
} else {
|
||||
projectDirtySinceLastPersist.value = false;
|
||||
}
|
||||
clearLocalProjectDraft();
|
||||
formBaselineSnapshot.value = serializeFormForCompare();
|
||||
if (setupSaveMeta.value.serverSynced && !projectDirtySinceLastPersist.value) {
|
||||
if (setupSaveMeta.value.serverSynced) {
|
||||
setupDirtySinceLastPersist.value = false;
|
||||
if (autoSaveTimer.value) {
|
||||
window.clearTimeout(autoSaveTimer.value);
|
||||
@@ -4638,7 +4615,6 @@ const refreshProjectDisplayAfterPublish = async () => {
|
||||
const { data } = await fetchStudyDetail(project.value.id);
|
||||
project.value = data as Study;
|
||||
syncFormFromProjectWithoutSetupLink(project.value);
|
||||
projectServerSnapshot.value = serializeFormForCompare();
|
||||
formBaselineSnapshot.value = serializeFormForCompare();
|
||||
if (studyStore.currentStudy?.id === project.value.id) {
|
||||
studyStore.setCurrentStudy({ ...(studyStore.currentStudy as Study), ...project.value } as Study);
|
||||
@@ -4693,14 +4669,7 @@ const startStep1SectionEdit = (section: Exclude<Step1EditSection, "all">) => {
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
syncFormFromProjectWithoutSetupLink(project.value);
|
||||
if (projectDirtySinceLastPersist.value) {
|
||||
const localProjectDraft = loadLocalProjectDraft();
|
||||
if (localProjectDraft) {
|
||||
applyProjectDraft(localProjectDraft.data);
|
||||
formBaselineSnapshot.value = serializeFormForCompare();
|
||||
}
|
||||
}
|
||||
applyProjectInfoDraft(setupDraft.projectInfo);
|
||||
isEditing.value = false;
|
||||
step1EditSection.value = "all";
|
||||
visitScheduleDialogVisible.value = false;
|
||||
@@ -4753,13 +4722,16 @@ const saveVisitScheduleDialogEdit = async () => {
|
||||
if (!canEditSetup.value) return;
|
||||
drawerConfirmLoading.value = true;
|
||||
try {
|
||||
const projectChanged = refreshProjectDirtyState();
|
||||
if (projectChanged) {
|
||||
persistProjectDraftToLocal();
|
||||
syncProjectInfoIntoSetupDraft();
|
||||
const setupChanged = refreshSetupDirtyState();
|
||||
if (setupChanged) {
|
||||
persistSetupDraftToLocal();
|
||||
} else {
|
||||
clearLocalProjectDraft();
|
||||
clearLocalSetupDraft();
|
||||
clearPendingAutoSave();
|
||||
markServerSyncedIfNoLocalChanges();
|
||||
}
|
||||
clearLocalProjectDraft();
|
||||
formBaselineSnapshot.value = serializeFormForCompare();
|
||||
visitScheduleDialogVisible.value = false;
|
||||
isEditing.value = false;
|
||||
@@ -4780,13 +4752,16 @@ const handleDrawerConfirm = async () => {
|
||||
if (formRef.value) {
|
||||
await formRef.value.validate();
|
||||
}
|
||||
const projectChanged = refreshProjectDirtyState();
|
||||
if (projectChanged) {
|
||||
persistProjectDraftToLocal();
|
||||
syncProjectInfoIntoSetupDraft();
|
||||
const setupChanged = refreshSetupDirtyState();
|
||||
if (setupChanged) {
|
||||
persistSetupDraftToLocal();
|
||||
} else {
|
||||
clearLocalProjectDraft();
|
||||
clearLocalSetupDraft();
|
||||
clearPendingAutoSave();
|
||||
markServerSyncedIfNoLocalChanges();
|
||||
}
|
||||
clearLocalProjectDraft();
|
||||
formBaselineSnapshot.value = serializeFormForCompare();
|
||||
saved = true;
|
||||
} else {
|
||||
@@ -4808,62 +4783,10 @@ const handleDrawerConfirm = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const saveEdit = async (closeAfterSave = true, showSuccessMessage = true): Promise<boolean> => {
|
||||
if (!project.value) return false;
|
||||
if (formRef.value) {
|
||||
await formRef.value.validate();
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const { data } = await updateStudy(project.value.id, {
|
||||
code: form.value.code.trim(),
|
||||
name: form.value.name.trim(),
|
||||
project_full_name: form.value.project_full_name?.trim() || null,
|
||||
sponsor: form.value.sponsor?.trim() || null,
|
||||
protocol_no: form.value.protocol_no?.trim() || null,
|
||||
lead_unit: form.value.lead_unit?.trim() || null,
|
||||
principal_investigator: form.value.principal_investigator?.trim() || null,
|
||||
main_pm: form.value.main_pm?.trim() || null,
|
||||
research_analysis: form.value.research_analysis?.trim() || null,
|
||||
research_product: form.value.research_product?.trim() || null,
|
||||
control_product: form.value.control_product?.trim() || null,
|
||||
indication: form.value.indication?.trim() || null,
|
||||
research_population: form.value.research_population?.trim() || null,
|
||||
research_design: form.value.research_design?.trim() || null,
|
||||
plan_start_date: form.value.plan_start_date || null,
|
||||
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,
|
||||
phase: form.value.phase?.trim() || null,
|
||||
status: form.value.status,
|
||||
visit_schedule: normalizeVisitSchedule(form.value.visit_schedule),
|
||||
});
|
||||
project.value = data as Study;
|
||||
syncFormFromProjectWithoutSetupLink(project.value);
|
||||
projectServerSnapshot.value = serializeFormForCompare();
|
||||
projectDirtySinceLastPersist.value = false;
|
||||
clearLocalProjectDraft();
|
||||
formBaselineSnapshot.value = serializeFormForCompare();
|
||||
if (closeAfterSave) {
|
||||
isEditing.value = false;
|
||||
step1EditSection.value = "all";
|
||||
}
|
||||
if (showSuccessMessage) {
|
||||
ElMessage.success("项目信息已保存");
|
||||
}
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
ElMessage.error(getApiErrorMessage(e, TEXT.common.messages.saveFailed));
|
||||
return false;
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const captureProjectCompareFallbackBeforeSave = (): ProjectPublishSnapshot | null => {
|
||||
if (
|
||||
!shouldCaptureProjectCompareFallback({
|
||||
hasProjectPendingChanges: hasProjectPendingChanges.value,
|
||||
hasProjectPendingChanges: hasFormUnsavedChanges.value,
|
||||
hasPublishedProjectSnapshot: Boolean(publishedProjectSnapshot.value),
|
||||
isFirstPublish: isFirstPublish.value,
|
||||
})
|
||||
@@ -4880,11 +4803,11 @@ const applyCapturedProjectCompareFallback = (snapshot: ProjectPublishSnapshot |
|
||||
const saveConfigNow = async (): Promise<boolean> => {
|
||||
if (!canSaveDraftAction.value) return false;
|
||||
const projectSnapshotBeforeSave = captureProjectCompareFallbackBeforeSave();
|
||||
if (hasProjectPendingChanges.value) {
|
||||
const basicSaved = await saveEdit(false, false);
|
||||
if (!basicSaved) return false;
|
||||
if (hasFormUnsavedChanges.value) {
|
||||
syncProjectInfoIntoSetupDraft();
|
||||
setupDirtySinceLastPersist.value = true;
|
||||
}
|
||||
const result = await persistSetupDraftWithRetry(false, { forceDraft: hasProjectPendingChanges.value });
|
||||
const result = await persistSetupDraftWithRetry(false, { forceDraft: true });
|
||||
if (!result.serverSynced) return false;
|
||||
applyCapturedProjectCompareFallback(projectSnapshotBeforeSave);
|
||||
formBaselineSnapshot.value = serializeFormForCompare();
|
||||
@@ -4894,9 +4817,13 @@ const saveConfigNow = async (): Promise<boolean> => {
|
||||
const doPublishConfigNow = async (): Promise<boolean> => {
|
||||
if (!project.value || !canPublishAction.value) return false;
|
||||
try {
|
||||
if (hasProjectPendingChanges.value) {
|
||||
const basicSaved = await saveEdit(false, false);
|
||||
if (!basicSaved) return false;
|
||||
if (hasFormUnsavedChanges.value) {
|
||||
syncProjectInfoIntoSetupDraft();
|
||||
setupDirtySinceLastPersist.value = true;
|
||||
}
|
||||
if (setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value)) {
|
||||
const draftSaved = await persistSetupDraftWithRetry(false, { forceDraft: true });
|
||||
if (!draftSaved.serverSynced) return false;
|
||||
}
|
||||
const { data } = await publishConfig(project.value.id, { expected_version: setupRevision.value });
|
||||
applySetupResponseMeta(data);
|
||||
@@ -4950,6 +4877,7 @@ const doPublishConfigNow = async (): Promise<boolean> => {
|
||||
|
||||
const publishConfigNow = async () => {
|
||||
if (!project.value || !canManageSetup.value) return;
|
||||
reconcileDraftSyncStateBeforePublish();
|
||||
if (hasSetupDraftUnsavedChanges.value) {
|
||||
ElMessage.warning("当前存在未上传的本地草稿,请先点击“保存配置”");
|
||||
return;
|
||||
@@ -5734,7 +5662,7 @@ const handleClearDraft = async () => {
|
||||
if (!project.value || !canManageSetup.value) return;
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
"确认清空当前立项配置草稿(第2-7步)?该操作不会影响已发布版本,也不会影响第1步项目信息主数据。",
|
||||
"确认清空当前立项配置草稿的所有数据?该操作不会影响已发布版本。",
|
||||
"清空草稿确认",
|
||||
{
|
||||
type: "warning",
|
||||
@@ -5751,10 +5679,11 @@ const handleClearDraft = async () => {
|
||||
expected_version: setupRevision.value,
|
||||
});
|
||||
applySetupResponseMeta(data);
|
||||
applySetupDraft(createDefaultSetupDraft(siteOptions.value));
|
||||
applySetupDraft(clone(data.data));
|
||||
clearSetupValidationErrors();
|
||||
markSetupSynced();
|
||||
clearLocalSetupDraft();
|
||||
clearLocalProjectDraft();
|
||||
setupViewMode.value = "draft";
|
||||
setupSaveMeta.value = {
|
||||
updatedAt: formatDisplayTime(data.updated_at),
|
||||
@@ -5764,10 +5693,7 @@ const handleClearDraft = async () => {
|
||||
updateLastServerSaveMeta(data.updated_at, data.saved_by_name || resolveSavedByDisplay(data.saved_by));
|
||||
await refreshSetupDraftFromServer({ silent: true });
|
||||
await loadSetupVersionHistory();
|
||||
if (activeStep.value === 0) {
|
||||
activeStep.value = 1;
|
||||
}
|
||||
ElMessage.success(`草稿已清空(第2-7步已重置,当前发布仍为 ${setupPublishedVersionText.value})`);
|
||||
ElMessage.success(`草稿已清空(当前发布仍为 ${setupPublishedVersionText.value})`);
|
||||
} catch (e: any) {
|
||||
if (e?.response?.status === 409) {
|
||||
ElMessage.warning("清空失败:版本冲突,正在刷新最新配置");
|
||||
@@ -8407,6 +8333,27 @@ onBeforeUnmount(() => {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.visit-special-rule {
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #dce6f2;
|
||||
border-radius: 8px;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.visit-special-rule-title {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.visit-special-rule-body {
|
||||
margin-top: 6px;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.visit-schedule-dialog :deep(.el-dialog) {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user