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,32 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const apiGet = vi.fn();
|
||||
const apiPost = vi.fn();
|
||||
const apiDelete = vi.fn();
|
||||
|
||||
vi.mock("./axios", () => ({
|
||||
apiGet,
|
||||
apiPost,
|
||||
apiDelete,
|
||||
}));
|
||||
|
||||
describe("attachments api", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uses canonical collection URLs with trailing slash", async () => {
|
||||
const { fetchAttachments, uploadAttachment } = await import("./attachments");
|
||||
const file = new File(["x"], "x.txt", { type: "text/plain" });
|
||||
|
||||
fetchAttachments("study-1", "startup_feasibility", "entity-1");
|
||||
uploadAttachment("study-1", "startup_feasibility", "entity-1", file);
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith("/api/v1/studies/study-1/startup_feasibility/entity-1/attachments/");
|
||||
expect(apiPost).toHaveBeenCalledWith(
|
||||
"/api/v1/studies/study-1/startup_feasibility/entity-1/attachments/",
|
||||
expect.any(FormData),
|
||||
expect.objectContaining({ headers: { "Content-Type": "multipart/form-data" } })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { apiGet, apiPost, apiDelete } from "./axios";
|
||||
|
||||
export const fetchAttachments = (studyId: string, entityType: string, entityId: string) =>
|
||||
apiGet(`/api/v1/studies/${studyId}/${entityType}/${entityId}/attachments`);
|
||||
apiGet(`/api/v1/studies/${studyId}/${entityType}/${entityId}/attachments/`);
|
||||
|
||||
export const uploadAttachment = (
|
||||
studyId: string,
|
||||
@@ -12,7 +12,7 @@ export const uploadAttachment = (
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return apiPost(`/api/v1/studies/${studyId}/${entityType}/${entityId}/attachments`, formData, {
|
||||
return apiPost(`/api/v1/studies/${studyId}/${entityType}/${entityId}/attachments/`, formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
...options,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
|
||||
import { apiDelete, apiGet, apiPatch, apiPost, type ApiRequestConfig } from "./axios";
|
||||
|
||||
export const fetchVisits = (studyId: string, subjectId: string) =>
|
||||
apiGet(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/`);
|
||||
@@ -6,6 +6,13 @@ export const fetchVisits = (studyId: string, subjectId: string) =>
|
||||
export const createVisit = (studyId: string, subjectId: string, payload: Record<string, any>) =>
|
||||
apiPost(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/`, payload);
|
||||
|
||||
export const createEarlyTerminationVisit = (
|
||||
studyId: string,
|
||||
subjectId: string,
|
||||
payload: Record<string, any>,
|
||||
config?: ApiRequestConfig
|
||||
) => apiPost(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/early-termination`, payload, config);
|
||||
|
||||
export const updateVisit = (studyId: string, subjectId: string, visitId: string, payload: Record<string, any>) =>
|
||||
apiPatch(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/${visitId}`, payload);
|
||||
|
||||
|
||||
@@ -101,9 +101,6 @@ const onSubmit = async () => {
|
||||
sort_order: form.sort_order,
|
||||
is_active: form.is_active,
|
||||
};
|
||||
console.log('[FaqCategoryForm] payload:', payload);
|
||||
console.log('[FaqCategoryForm] isEdit:', isEdit.value);
|
||||
console.log('[FaqCategoryForm] study.currentStudy:', study.currentStudy);
|
||||
if (isEdit.value && props.category) {
|
||||
await updateFaqCategory(props.category.id, payload);
|
||||
} else {
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-dialog append-to=".layout-main .content-wrapper" v-model="previewVisible" :title="previewTitle" width="720px" :close-on-click-modal="false">
|
||||
<el-dialog v-if="previewVisible" append-to-body v-model="previewVisible" :title="previewTitle" width="720px" :close-on-click-modal="false">
|
||||
<div v-if="previewError" class="preview-error">
|
||||
{{ previewError }}
|
||||
</div>
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface CenterConfirmDraft {
|
||||
}
|
||||
|
||||
export interface SetupConfigDraft {
|
||||
projectInfo: ProjectPublishSnapshot;
|
||||
projectMilestones: ProjectMilestoneDraft[];
|
||||
enrollmentPlan: EnrollmentPlanDraft;
|
||||
siteMilestones: SiteMilestoneDraft[];
|
||||
|
||||
@@ -32,6 +32,28 @@ const statusLabelMap: Record<string, string> = {
|
||||
};
|
||||
|
||||
const setupFieldLabelMap: Record<keyof SetupConfigDraft, Record<string, string>> = {
|
||||
projectInfo: {
|
||||
code: "项目编号",
|
||||
name: "项目简称",
|
||||
project_full_name: "项目全称",
|
||||
sponsor: "申办方",
|
||||
protocol_no: "方案号",
|
||||
lead_unit: "组长单位",
|
||||
principal_investigator: "主要研究者",
|
||||
main_pm: "主PM",
|
||||
research_analysis: "研究分期",
|
||||
research_product: "研究产品",
|
||||
control_product: "对照产品",
|
||||
indication: "适应症",
|
||||
research_population: "研究人群",
|
||||
research_design: "研究设计",
|
||||
status: "项目状态",
|
||||
plan_start_date: "计划开始日期",
|
||||
plan_end_date: "计划结束日期",
|
||||
planned_site_count: "计划中心数",
|
||||
planned_enrollment_count: "计划入组例数",
|
||||
visit_schedule: "访视计划",
|
||||
},
|
||||
projectMilestones: {
|
||||
id: "ID",
|
||||
name: "里程碑",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ProjectPublishSnapshot } from "../types/setupConfig";
|
||||
import { buildProjectDiffRows } from "./setupPublishWorkflow";
|
||||
import { serializeDiffValue } from "./setupDiffRows";
|
||||
|
||||
const emptyProjectSnapshot = (): 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: [],
|
||||
});
|
||||
|
||||
describe("setup publish workflow project diff rows", () => {
|
||||
it("shows visit schedule changes under the summary module", () => {
|
||||
const current = emptyProjectSnapshot();
|
||||
current.visit_schedule = [
|
||||
{
|
||||
visit_code: "V1",
|
||||
baseline_offset_days: 0,
|
||||
window_before_days: 0,
|
||||
window_after_days: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const rows = buildProjectDiffRows(current, emptyProjectSnapshot(), serializeDiffValue);
|
||||
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
moduleLabel: "方案摘要",
|
||||
path: "访视计划",
|
||||
changeType: "修改",
|
||||
localValue: "已配置列表(1项)",
|
||||
serverValue: "已配置列表(0项)",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not treat equivalent visit schedule arrays as changed", () => {
|
||||
const current = emptyProjectSnapshot();
|
||||
const base = emptyProjectSnapshot();
|
||||
current.visit_schedule = [
|
||||
{
|
||||
visit_code: "V1",
|
||||
baseline_offset_days: 0,
|
||||
window_before_days: 0,
|
||||
window_after_days: 3,
|
||||
},
|
||||
];
|
||||
base.visit_schedule = [
|
||||
{
|
||||
visit_code: "V1",
|
||||
baseline_offset_days: 0,
|
||||
window_before_days: 0,
|
||||
window_after_days: 3,
|
||||
},
|
||||
];
|
||||
|
||||
expect(buildProjectDiffRows(current, base, serializeDiffValue)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -122,6 +122,34 @@ const PROJECT_FIELD_LABEL_MAP: Record<keyof ProjectPublishSnapshot, string> = {
|
||||
visit_schedule: "访视计划",
|
||||
};
|
||||
|
||||
const PROJECT_FIELD_MODULE_LABEL_MAP: Record<keyof ProjectPublishSnapshot, string> = {
|
||||
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: "执行信息",
|
||||
planned_enrollment_count: "执行信息",
|
||||
status: "执行信息",
|
||||
visit_schedule: "方案摘要",
|
||||
};
|
||||
|
||||
const isProjectFieldValueEqual = (currentValue: unknown, baseValue: unknown): boolean => {
|
||||
if (currentValue === baseValue) return true;
|
||||
return JSON.stringify(currentValue ?? null) === JSON.stringify(baseValue ?? null);
|
||||
};
|
||||
|
||||
export const buildProjectDiffRows = (
|
||||
currentSnapshot: ProjectPublishSnapshot,
|
||||
baseSnapshot: ProjectPublishSnapshot | null,
|
||||
@@ -130,9 +158,9 @@ export const buildProjectDiffRows = (
|
||||
if (!baseSnapshot) return [];
|
||||
const rows: SetupDiffRow[] = [];
|
||||
(Object.keys(PROJECT_FIELD_LABEL_MAP) as Array<keyof ProjectPublishSnapshot>).forEach((key) => {
|
||||
if (currentSnapshot[key] === baseSnapshot[key]) return;
|
||||
if (isProjectFieldValueEqual(currentSnapshot[key], baseSnapshot[key])) return;
|
||||
rows.push({
|
||||
moduleLabel: "项目信息",
|
||||
moduleLabel: PROJECT_FIELD_MODULE_LABEL_MAP[key],
|
||||
path: PROJECT_FIELD_LABEL_MAP[key],
|
||||
changeType: "修改",
|
||||
localValue: serializeValue(currentSnapshot[key]),
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readLoginView = () => readFileSync(resolve(__dirname, "./Login.vue"), "utf8");
|
||||
|
||||
describe("Login protocol agreement", () => {
|
||||
it("requires protocol agreement before calling login", () => {
|
||||
const source = readLoginView();
|
||||
const protocolGuardIndex = source.indexOf("!form.agreeProtocol");
|
||||
const loginCallIndex = source.indexOf("auth.login(form.email, form.password)");
|
||||
|
||||
expect(source).toContain('v-model="form.agreeProtocol"');
|
||||
expect(source).toContain("我已阅读并同意");
|
||||
expect(source).toContain("请先阅读并同意用户协议");
|
||||
expect(protocolGuardIndex).toBeGreaterThan(-1);
|
||||
expect(loginCallIndex).toBeGreaterThan(-1);
|
||||
expect(protocolGuardIndex).toBeLessThan(loginCallIndex);
|
||||
});
|
||||
|
||||
it("offers remember password through browser credentials without local password storage", () => {
|
||||
const source = readLoginView();
|
||||
|
||||
expect(source).toContain('v-model="form.rememberPassword"');
|
||||
expect(source).toContain("记住密码");
|
||||
expect(source).toContain('autocomplete="username"');
|
||||
expect(source).toContain('name="username"');
|
||||
expect(source).toContain('name="password"');
|
||||
expect(source).toContain("tryLoadBrowserCredential");
|
||||
expect(source).toContain("tryStoreBrowserCredential");
|
||||
expect(source).toContain("navigator.credentials");
|
||||
expect(source).toContain("password: true");
|
||||
expect(source).not.toContain('localStorage.setItem("ctms_saved_password"');
|
||||
expect(source).not.toContain("localStorage.setItem('ctms_saved_password'");
|
||||
});
|
||||
|
||||
it("opens a CTMS-specific protocol dialog from the protocol text", () => {
|
||||
const source = readLoginView();
|
||||
|
||||
expect(source).toContain('v-model="protocolDialogVisible"');
|
||||
expect(source).toContain('@click.stop.prevent="openProtocolDialog"');
|
||||
expect(source).toContain("protocolSections");
|
||||
expect(source).toContain("临床试验数据与合规");
|
||||
expect(source).toContain("项目、中心、受试者、访视、AE/SAE、PD");
|
||||
expect(source).toContain("权限与审计");
|
||||
expect(source).toContain("confirmProtocol");
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,8 @@
|
||||
type="email"
|
||||
placeholder="请输入邮箱"
|
||||
size="large"
|
||||
autocomplete="email"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
class="login-input"
|
||||
>
|
||||
<template #prefix>
|
||||
@@ -62,6 +63,7 @@
|
||||
placeholder="请输入密码"
|
||||
show-password
|
||||
size="large"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
class="login-input"
|
||||
>
|
||||
@@ -74,6 +76,21 @@
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<div class="login-options">
|
||||
<el-checkbox v-model="form.rememberPassword" class="remember-checkbox">
|
||||
<span class="remember-text">记住密码</span>
|
||||
</el-checkbox>
|
||||
<el-checkbox v-model="form.agreeProtocol" class="protocol-checkbox">
|
||||
<span class="protocol-text">
|
||||
我已阅读并同意
|
||||
<button type="button" class="protocol-link" @click.stop.prevent="openProtocolDialog">
|
||||
《用户协议》
|
||||
</button>
|
||||
登录即代表同意当前协议
|
||||
</span>
|
||||
</el-checkbox>
|
||||
</div>
|
||||
|
||||
<el-button type="primary" :loading="loading" @click="onSubmit" size="large" class="login-btn">
|
||||
登 录
|
||||
</el-button>
|
||||
@@ -85,6 +102,27 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="protocolDialogVisible"
|
||||
title="用户协议"
|
||||
width="860px"
|
||||
class="protocol-dialog"
|
||||
append-to-body
|
||||
align-center
|
||||
>
|
||||
<div class="protocol-content">
|
||||
<section v-for="section in protocolSections" :key="section.title" class="protocol-section">
|
||||
<h3>{{ section.title }}</h3>
|
||||
<p v-for="paragraph in section.paragraphs" :key="paragraph">{{ paragraph }}</p>
|
||||
</section>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button type="primary" size="large" class="protocol-confirm-btn" @click="confirmProtocol">
|
||||
确 定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -99,11 +137,17 @@ import { consumeLogoutReason, LOGOUT_REASON_TIMEOUT } from "../session/sessionMa
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const REMEMBER_PASSWORD_KEY = "ctms_remember_password";
|
||||
|
||||
type PasswordCredentialConstructor = new (data: { id: string; password: string; name?: string }) => Credential;
|
||||
type StoredPasswordCredential = Credential & { id?: string; password?: string };
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const form = reactive({
|
||||
email: "",
|
||||
password: "",
|
||||
rememberPassword: false,
|
||||
agreeProtocol: false,
|
||||
});
|
||||
|
||||
const rules: FormRules<typeof form> = {
|
||||
@@ -115,22 +159,118 @@ const rules: FormRules<typeof form> = {
|
||||
};
|
||||
|
||||
const loading = ref(false);
|
||||
const protocolDialogVisible = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
const protocolSections = [
|
||||
{
|
||||
title: "1. 服务条款",
|
||||
paragraphs: [
|
||||
"本系统为 CTMS 临床试验管理系统,用于支持临床试验项目的账号治理、项目与中心管理、项目里程碑、立项与伦理、启动会与培训授权、受试者管理、访视、AE/SAE、PD、风险问题、费用、药品物资、文件版本、知识库与审计日志等业务协同。",
|
||||
"用户应在所属机构授权范围内使用本系统。系统中的流程提醒、状态跟踪和数据汇总用于辅助项目管理,不替代申办方、研究中心、CRA、PM、PV、IMP 或管理员按照法规、方案、SOP 和合同约定应履行的专业判断与职责。",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "2. 账号安全",
|
||||
paragraphs: [
|
||||
"用户应妥善保管登录账号、密码和浏览器会话,不得转让、出借、共享账号,不得使用他人账号访问项目数据。因账号保管不当造成的数据泄露、误操作或审计异常,由账号所属用户及其机构承担相应责任。",
|
||||
"系统支持邮箱密码登录、账号审核、启用停用、项目内角色授权和操作级权限控制。用户发现账号异常、权限错误、设备遗失或疑似未授权访问时,应立即联系管理员处理。",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "3. 临床试验数据与合规",
|
||||
paragraphs: [
|
||||
"用户在系统中录入、上传、维护或导出的项目、中心、受试者、访视、AE/SAE、PD、伦理、培训、合同费用、特殊费用、药品流向、设备台账、文件版本和知识库资料,应确保来源合法、内容真实、准确、完整、及时,并符合适用的 GCP、研究方案、伦理批件、SOP、数据保护和保密要求。",
|
||||
"涉及受试者、研究者、中心联系人、医学事件、财务附件、合同文件或其他敏感信息时,用户应遵循最小必要原则,不得上传与当前项目管理无关的数据,不得通过截图、导出、复制或外部工具进行未授权传播。",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "4. 权限与审计",
|
||||
paragraphs: [
|
||||
"系统按照管理员、PM、CRA、PV、IMP 及普通成员等角色提供不同操作能力。同一账号在不同项目中的角色可能不同,用户仅可在被授权项目和中心范围内执行查看、新增、编辑、审批、关闭、导出等操作。",
|
||||
"系统会记录关键业务操作和管理操作,包括但不限于账号、项目、成员、中心、AE、费用、文件、审计导出等事件。用户理解并同意这些日志可用于安全追踪、合规核查、问题复盘和内部管理。",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "5. 隐私与数据保护",
|
||||
paragraphs: [
|
||||
"平台将按照业务需要处理用户账号信息、项目角色、操作记录以及临床试验管理过程中产生的业务数据,并采取访问控制、登录加密、会话管理和审计追踪等措施保护数据安全。",
|
||||
"除法律法规、监管要求、机构授权或项目管理必要场景外,平台不会主动向无关第三方披露业务数据。用户应自行确认其录入和上传的数据已取得必要授权或具备合法处理依据。",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "6. 免责声明",
|
||||
paragraphs: [
|
||||
"因网络故障、浏览器兼容性、用户设备异常、第三方服务中断、不可抗力或用户误操作导致的服务中断、数据延迟、展示异常或操作失败,平台将在合理范围内协助排查,但不承担超出适用法律和合同约定范围的责任。",
|
||||
"用户继续登录和使用系统,即表示已阅读、理解并同意本协议。若不同意本协议内容,应停止登录并联系管理员或所属机构负责人处理。",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
onMounted(async () => {
|
||||
const logoutReason = consumeLogoutReason();
|
||||
if (logoutReason === LOGOUT_REASON_TIMEOUT) {
|
||||
ElMessage.warning("长时间未操作,已自动退出登录,请重新登录");
|
||||
}
|
||||
form.email = localStorage.getItem("ctms_last_login_email") || "";
|
||||
form.rememberPassword = localStorage.getItem(REMEMBER_PASSWORD_KEY) === "true";
|
||||
await tryLoadBrowserCredential();
|
||||
});
|
||||
|
||||
const tryLoadBrowserCredential = async () => {
|
||||
if (!form.rememberPassword || !navigator.credentials?.get) return;
|
||||
|
||||
try {
|
||||
const credential = (await navigator.credentials.get({
|
||||
password: true,
|
||||
mediation: "optional",
|
||||
} as CredentialRequestOptions)) as StoredPasswordCredential | null;
|
||||
if (!credential?.password) return;
|
||||
form.email = credential.id || form.email;
|
||||
form.password = credential.password;
|
||||
} catch {
|
||||
// 浏览器可能不支持读取密码凭据,保留用户手动输入流程。
|
||||
}
|
||||
};
|
||||
|
||||
const tryStoreBrowserCredential = async (email: string, password: string) => {
|
||||
if (!form.rememberPassword) {
|
||||
localStorage.removeItem(REMEMBER_PASSWORD_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(REMEMBER_PASSWORD_KEY, "true");
|
||||
const PasswordCredential = (window as typeof window & { PasswordCredential?: PasswordCredentialConstructor })
|
||||
.PasswordCredential;
|
||||
if (!navigator.credentials?.store || !PasswordCredential) return;
|
||||
|
||||
try {
|
||||
await navigator.credentials.store(new PasswordCredential({ id: email, password, name: email }));
|
||||
} catch {
|
||||
// 浏览器或用户可能拒绝保存凭据,不影响登录主流程。
|
||||
}
|
||||
};
|
||||
|
||||
const openProtocolDialog = () => {
|
||||
protocolDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const confirmProtocol = () => {
|
||||
form.agreeProtocol = true;
|
||||
protocolDialogVisible.value = false;
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!formRef.value) return;
|
||||
const valid = await formRef.value.validate();
|
||||
if (!valid) return;
|
||||
if (!form.agreeProtocol) {
|
||||
ElMessage.warning("请先阅读并同意用户协议");
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
await auth.login(form.email, form.password);
|
||||
await tryStoreBrowserCredential(form.email, form.password);
|
||||
const studyStore = useStudyStore();
|
||||
if (!studyStore.currentStudy) {
|
||||
if (auth.user?.role === "ADMIN") {
|
||||
@@ -309,6 +449,129 @@ const onSubmit = async () => {
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
.login-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin: -4px 0 26px;
|
||||
}
|
||||
|
||||
.remember-checkbox,
|
||||
.protocol-checkbox {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.remember-checkbox :deep(.el-checkbox__input),
|
||||
.protocol-checkbox :deep(.el-checkbox__input) {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.remember-checkbox :deep(.el-checkbox__label),
|
||||
.protocol-checkbox :deep(.el-checkbox__label) {
|
||||
display: block;
|
||||
padding-left: 10px;
|
||||
line-height: 20px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.remember-text {
|
||||
color: #52657b;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.protocol-text {
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.protocol-link {
|
||||
appearance: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #4da3ff;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.protocol-link:hover {
|
||||
color: #2f8fff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.protocol-content {
|
||||
max-height: 58vh;
|
||||
overflow-y: auto;
|
||||
padding: 2px 8px 2px 0;
|
||||
}
|
||||
|
||||
.protocol-section {
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.protocol-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.protocol-section h3 {
|
||||
margin: 0 0 14px;
|
||||
color: #2f3946;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.protocol-section p {
|
||||
margin: 0 0 10px;
|
||||
color: #5f6b7a;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.protocol-section p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.protocol-confirm-btn {
|
||||
min-width: 112px;
|
||||
height: 44px;
|
||||
border-radius: 22px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
background: linear-gradient(135deg, #3f5d75, #4a6d87);
|
||||
border: none;
|
||||
box-shadow: 0 8px 16px -4px rgba(63, 93, 117, 0.3);
|
||||
}
|
||||
|
||||
:global(.protocol-dialog .el-dialog__header) {
|
||||
padding: 24px 28px 12px;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
:global(.protocol-dialog .el-dialog__title) {
|
||||
color: #2f3946;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
:global(.protocol-dialog .el-dialog__body) {
|
||||
padding: 22px 36px 10px;
|
||||
}
|
||||
|
||||
:global(.protocol-dialog .el-dialog__footer) {
|
||||
padding: 18px 28px 24px;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
@@ -359,5 +622,13 @@ const onSubmit = async () => {
|
||||
.sys-title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
:global(.protocol-dialog) {
|
||||
width: calc(100vw - 32px) !important;
|
||||
}
|
||||
|
||||
:global(.protocol-dialog .el-dialog__body) {
|
||||
padding: 18px 22px 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" label-position="top">
|
||||
<h3 class="section-title">{{ TEXT.common.labels.basicInfo }}</h3>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-col :xs="24" :sm="12" :md="8" :xl="6">
|
||||
<el-form-item :label="TEXT.common.fields.site" prop="center_id">
|
||||
<el-select v-model="form.center_id" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
|
||||
<el-option
|
||||
@@ -24,7 +24,7 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-col :xs="24" :sm="12" :md="8" :xl="6">
|
||||
<el-form-item :label="TEXT.common.fields.direction" prop="direction">
|
||||
<el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
|
||||
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
|
||||
@@ -32,7 +32,7 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-col :xs="24" :sm="12" :md="8" :xl="6">
|
||||
<el-form-item :label="TEXT.common.fields.status" prop="status">
|
||||
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
|
||||
<el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
|
||||
@@ -47,32 +47,32 @@
|
||||
|
||||
<h3 class="section-title">{{ TEXT.modules.drugShipments.recordLabel }}</h3>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-col :xs="24" :sm="12" :md="8" :xl="6">
|
||||
<el-form-item :label="TEXT.common.fields.shipDate" prop="ship_date">
|
||||
<el-date-picker v-model="form.ship_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-col :xs="24" :sm="12" :md="8" :xl="6">
|
||||
<el-form-item :label="TEXT.common.fields.receiveDate" prop="receive_date">
|
||||
<el-date-picker v-model="form.receive_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-col :xs="24" :sm="12" :md="8" :xl="6">
|
||||
<el-form-item :label="TEXT.common.fields.quantity" prop="quantity">
|
||||
<el-input-number v-model="form.quantity" :min="0" :controls="false" :placeholder="TEXT.common.placeholders.input" style="width: 100%" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-col :xs="24" :sm="12" :md="8" :xl="6">
|
||||
<el-form-item :label="TEXT.common.fields.batchNo" prop="batch_no">
|
||||
<el-input v-model="form.batch_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.batchNo" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-col :xs="24" :sm="12" :md="8" :xl="6">
|
||||
<el-form-item :label="TEXT.common.fields.carrier" prop="carrier">
|
||||
<el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-col :xs="24" :sm="12" :md="8" :xl="6">
|
||||
<el-form-item :label="TEXT.common.fields.trackingNo" prop="tracking_no">
|
||||
<el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,10 @@ describe("route view overlays are mounted only when visible", () => {
|
||||
'v-model="viewDialogVisible"',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "../components/attachments/AttachmentList.vue",
|
||||
snippets: ['<el-dialog v-if="previewVisible"', 'append-to-body', 'v-model="previewVisible"'],
|
||||
},
|
||||
{
|
||||
file: "./admin/ProjectDetail.vue",
|
||||
snippets: [
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSubjectDetail = () => readFileSync(resolve(__dirname, "./SubjectDetail.vue"), "utf8");
|
||||
|
||||
describe("SubjectDetail medication adherence", () => {
|
||||
it("adds a medication adherence tab with editable actual count", () => {
|
||||
const source = readSubjectDetail();
|
||||
|
||||
expect(source).toContain('name="medicationAdherence"');
|
||||
expect(source).toContain("actual_medication_count");
|
||||
expect(source).toContain("保存用药次数");
|
||||
expect(source).toContain("实际用药次数为试验期间实际药物暴露次数");
|
||||
expect(source).toContain("75%~125% 范围内(包含边界值)");
|
||||
});
|
||||
|
||||
it("uses baseline and termination visit actual dates for adherence calculation", () => {
|
||||
const source = readSubjectDetail();
|
||||
|
||||
expect(source).toContain("const medicationStartDate = computed(() => detail.baseline_date || \"\");");
|
||||
expect(source).toContain('new Set(["终止治疗", "提前终止"])');
|
||||
expect(source).toContain("return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1;");
|
||||
});
|
||||
|
||||
it("marks adherence outside 75 to 125 percent as abnormal", () => {
|
||||
const source = readSubjectDetail();
|
||||
|
||||
expect(source).toContain("if (rate < 75 || rate > 125) return \"danger\";");
|
||||
expect(source).not.toContain("if (rate < 80 || rate > 120) return \"danger\";");
|
||||
});
|
||||
|
||||
it("provides a dedicated early termination workflow", () => {
|
||||
const source = readSubjectDetail();
|
||||
|
||||
expect(source).toContain("openEarlyTerminationDialog");
|
||||
expect(source).toContain("createEarlyTerminationVisit");
|
||||
expect(source).toContain("return \"不适用\";");
|
||||
expect(source).toContain("getVisitStatusLabel");
|
||||
expect(source).toContain("ElMessage.success(TEXT.common.messages.saveSuccess);");
|
||||
expect(source).toContain("提前终止已保存,刷新数据失败,请手动刷新页面");
|
||||
expect(source).toContain("提前终止表示受试者在方案最后一个计划访视窗口期前停止治疗");
|
||||
expect(source).toContain("validateEarlyTerminationDateInput");
|
||||
expect(source).toContain("提前终止日期必须早于最后计划访视窗口期开始日");
|
||||
expect(source).toContain("getVisitPlannedDateLabel");
|
||||
expect(source).toContain("getVisitWindowRangeLabel");
|
||||
expect(source).toContain("~");
|
||||
expect(source).toContain("row.window_start === row.planned_date && row.window_end === row.planned_date");
|
||||
expect(source).toContain("return \"不适用\";");
|
||||
});
|
||||
});
|
||||
@@ -88,9 +88,15 @@
|
||||
|
||||
<el-card class="unified-shell subject-shell subject-tabs-shell">
|
||||
<div class="subject-tabs-action">
|
||||
<el-button type="primary" :disabled="isReadOnly" @click="currentTabAction.onClick">
|
||||
<el-button type="primary" :loading="currentTabAction.loading" :disabled="isReadOnly" @click="currentTabAction.onClick">
|
||||
{{ currentTabAction.label }}
|
||||
</el-button>
|
||||
<el-button v-if="activeTab === 'visits'" type="warning" plain :disabled="isReadOnly" @click="openEarlyTerminationDialog">
|
||||
提前终止
|
||||
</el-button>
|
||||
<el-button v-if="adherenceEditing && activeTab === 'medicationAdherence'" :disabled="isReadOnly" @click="adherenceEditing = false">
|
||||
{{ TEXT.common.actions.cancel }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.history" name="history">
|
||||
@@ -131,7 +137,14 @@
|
||||
<el-table :data="visitItems" v-loading="loadingVisits" style="width: 100%" class="subject-detail-table" table-layout="fixed">
|
||||
<el-table-column prop="visit_code" :label="TEXT.common.fields.visitCode" show-overflow-tooltip />
|
||||
<el-table-column prop="planned_date" :label="TEXT.common.fields.plannedDate" show-overflow-tooltip>
|
||||
<template #default="scope">{{ displayDate(scope.row.planned_date) }}</template>
|
||||
<template #default="scope">
|
||||
<div class="visit-planned-cell">
|
||||
<span>{{ getVisitPlannedDateLabel(scope.row) }}</span>
|
||||
<span v-if="getVisitWindowRangeLabel(scope.row)" class="visit-window-range">
|
||||
{{ getVisitWindowRangeLabel(scope.row) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actual_date" :label="TEXT.common.fields.actualDate" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
@@ -151,7 +164,7 @@
|
||||
:type="getVisitStatusTagType(getVisitStatus(scope.row, visitEditingRowId === scope.row.id ? visitRowDraft.actual_date : undefined))"
|
||||
effect="light"
|
||||
>
|
||||
{{ displayEnum(TEXT.enums.visitStatus, getVisitStatus(scope.row, visitEditingRowId === scope.row.id ? visitRowDraft.actual_date : undefined)) }}
|
||||
{{ getVisitStatusLabel(scope.row, visitEditingRowId === scope.row.id ? visitRowDraft.actual_date : undefined) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -205,6 +218,43 @@
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="用药依从性" name="medicationAdherence">
|
||||
<div class="adherence-panel">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="开始用药日期">
|
||||
{{ displayDate(medicationStartDate) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="结束用药日期">
|
||||
{{ displayDate(medicationEndDate) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="结束日期来源">
|
||||
{{ medicationEndVisitLabel }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="理论用药次数">
|
||||
{{ expectedMedicationCountText }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="实际用药次数">
|
||||
<el-input-number
|
||||
v-if="adherenceEditing"
|
||||
v-model="adherenceForm.actual_medication_count"
|
||||
:min="0"
|
||||
:controls="false"
|
||||
size="small"
|
||||
class="adherence-count-input"
|
||||
:disabled="isReadOnly"
|
||||
/>
|
||||
<span v-else>{{ actualMedicationCountText }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用药依从性">
|
||||
<el-tag :type="adherenceRateTagType" effect="light">{{ medicationAdherenceRateText }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="adherence-hint">
|
||||
用药依从性 = 实际用药次数 ÷ 理论用药次数 × 100%。实际用药次数为试验期间实际药物暴露次数;理论用药次数 =(结束用药日期 - 开始用药日期 + 1)× 1次/天。试验期间的用药依从性原则上要求在 75%~125% 范围内(包含边界值)。
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.ae" name="ae">
|
||||
<el-table :data="aeItems" v-loading="loadingAes" style="width: 100%" class="subject-detail-table" table-layout="fixed">
|
||||
<el-table-column prop="onset_date" :label="TEXT.common.fields.occurDate" show-overflow-tooltip>
|
||||
@@ -347,6 +397,35 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-if="earlyTerminationDialogVisible"
|
||||
append-to=".layout-main .content-wrapper"
|
||||
v-model="earlyTerminationDialogVisible"
|
||||
title="提前终止"
|
||||
width="560px"
|
||||
>
|
||||
<el-form :model="earlyTerminationForm" label-width="110px">
|
||||
<el-form-item label="提前终止日期" required>
|
||||
<el-date-picker v-model="earlyTerminationForm.termination_date" type="date" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
<el-form-item label="提前终止原因" required>
|
||||
<el-input v-model="earlyTerminationForm.reason" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.remark">
|
||||
<el-input v-model="earlyTerminationForm.notes" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="early-termination-hint">
|
||||
提前终止表示受试者在方案最后一个计划访视窗口期前停止治疗;日期进入最后访视窗口期后,应录入正常终止治疗访视。
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="earlyTerminationDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="primary" :loading="earlyTerminationSaving" :disabled="isReadOnly" @click="saveEarlyTermination">
|
||||
{{ TEXT.common.actions.save }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-if="aeDialogVisible" append-to=".layout-main .content-wrapper" v-model="aeDialogVisible" :title="TEXT.modules.subjectDetail.dialogAe" width="560px">
|
||||
<el-form :model="aeForm" label-width="100px">
|
||||
<el-form-item :label="TEXT.common.fields.title" required>
|
||||
@@ -426,7 +505,7 @@ import { useStudyStore } from "../../store/study";
|
||||
import { getSubject, updateSubject } from "../../api/subjects";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import { listSubjectHistories, createSubjectHistory, updateSubjectHistory, deleteSubjectHistory } from "../../api/subjectHistories";
|
||||
import { fetchVisits, createVisit, updateVisit, deleteVisit } from "../../api/visits";
|
||||
import { fetchVisits, createEarlyTerminationVisit, createVisit, updateVisit, deleteVisit } from "../../api/visits";
|
||||
import { fetchAes, createAe, updateAe, deleteAe } from "../../api/aes";
|
||||
import { listSubjectPds, createSubjectPd, updateSubjectPd, deleteSubjectPd } from "../../api/subjectPds";
|
||||
import { displayDate, displayEnum, displayText } from "../../utils/display";
|
||||
@@ -439,7 +518,7 @@ const subjectId = route.params.subjectId as string;
|
||||
const studyId = study.currentStudy?.id || "";
|
||||
const resolveTabFromQuery = () => {
|
||||
const tab = typeof route.query.tab === "string" ? route.query.tab : "";
|
||||
return ["history", "visits", "ae", "pd"].includes(tab) ? tab : "history";
|
||||
return ["history", "visits", "medicationAdherence", "ae", "pd"].includes(tab) ? tab : "history";
|
||||
};
|
||||
|
||||
const loading = ref(false);
|
||||
@@ -452,6 +531,7 @@ const detail = reactive<any>({
|
||||
enrollment_date: "",
|
||||
baseline_date: "",
|
||||
completion_date: "",
|
||||
actual_medication_count: null as number | null,
|
||||
drop_reason: "",
|
||||
});
|
||||
|
||||
@@ -483,6 +563,8 @@ const historyForm = reactive({
|
||||
const visitItems = ref<any[]>([]);
|
||||
const loadingVisits = ref(false);
|
||||
const visitDialogVisible = ref(false);
|
||||
const earlyTerminationDialogVisible = ref(false);
|
||||
const earlyTerminationSaving = ref(false);
|
||||
const visitEditingId = ref<string | null>(null);
|
||||
const visitEditingRowId = ref<string | null>(null);
|
||||
const visitRowDraft = reactive({
|
||||
@@ -497,7 +579,17 @@ const visitForm = reactive<any>({
|
||||
actual_date: "",
|
||||
notes: "",
|
||||
});
|
||||
const earlyTerminationForm = reactive({
|
||||
termination_date: "",
|
||||
reason: "",
|
||||
notes: "",
|
||||
});
|
||||
const isFirstVisit = computed(() => visitForm.visit_code === "V1");
|
||||
const adherenceEditing = ref(false);
|
||||
const adherenceSaving = ref(false);
|
||||
const adherenceForm = reactive({
|
||||
actual_medication_count: null as number | null,
|
||||
});
|
||||
|
||||
const aeItems = ref<any[]>([]);
|
||||
const loadingAes = ref(false);
|
||||
@@ -572,6 +664,14 @@ const normalizeSeriousness = (value?: string | null) => {
|
||||
return "I";
|
||||
};
|
||||
|
||||
const getErrorMessage = (error: any, fallback: string) => {
|
||||
const detail = error?.response?.data?.detail;
|
||||
if (typeof error?.response?.data?.message === "string") return error.response.data.message;
|
||||
if (typeof detail === "string") return detail;
|
||||
if (typeof detail?.message === "string") return detail.message;
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const loadSites = async () => {
|
||||
if (!studyId) return;
|
||||
try {
|
||||
@@ -763,6 +863,17 @@ const openVisitDialog = (row?: any) => {
|
||||
visitDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const openEarlyTerminationDialog = () => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
earlyTerminationForm.termination_date = "";
|
||||
earlyTerminationForm.reason = "";
|
||||
earlyTerminationForm.notes = "";
|
||||
earlyTerminationDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const startVisitRowEdit = (row: any) => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
@@ -803,6 +914,84 @@ const parseDate = (value?: string | null) => {
|
||||
return date;
|
||||
};
|
||||
|
||||
const parseDateAtNoon = (value?: string | null) => {
|
||||
if (!value) return null;
|
||||
const date = new Date(`${value}T12:00:00`);
|
||||
if (isNaN(date.getTime())) return null;
|
||||
return date;
|
||||
};
|
||||
|
||||
const getVisitWindowStartDateText = (row: any) => row?.window_start || row?.planned_date || "";
|
||||
|
||||
const getVisitPlannedDateLabel = (row: any) => {
|
||||
if (String(row?.visit_code || "").trim() === "提前终止") return "不适用";
|
||||
return displayDate(row?.planned_date);
|
||||
};
|
||||
|
||||
const getVisitWindowRangeLabel = (row: any) => {
|
||||
if (String(row?.visit_code || "").trim() === "提前终止") return "";
|
||||
if (!row?.window_start || !row?.window_end) return "";
|
||||
if (row.window_start === row.planned_date && row.window_end === row.planned_date) return "";
|
||||
return `${displayDate(row.window_start)} ~ ${displayDate(row.window_end)}`;
|
||||
};
|
||||
|
||||
const lastPlannedVisitWindowStartText = computed(() => {
|
||||
const excludedCodes = new Set(["筛选访视", "基线访视", "提前终止"]);
|
||||
const candidates = visitItems.value
|
||||
.filter((item) => !excludedCodes.has(String(item?.visit_code || "").trim()))
|
||||
.map((item) => getVisitWindowStartDateText(item))
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
return candidates[candidates.length - 1] || "";
|
||||
});
|
||||
|
||||
const validateEarlyTerminationDateInput = () => {
|
||||
const boundary = lastPlannedVisitWindowStartText.value;
|
||||
if (!boundary || !earlyTerminationForm.termination_date) return true;
|
||||
if (earlyTerminationForm.termination_date >= boundary) {
|
||||
ElMessage.error(`提前终止日期必须早于最后计划访视窗口期开始日(${boundary})`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const formatCount = (value: number | null | undefined) => (value === null || value === undefined ? TEXT.common.fallback : String(value));
|
||||
const medicationStartDate = computed(() => detail.baseline_date || "");
|
||||
const medicationEndVisit = computed(() => {
|
||||
const targetLabels = new Set(["终止治疗", "提前终止"]);
|
||||
return (
|
||||
visitItems.value.find((item) => targetLabels.has(String(item?.visit_code || "").trim()) && item?.actual_date) || null
|
||||
);
|
||||
});
|
||||
const medicationEndDate = computed(() => medicationEndVisit.value?.actual_date || "");
|
||||
const medicationEndVisitLabel = computed(() => medicationEndVisit.value?.visit_code || "未记录终止治疗/提前终止实际日期");
|
||||
const expectedMedicationCount = computed(() => {
|
||||
const start = parseDateAtNoon(medicationStartDate.value);
|
||||
const end = parseDateAtNoon(medicationEndDate.value);
|
||||
if (!start || !end || end < start) return null;
|
||||
return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1;
|
||||
});
|
||||
const expectedMedicationCountText = computed(() => formatCount(expectedMedicationCount.value));
|
||||
const actualMedicationCount = computed(() => detail.actual_medication_count ?? null);
|
||||
const actualMedicationCountText = computed(() => formatCount(actualMedicationCount.value));
|
||||
const medicationAdherenceRate = computed(() => {
|
||||
const expected = expectedMedicationCount.value;
|
||||
const actual = actualMedicationCount.value;
|
||||
if (!expected || actual === null || actual === undefined) return null;
|
||||
return (actual / expected) * 100;
|
||||
});
|
||||
const medicationAdherenceRateText = computed(() => {
|
||||
const rate = medicationAdherenceRate.value;
|
||||
if (rate === null) return "无法计算";
|
||||
return `${rate.toFixed(1)}%`;
|
||||
});
|
||||
const adherenceRateTagType = computed(() => {
|
||||
const rate = medicationAdherenceRate.value;
|
||||
if (rate === null) return "info";
|
||||
if (rate < 75 || rate > 125) return "danger";
|
||||
return "success";
|
||||
});
|
||||
|
||||
const getVisitStatus = (row: any, actualDateOverride?: string) => {
|
||||
if (row?.status === "CANCELLED") return "CANCELLED";
|
||||
const actualDate = parseDate(actualDateOverride ?? row?.actual_date);
|
||||
@@ -840,6 +1029,14 @@ const getVisitStatusTagType = (status: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getVisitStatusLabel = (row: any, actualDateOverride?: string) => {
|
||||
const status = getVisitStatus(row, actualDateOverride);
|
||||
if (status === "CANCELLED" && String(row?.notes || "").includes("提前终止")) {
|
||||
return "不适用";
|
||||
}
|
||||
return displayEnum(TEXT.enums.visitStatus, status);
|
||||
};
|
||||
|
||||
const getAeTypeKey = (row: any) => {
|
||||
if (row?.is_susar) return "susar";
|
||||
if (row?.is_sae) return "sae";
|
||||
@@ -896,6 +1093,46 @@ const saveVisit = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const saveEarlyTermination = async () => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (!studyId || !subjectId) return;
|
||||
if (!earlyTerminationForm.termination_date) {
|
||||
ElMessage.error("请填写提前终止日期");
|
||||
return;
|
||||
}
|
||||
if (!earlyTerminationForm.reason.trim()) {
|
||||
ElMessage.error("请填写提前终止原因");
|
||||
return;
|
||||
}
|
||||
if (!validateEarlyTerminationDateInput()) return;
|
||||
earlyTerminationSaving.value = true;
|
||||
try {
|
||||
await createEarlyTerminationVisit(studyId, subjectId, {
|
||||
termination_date: earlyTerminationForm.termination_date,
|
||||
reason: earlyTerminationForm.reason.trim(),
|
||||
notes: earlyTerminationForm.notes || null,
|
||||
}, {
|
||||
suppressErrorMessage: true,
|
||||
});
|
||||
earlyTerminationDialogVisible.value = false;
|
||||
ElMessage.success(TEXT.common.messages.saveSuccess);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(getErrorMessage(e, TEXT.common.messages.saveFailed));
|
||||
earlyTerminationSaving.value = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await Promise.all([loadSubject(), loadVisits()]);
|
||||
} catch {
|
||||
ElMessage.warning("提前终止已保存,刷新数据失败,请手动刷新页面");
|
||||
} finally {
|
||||
earlyTerminationSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const removeVisit = async (row: any) => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
@@ -1032,6 +1269,11 @@ const currentTabAction = computed(() => {
|
||||
switch (activeTab.value) {
|
||||
case "visits":
|
||||
return { label: TEXT.common.actions.newVisit, onClick: () => openVisitDialog() };
|
||||
case "medicationAdherence":
|
||||
if (adherenceEditing.value) {
|
||||
return { label: "保存用药次数", loading: adherenceSaving.value, onClick: () => saveMedicationAdherence() };
|
||||
}
|
||||
return { label: "编辑", loading: false, onClick: () => startMedicationAdherenceEdit() };
|
||||
case "ae":
|
||||
return { label: TEXT.common.actions.newAe, onClick: () => openAeDialog() };
|
||||
case "pd":
|
||||
@@ -1042,6 +1284,36 @@ const currentTabAction = computed(() => {
|
||||
}
|
||||
});
|
||||
|
||||
const startMedicationAdherenceEdit = () => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
adherenceForm.actual_medication_count = detail.actual_medication_count ?? null;
|
||||
adherenceEditing.value = true;
|
||||
};
|
||||
|
||||
const saveMedicationAdherence = async () => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (!studyId || !subjectId) return;
|
||||
adherenceSaving.value = true;
|
||||
try {
|
||||
await updateSubject(studyId, subjectId, {
|
||||
actual_medication_count: adherenceForm.actual_medication_count ?? null,
|
||||
});
|
||||
adherenceEditing.value = false;
|
||||
ElMessage.success(TEXT.common.messages.saveSuccess);
|
||||
await loadSubject();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
} finally {
|
||||
adherenceSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const savePd = async () => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
@@ -1168,12 +1440,47 @@ onMounted(async () => {
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.subject-overview-descriptions :deep(.el-descriptions__table) {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.visit-planned-cell {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.visit-window-range {
|
||||
color: #8a97ab;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.adherence-panel {
|
||||
padding: 16px 0 0;
|
||||
}
|
||||
|
||||
.adherence-count-input {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.adherence-hint {
|
||||
margin-top: 12px;
|
||||
color: #6b778c;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.early-termination-hint {
|
||||
margin-top: 10px;
|
||||
color: #6b778c;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.subject-overview-descriptions :deep(.el-descriptions__label) {
|
||||
width: 140px;
|
||||
min-width: 140px;
|
||||
|
||||
Reference in New Issue
Block a user