feat: harden auth and study workflows

- replace plaintext login and unlock requests with RSA-OAEP/AES-GCM encrypted payloads

- add login challenge replay protection, production RSA key validation, and auth tests

- wire compose to environment-driven dev/prod settings without committing local secrets

- update setup-config smoke scripts and Postman docs for encrypted login

- add visit schedule migrations/tests and update study/subject setup workflows
This commit is contained in:
Cheng Zhou
2026-05-08 22:13:12 +08:00
parent a7bbcaa5dc
commit 74feca4467
47 changed files with 2423 additions and 534 deletions
+4 -1
View File
@@ -1,10 +1,13 @@
import type { AxiosResponse } from "axios";
import api, { apiGet, apiPatch, apiPost } from "./axios";
import type { UserMeResponse, LoginRequest, LoginResponse, RegisterRequest } from "../types/api";
import type { UserMeResponse, LoginRequest, LoginResponse, LoginKeyResponse, RegisterRequest } from "../types/api";
export const login = (payload: LoginRequest): Promise<AxiosResponse<LoginResponse>> =>
apiPost<LoginResponse>("/api/v1/auth/login", payload);
export const getLoginKey = (): Promise<AxiosResponse<LoginKeyResponse>> =>
apiGet<LoginKeyResponse>("/api/v1/auth/login-key");
export const fetchMe = (): Promise<AxiosResponse<UserMeResponse>> => apiGet<UserMeResponse>("/api/v1/auth/me");
export const register = (payload: RegisterRequest): Promise<AxiosResponse<{ message: string }>> =>
+17 -1
View File
@@ -16,6 +16,19 @@ export type UnlockResponse = {
expiresAt: string;
};
export type LoginKeyResponse = {
key_id: string;
public_key: string;
challenge: string;
expires_at: string;
};
export type EncryptedPasswordRequest = {
key_id: string;
challenge: string;
ciphertext: string;
};
export const extendToken = (token: string): Promise<AxiosResponse<ExtendResponse>> =>
authClient.post<ExtendResponse>(
"/api/v1/auth/extend",
@@ -27,7 +40,10 @@ export const extendToken = (token: string): Promise<AxiosResponse<ExtendResponse
}
);
export const unlockSession = (payload: { email: string; password: string }): Promise<AxiosResponse<UnlockResponse>> =>
export const getLoginKey = (): Promise<AxiosResponse<LoginKeyResponse>> =>
authClient.get<LoginKeyResponse>("/api/v1/auth/login-key");
export const unlockSession = (payload: EncryptedPasswordRequest): Promise<AxiosResponse<UnlockResponse>> =>
authClient.post<UnlockResponse>("/api/v1/auth/unlock", payload);
export default authClient;
+2 -1
View File
@@ -121,11 +121,12 @@ export const TEXT = {
screeningDate: "筛选日期",
consentDate: "知情日期",
enrollmentDate: "入组日期",
baselineDate: "基线/治疗日期",
completionDate: "完成日期",
dropReason: "退出原因",
recordDate: "日期",
content: "内容",
visitCode: "访视编号",
visitCode: "访视",
plannedDate: "计划访视",
actualDate: "实际访视",
windowStart: "窗口开始",
+13 -2
View File
@@ -2,7 +2,8 @@ import router from "../router";
import { useAuthStore } from "../store/auth";
import { useSessionStore } from "../store/session";
import { getToken, setToken, clearToken } from "../utils/auth";
import { extendToken, unlockSession } from "../api/authClient";
import { extendToken, getLoginKey, unlockSession } from "../api/authClient";
import { encryptLoginPayload } from "../utils/loginCrypto";
import { parseJwtExp } from "./jwt";
export const IDLE_TIMEOUT_MINUTES = 30;
@@ -292,7 +293,17 @@ export const startTokenKeepAlive = () => {
export const unlockWithPassword = async (email: string, password: string) => {
const session = useSessionStore();
const auth = useAuthStore();
const { data } = await unlockSession({ email, password });
const { data: loginKey } = await getLoginKey();
const ciphertext = await encryptLoginPayload(loginKey.public_key, {
email,
password,
challenge: loginKey.challenge,
});
const { data } = await unlockSession({
key_id: loginKey.key_id,
challenge: loginKey.challenge,
ciphertext,
});
updateToken(data.accessToken);
session.unlock();
try {
+13 -2
View File
@@ -1,7 +1,8 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { login as apiLogin, fetchMe } from "../api/auth";
import { getLoginKey, login as apiLogin, fetchMe } from "../api/auth";
import { setToken, clearToken, getToken } from "../utils/auth";
import { encryptLoginPayload } from "../utils/loginCrypto";
import type { UserInfo } from "../types/api";
import { useStudyStore } from "./study";
import { useSessionStore } from "./session";
@@ -16,7 +17,17 @@ export const useAuthStore = defineStore("auth", () => {
const login = async (email: string, password: string) => {
loading.value = true;
try {
const { data } = await apiLogin({ email, password });
const { data: loginKey } = await getLoginKey();
const ciphertext = await encryptLoginPayload(loginKey.public_key, {
email,
password,
challenge: loginKey.challenge,
});
const { data } = await apiLogin({
key_id: loginKey.key_id,
challenge: loginKey.challenge,
ciphertext,
});
token.value = data.access_token;
setToken(data.access_token);
// 避免锁屏态下 fetchMe 被请求拦截
+18 -8
View File
@@ -15,8 +15,9 @@ export interface ApiError {
}
export interface LoginRequest {
email: string;
password: string;
key_id: string;
challenge: string;
ciphertext: string;
}
export interface LoginResponse {
@@ -24,6 +25,13 @@ export interface LoginResponse {
token_type: string;
}
export interface LoginKeyResponse {
key_id: string;
public_key: string;
challenge: string;
expires_at: string;
}
export type UserRole = "PM" | "CRA" | "PV" | "IMP" | "QA" | "ADMIN";
export type UserStatus = "PENDING" | "ACTIVE" | "REJECTED" | "DISABLED";
@@ -79,20 +87,22 @@ export interface Study {
planned_enrollment_count?: number | null;
enrollment_monthly_goal_note?: string | null;
enrollment_stage_breakdown?: string | null;
summary_note?: string | null;
objective_note?: string | null;
phase?: string | null;
status: string;
is_locked?: boolean;
visit_interval_days?: number | null;
visit_total?: number | null;
visit_window_start_offset?: number | null;
visit_window_end_offset?: number | null;
visit_schedule?: VisitScheduleItem[];
created_by?: string | null;
created_at?: string;
role_in_study?: string | null;
}
export interface VisitScheduleItem {
visit_code: string;
baseline_offset_days: number;
window_before_days: number;
window_after_days: number;
}
export interface StudyMember {
id: string;
study_id: string;
+8 -6
View File
@@ -66,6 +66,13 @@ export interface SetupConfigDraft {
centerConfirm: CenterConfirmDraft[];
}
export interface VisitScheduleItem {
visit_code: string;
baseline_offset_days: number;
window_before_days: number;
window_after_days: number;
}
export interface ProjectPublishSnapshot {
code: string;
name: string;
@@ -85,13 +92,8 @@ export interface ProjectPublishSnapshot {
plan_end_date: string;
planned_site_count: number | null;
planned_enrollment_count: number | null;
summary_note: string;
objective_note: string;
status: string;
visit_interval_days: number | null;
visit_total: number | null;
visit_window_start_offset: number | null;
visit_window_end_offset: number | null;
visit_schedule: VisitScheduleItem[];
}
export interface StudySetupConfigResponse {
-28
View File
@@ -1,5 +1,4 @@
const TOKEN_KEY = "ctms_token";
const CREDENTIAL_KEY = "ctms_credential";
export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY);
@@ -10,30 +9,3 @@ export const setToken = (token: string): void => {
export const clearToken = (): void => {
localStorage.removeItem(TOKEN_KEY);
};
export const setCachedCredential = (email: string, password: string) => {
try {
const payload = btoa(JSON.stringify({ email, password }));
localStorage.setItem(CREDENTIAL_KEY, payload);
} catch {
/* ignore */
}
};
export const getCachedCredential = (): { email: string; password: string } | null => {
const value = localStorage.getItem(CREDENTIAL_KEY);
if (!value) return null;
try {
const parsed = JSON.parse(atob(value));
if (parsed?.email && parsed?.password) {
return { email: parsed.email, password: parsed.password };
}
} catch {
/* ignore */
}
return null;
};
export const clearCachedCredential = (): void => {
localStorage.removeItem(CREDENTIAL_KEY);
};
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it, vi } from "vitest";
import { encryptLoginPayload } from "./loginCrypto";
describe("encryptLoginPayload", () => {
it("fails with a clear error outside a secure browser context", async () => {
vi.stubGlobal("isSecureContext", false);
await expect(
encryptLoginPayload("unused", {
email: "admin@test.com",
password: "admin123",
challenge: "challenge-value-with-enough-length",
})
).rejects.toThrow("当前浏览器环境不支持安全登录加密");
vi.unstubAllGlobals();
});
});
+56
View File
@@ -0,0 +1,56 @@
const pemToArrayBuffer = (pem: string): ArrayBuffer => {
const base64 = pem
.replace(/-----BEGIN PUBLIC KEY-----/g, "")
.replace(/-----END PUBLIC KEY-----/g, "")
.replace(/\s/g, "");
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes.buffer;
};
const arrayBufferToBase64 = (buffer: ArrayBuffer): string => {
const bytes = new Uint8Array(buffer);
let binary = "";
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary);
};
const assertSecureCryptoAvailable = () => {
if (!window.isSecureContext || !crypto?.subtle) {
throw new Error("当前浏览器环境不支持安全登录加密,请使用 HTTPS 或 localhost 访问系统");
}
};
export const encryptLoginPayload = async (
publicKeyPem: string,
payload: { email: string; password: string; challenge: string }
): Promise<string> => {
assertSecureCryptoAvailable();
const publicKey = await crypto.subtle.importKey(
"spki",
pemToArrayBuffer(publicKeyPem),
{
name: "RSA-OAEP",
hash: "SHA-256",
},
false,
["encrypt"]
);
const aesKey = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt"]);
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(JSON.stringify(payload));
const encryptedData = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, aesKey, encoded);
const rawAesKey = await crypto.subtle.exportKey("raw", aesKey);
const encryptedKey = await crypto.subtle.encrypt({ name: "RSA-OAEP" }, publicKey, rawAesKey);
const envelope = {
encrypted_key: arrayBufferToBase64(encryptedKey),
iv: arrayBufferToBase64(iv.buffer),
data: arrayBufferToBase64(encryptedData),
};
return btoa(JSON.stringify(envelope));
};
+1 -6
View File
@@ -118,13 +118,8 @@ const PROJECT_FIELD_LABEL_MAP: Record<keyof ProjectPublishSnapshot, string> = {
plan_end_date: "计划结束日期",
planned_site_count: "计划中心数",
planned_enrollment_count: "计划入组数",
summary_note: "方案摘要说明",
objective_note: "研究目标摘要",
status: "项目状态",
visit_interval_days: "访视间隔(天)",
visit_total: "访视总数",
visit_window_start_offset: "窗口期起始偏移",
visit_window_end_offset: "窗口期结束偏移",
visit_schedule: "访视计划",
};
export const buildProjectDiffRows = (
+1 -35
View File
@@ -74,12 +74,6 @@
</el-input>
</el-form-item>
<div class="remember-row">
<el-checkbox id="remember" v-model="form.remember" class="remember-checkbox">
<span class="remember-text">记住此设备</span>
</el-checkbox>
</div>
<el-button type="primary" :loading="loading" @click="onSubmit" size="large" class="login-btn">
</el-button>
@@ -100,7 +94,6 @@ import { useRouter } from "vue-router";
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { getCachedCredential, setCachedCredential, clearCachedCredential } from "../utils/auth";
import { TEXT, requiredMessage } from "../locales";
import { consumeLogoutReason, LOGOUT_REASON_TIMEOUT } from "../session/sessionManager";
@@ -111,7 +104,6 @@ const formRef = ref<FormInstance>();
const form = reactive({
email: "",
password: "",
remember: false,
});
const rules: FormRules<typeof form> = {
@@ -129,12 +121,7 @@ onMounted(() => {
if (logoutReason === LOGOUT_REASON_TIMEOUT) {
ElMessage.warning("长时间未操作,已自动退出登录,请重新登录");
}
const cached = getCachedCredential();
if (cached) {
form.email = cached.email;
form.password = cached.password;
form.remember = true;
}
form.email = localStorage.getItem("ctms_last_login_email") || "";
});
const onSubmit = async () => {
@@ -144,11 +131,6 @@ const onSubmit = async () => {
loading.value = true;
try {
await auth.login(form.email, form.password);
if (form.remember) {
setCachedCredential(form.email, form.password);
} else {
clearCachedCredential();
}
const studyStore = useStudyStore();
if (!studyStore.currentStudy) {
if (auth.user?.role === "ADMIN") {
@@ -327,22 +309,6 @@ const onSubmit = async () => {
transition: background-color 5000s ease-in-out 0s;
}
.remember-row {
display: flex;
align-items: center;
justify-content: flex-start;
margin: -6px 0 18px;
}
.remember-checkbox :deep(.el-checkbox__label) {
padding-left: 8px;
}
.remember-text {
color: #64748b;
font-size: 13px;
}
.login-btn {
width: 100%;
height: 48px;
+555 -103
View File
@@ -471,55 +471,12 @@
</el-tab-pane>
<el-tab-pane v-if="showStep1SummaryTab" label="方案摘要" name="summary">
<el-form v-if="canEditStep1Summary" label-width="120px" class="detail-form" label-position="top">
<el-row :gutter="24">
<el-col :span="6">
<el-form-item :label="TEXT.common.fields.visitTotal">
<el-input-number v-model="form.visit_total" :min="1" :max="50" :step="1" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item :label="TEXT.common.fields.visitIntervalDays">
<el-input-number v-model="form.visit_interval_days" :min="1" :max="365" :step="1" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item :label="TEXT.common.fields.visitWindowStartOffset">
<el-input-number v-model="form.visit_window_start_offset" :min="-365" :max="365" :step="1" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item :label="TEXT.common.fields.visitWindowEndOffset">
<el-input-number v-model="form.visit_window_end_offset" :min="-365" :max="365" :step="1" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="方案摘要说明">
<el-input v-model="form.summary_note" type="textarea" :rows="3" placeholder="用于展示项目信息概览说明" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="研究目标摘要">
<el-input v-model="form.objective_note" type="textarea" :rows="3" placeholder="用于展示研究目标摘要" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<div v-else class="info-grid">
<div
v-for="item in summaryInfoItems"
:key="item.label"
class="info-item"
:class="{ 'is-updated': item.updated && shouldShowPreviewUpdateMark }"
>
<span class="info-label">{{ item.label }}</span>
<span class="info-value-wrap">
<span class="info-value">{{ item.value || "-" }}</span>
<div class="info-group summary-display-group">
<div class="info-group-title-row">
<div class="info-group-title">访视计划</div>
<div class="info-group-title-actions">
<el-tag
v-if="item.updated && shouldShowPreviewUpdateMark"
v-if="isProjectSnapshotFieldUpdated('visit_schedule') && shouldShowPreviewUpdateMark"
size="small"
type="warning"
effect="plain"
@@ -527,7 +484,33 @@
>
已更新
</el-tag>
</span>
<el-button
v-if="!isPublishedView"
type="primary"
class="info-group-edit-btn"
:disabled="!canEditSetup"
@click="startStep1SectionEdit('summary')"
>
编辑
</el-button>
</div>
</div>
<div class="visit-schedule-display">
<div class="visit-schedule-display-head">
<span>访视</span>
<span>基线后天数</span>
<span>访视窗</span>
</div>
<div
v-for="(row, index) in summaryVisitScheduleRows"
:key="`${row.visit_code || 'visit'}-${index}`"
class="visit-schedule-display-row"
>
<span class="visit-schedule-display-code">{{ row.visit_code || "-" }}</span>
<span>{{ row.baseline_offset_days }} </span>
<span>{{ formatVisitWindow(row) }}</span>
</div>
<div v-if="!summaryVisitScheduleRows.length" class="visit-schedule-display-empty">暂无访视计划</div>
</div>
</div>
</el-tab-pane>
@@ -1661,6 +1644,100 @@
</template>
</el-dialog>
<el-dialog
v-model="visitScheduleDialogVisible"
title="编辑访视计划"
width="min(860px, calc(100vw - 32px))"
:close-on-click-modal="false"
:show-close="false"
class="visit-schedule-dialog"
>
<el-form label-width="120px" class="detail-form visit-schedule-dialog-form" label-position="top">
<div class="summary-section-bar">
<el-button plain size="small" @click="addVisitScheduleRow">添加访视</el-button>
</div>
<div class="visit-schedule-editor">
<div class="visit-schedule-head">
<span>访视</span>
<span>基线后天数</span>
<span>窗口前</span>
<span>窗口后</span>
<span />
</div>
<div
v-for="(row, index) in form.visit_schedule"
:key="`${row.visit_code || 'visit'}-${index}`"
class="visit-schedule-row"
:class="{
'is-dragging': visitScheduleDragIndex === index,
'insert-before': isVisitScheduleInsertTarget(index, 'before'),
'insert-after': isVisitScheduleInsertTarget(index, 'after'),
}"
draggable="true"
aria-label="拖拽调整访视顺序"
@dragstart="handleVisitScheduleDragStart(index, $event)"
@dragover.prevent="handleVisitScheduleDragOver(index, $event)"
@drop.prevent="handleVisitScheduleDrop(index)"
@dragenter.prevent="handleVisitScheduleDragOver(index, $event)"
@dragend="handleVisitScheduleDragEnd"
>
<div class="visit-field visit-code-field">
<span class="visit-field-label">访视</span>
<el-select
v-model="row.visit_code"
filterable
allow-create
default-first-option
placeholder="选择或输入访视"
class="w-full"
@change="handleVisitScheduleCodeChange(index, $event)"
>
<el-option-group label="常规访视">
<el-option label="常规访视(自动生成 Vn" :value="regularVisitOptionValue" />
<el-option label="安全性随访(自动生成 Vn" :value="safetyFollowUpVisitOptionValue" />
</el-option-group>
<el-option-group label="特殊访视">
<el-option
v-for="option in specialVisitOptions"
:key="option"
:label="option"
:value="option"
/>
</el-option-group>
</el-select>
</div>
<div class="visit-field">
<span class="visit-field-label">基线后天数</span>
<el-input-number v-model="row.baseline_offset_days" :min="0" :max="3650" :step="1" controls-position="right" class="w-full" />
</div>
<div class="visit-field">
<span class="visit-field-label">窗口前</span>
<el-input-number v-model="row.window_before_days" :min="0" :max="365" :step="1" controls-position="right" class="w-full" />
</div>
<div class="visit-field">
<span class="visit-field-label">窗口后</span>
<el-input-number v-model="row.window_after_days" :min="0" :max="365" :step="1" controls-position="right" class="w-full" />
</div>
<el-button
:icon="Delete"
text
type="danger"
class="visit-schedule-remove"
@click="removeVisitScheduleRow(index)"
/>
</div>
</div>
</el-form>
<template #footer>
<div class="visit-schedule-dialog-footer">
<el-button :disabled="drawerConfirmLoading || submitting" @click="cancelVisitScheduleDialogEdit">取消</el-button>
<el-button type="primary" :loading="drawerConfirmLoading || submitting" :disabled="!canEditSetup" @click="saveVisitScheduleDialogEdit">
保存
</el-button>
</div>
</template>
</el-dialog>
<el-drawer
v-if="projectMilestoneEditorVisible"
v-model="projectMilestoneEditorVisible"
@@ -1984,6 +2061,7 @@ import type {
StudySetupConfigResponse,
StudySetupConfigVersionItem,
StudySetupConfigUpsertPayload,
VisitScheduleItem,
} from "../../types/setupConfig";
import StateLoading from "../../components/StateLoading.vue";
import { useStudyStore } from "../../store/study";
@@ -2145,10 +2223,16 @@ const infoTab = ref<"basic" | "summary">("basic");
const step1EditSection = ref<Step1EditSection>("all");
const isEditing = ref(false);
const drawerClosing = ref(false);
const visitScheduleDialogVisible = ref(false);
const drawerVisible = computed(() => {
if (visitScheduleDialogVisible.value) return false;
if (activeStep.value === 2 || activeStep.value === 4) return false;
return isEditing.value || drawerClosing.value;
});
const visitScheduleDragIndex = ref<number | null>(null);
const visitScheduleDragOverIndex = ref<number | null>(null);
type VisitScheduleInsertPosition = "before" | "after";
const visitScheduleInsertPosition = ref<VisitScheduleInsertPosition>("before");
const drawerCloseTimer = ref<number | undefined>(undefined);
const submitting = ref(false);
const formRef = ref<FormInstance>();
@@ -2311,14 +2395,148 @@ const form = ref({
phase: "",
status: "DRAFT",
scope: "国内",
summary_note: "",
objective_note: "",
visit_interval_days: null as number | null,
visit_total: null as number | null,
visit_window_start_offset: null as number | null,
visit_window_end_offset: null as number | null,
visit_schedule: [] as VisitScheduleItem[],
});
const formBaselineSnapshot = ref("");
const regularVisitOptionValue = "__REGULAR_VISIT__";
const safetyFollowUpVisitOptionValue = "__SAFETY_FOLLOW_UP_VISIT__";
const specialVisitOptions = ["筛选访视", "基线访视", "终止治疗", "提前终止"];
const getNextRegularVisitCode = (excludeIndex?: number): string => {
const maxRegularIndex = form.value.visit_schedule.reduce((maxIndex, row, rowIndex) => {
if (rowIndex === excludeIndex) return maxIndex;
const match = /^V(\d+)$/.exec((row.visit_code || "").trim());
if (!match) return maxIndex;
return Math.max(maxIndex, Number(match[1]));
}, 0);
return `V${maxRegularIndex + 1}`;
};
const getNextSafetyFollowUpVisitCode = (excludeIndex?: number): string => {
const maxSafetyFollowUpIndex = form.value.visit_schedule.reduce((maxIndex, row, rowIndex) => {
if (rowIndex === excludeIndex) return maxIndex;
const match = /^安全性随访V(\d+)$/.exec((row.visit_code || "").trim());
if (!match) return maxIndex;
return Math.max(maxIndex, Number(match[1]));
}, 0);
return `安全性随访V${maxSafetyFollowUpIndex + 1}`;
};
const hasDuplicateVisitCode = (visitCode: string, currentIndex: number): boolean => {
const normalizedCode = visitCode.trim();
if (!normalizedCode) return false;
return form.value.visit_schedule.some(
(row, rowIndex) => rowIndex !== currentIndex && (row.visit_code || "").trim() === normalizedCode
);
};
const normalizeVisitSchedule = (rows: VisitScheduleItem[] | undefined | null): VisitScheduleItem[] =>
(rows || []).map((row) => ({
visit_code: (row.visit_code || "").trim(),
baseline_offset_days: Number(row.baseline_offset_days || 0),
window_before_days: Number(row.window_before_days || 0),
window_after_days: Number(row.window_after_days || 0),
}));
const addVisitScheduleRow = () => {
form.value.visit_schedule.push({
visit_code: getNextRegularVisitCode(),
baseline_offset_days: 0,
window_before_days: 0,
window_after_days: 0,
});
};
const removeVisitScheduleRow = (index: number) => {
form.value.visit_schedule.splice(index, 1);
};
const handleVisitScheduleCodeChange = (index: number, value: string) => {
const row = form.value.visit_schedule[index];
if (!row) return;
let nextVisitCode = String(value || "").trim();
if (nextVisitCode === regularVisitOptionValue) {
nextVisitCode = getNextRegularVisitCode(index);
} else if (nextVisitCode === safetyFollowUpVisitOptionValue) {
nextVisitCode = getNextSafetyFollowUpVisitCode(index);
}
if (hasDuplicateVisitCode(nextVisitCode, index)) {
row.visit_code = "";
ElMessage.warning(`访视已存在:${nextVisitCode}`);
return;
}
row.visit_code = nextVisitCode;
};
const handleVisitScheduleDragStart = (index: number, event: DragEvent) => {
visitScheduleDragIndex.value = index;
visitScheduleDragOverIndex.value = index;
visitScheduleInsertPosition.value = "before";
event.dataTransfer?.setData("text/plain", String(index));
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
}
};
const handleVisitScheduleDragOver = (index: number, event: DragEvent) => {
if (visitScheduleDragIndex.value === null) return;
const target = event.currentTarget as HTMLElement | null;
if (target) {
const rect = target.getBoundingClientRect();
visitScheduleInsertPosition.value = event.clientY < rect.top + rect.height / 2 ? "before" : "after";
}
visitScheduleDragOverIndex.value = index;
};
const handleVisitScheduleDrop = (targetIndex: number) => {
const sourceIndex = visitScheduleDragIndex.value;
if (sourceIndex === null) {
handleVisitScheduleDragEnd();
return;
}
let insertIndex = visitScheduleInsertPosition.value === "after" ? targetIndex + 1 : targetIndex;
if (sourceIndex === targetIndex || sourceIndex + 1 === insertIndex) {
handleVisitScheduleDragEnd();
return;
}
const nextRows = [...form.value.visit_schedule];
const [movedRow] = nextRows.splice(sourceIndex, 1);
if (!movedRow) {
handleVisitScheduleDragEnd();
return;
}
if (sourceIndex < insertIndex) {
insertIndex -= 1;
}
nextRows.splice(insertIndex, 0, movedRow);
form.value.visit_schedule = nextRows;
handleVisitScheduleDragEnd();
};
const handleVisitScheduleDragEnd = () => {
visitScheduleDragIndex.value = null;
visitScheduleDragOverIndex.value = null;
visitScheduleInsertPosition.value = "before";
};
const isVisitScheduleInsertTarget = (index: number, position: VisitScheduleInsertPosition) =>
visitScheduleDragIndex.value !== null &&
visitScheduleDragIndex.value !== index &&
visitScheduleDragOverIndex.value === index &&
visitScheduleInsertPosition.value === position;
const formatVisitSchedule = (rows: VisitScheduleItem[] | undefined | null): string => {
const normalized = normalizeVisitSchedule(rows);
if (!normalized.length) return "-";
return normalized
.map(
(row) =>
`${row.visit_code || "-"}:基线+${row.baseline_offset_days}天,窗口-${row.window_before_days}/+${row.window_after_days}`
)
.join("");
};
const formatVisitWindow = (row: VisitScheduleItem): string => `-${row.window_before_days} / +${row.window_after_days}`;
const setupDraft = reactive<SetupConfigDraft>({
projectMilestones: [],
@@ -2354,9 +2572,6 @@ const canEditSetup = computed(
const canMutateDraft = () => canEditSetup.value && !isPublishedView.value;
const canEditStep1BasicGroup = (group: Exclude<Step1EditSection, "all" | "summary">) =>
activeStep.value === 0 && isEditing.value && (step1EditSection.value === "all" || step1EditSection.value === group);
const canEditStep1Summary = computed(
() => activeStep.value === 0 && isEditing.value && (step1EditSection.value === "all" || step1EditSection.value === "summary")
);
const isStep1BasicScopedEdit = computed(
() => activeStep.value === 0 && isEditing.value && ["project", "research", "execution"].includes(step1EditSection.value)
);
@@ -2370,7 +2585,7 @@ const stepActionMode = computed<StepActionMode>(() => {
case 2:
return "edit";
case 0:
return infoTab.value === "summary" ? "edit" : "none";
return "none";
case 1:
return "project-milestone";
case 3:
@@ -2483,14 +2698,9 @@ const serializeFormForCompare = (): string =>
plan_start_date: "",
plan_end_date: "",
planned_enrollment_count: null,
summary_note: form.value.summary_note || "",
objective_note: form.value.objective_note || "",
phase: form.value.phase || "",
status: form.value.status || "",
visit_interval_days: form.value.visit_interval_days,
visit_total: form.value.visit_total,
visit_window_start_offset: form.value.visit_window_start_offset,
visit_window_end_offset: form.value.visit_window_end_offset,
visit_schedule: normalizeVisitSchedule(form.value.visit_schedule),
});
const serializeSetupForCompare = (): string => JSON.stringify(clone(setupDraft));
const buildProjectPublishSnapshotFromStudy = (study: Partial<Study> | null | undefined): ProjectPublishSnapshot => ({
@@ -2512,13 +2722,8 @@ const buildProjectPublishSnapshotFromStudy = (study: Partial<Study> | null | und
plan_end_date: study?.plan_end_date || "",
planned_site_count: study?.planned_site_count ?? null,
planned_enrollment_count: study?.planned_enrollment_count ?? null,
summary_note: study?.summary_note || "",
objective_note: study?.objective_note || "",
status: study?.status || "",
visit_interval_days: study?.visit_interval_days ?? null,
visit_total: study?.visit_total ?? null,
visit_window_start_offset: study?.visit_window_start_offset ?? null,
visit_window_end_offset: study?.visit_window_end_offset ?? null,
visit_schedule: normalizeVisitSchedule(study?.visit_schedule),
});
const buildProjectPublishSnapshot = (): ProjectPublishSnapshot =>
buildProjectPublishSnapshotFromStudy({
@@ -2540,13 +2745,8 @@ const buildProjectPublishSnapshot = (): ProjectPublishSnapshot =>
plan_end_date: form.value.plan_end_date,
planned_site_count: form.value.planned_site_count,
planned_enrollment_count: form.value.planned_enrollment_count,
summary_note: form.value.summary_note,
objective_note: form.value.objective_note,
status: form.value.status,
visit_interval_days: form.value.visit_interval_days,
visit_total: form.value.visit_total,
visit_window_start_offset: form.value.visit_window_start_offset,
visit_window_end_offset: form.value.visit_window_end_offset,
visit_schedule: normalizeVisitSchedule(form.value.visit_schedule),
});
const serializeProjectForPublishCompare = (): string => JSON.stringify(buildProjectPublishSnapshot());
const hasFormUnsavedChanges = computed(() => Boolean(formBaselineSnapshot.value && serializeFormForCompare() !== formBaselineSnapshot.value));
@@ -2774,14 +2974,7 @@ const executionInfoItems = computed<DisplayInfoItem[]>(() => [
},
]);
const summaryInfoItems = computed<DisplayInfoItem[]>(() => [
{ label: "访视总数", value: toDisplayNumber(currentProjectPublishSnapshot.value.visit_total), updated: isProjectSnapshotFieldUpdated("visit_total") },
{ label: "访视间隔(天)", value: toDisplayNumber(currentProjectPublishSnapshot.value.visit_interval_days), updated: isProjectSnapshotFieldUpdated("visit_interval_days") },
{ label: "窗口期起始偏移", value: toDisplayNumber(currentProjectPublishSnapshot.value.visit_window_start_offset), updated: isProjectSnapshotFieldUpdated("visit_window_start_offset") },
{ label: "窗口期结束偏移", value: toDisplayNumber(currentProjectPublishSnapshot.value.visit_window_end_offset), updated: isProjectSnapshotFieldUpdated("visit_window_end_offset") },
{ label: "方案摘要说明", value: currentProjectPublishSnapshot.value.summary_note || "-", updated: isProjectSnapshotFieldUpdated("summary_note") },
{ label: "研究目标摘要", value: currentProjectPublishSnapshot.value.objective_note || "-", updated: isProjectSnapshotFieldUpdated("objective_note") },
]);
const summaryVisitScheduleRows = computed<VisitScheduleItem[]>(() => normalizeVisitSchedule(currentProjectPublishSnapshot.value.visit_schedule));
const rules = ref<FormRules>({
code: [{ required: true, message: requiredMessage(TEXT.common.fields.projectCode), trigger: "blur" }],
@@ -4283,12 +4476,7 @@ const syncForm = (data: Study | null) => {
form.value.phase = data?.phase || "";
form.value.status = data?.status || "DRAFT";
form.value.scope = "国内";
form.value.summary_note = data?.summary_note || "";
form.value.objective_note = data?.objective_note || "";
form.value.visit_interval_days = data?.visit_interval_days ?? null;
form.value.visit_total = data?.visit_total ?? null;
form.value.visit_window_start_offset = data?.visit_window_start_offset ?? null;
form.value.visit_window_end_offset = data?.visit_window_end_offset ?? null;
form.value.visit_schedule = normalizeVisitSchedule(data?.visit_schedule);
formBaselineSnapshot.value = serializeFormForCompare();
};
@@ -4498,6 +4686,9 @@ const startStep1SectionEdit = (section: Exclude<Step1EditSection, "all">) => {
startEdit();
if (isEditing.value) {
step1EditSection.value = section;
if (section === "summary") {
visitScheduleDialogVisible.value = true;
}
}
};
@@ -4512,12 +4703,17 @@ const cancelEdit = () => {
}
isEditing.value = false;
step1EditSection.value = "all";
visitScheduleDialogVisible.value = false;
clearSetupValidationErrors();
formBaselineSnapshot.value = serializeFormForCompare();
};
const closeEditDrawer = () => {
if (!isEditing.value || drawerClosing.value) return;
if (visitScheduleDialogVisible.value) {
cancelEdit();
return;
}
drawerClosing.value = true;
drawerCloseTimer.value = window.setTimeout(() => {
cancelEdit();
@@ -4528,6 +4724,15 @@ const closeEditDrawer = () => {
const closeEditDrawerAfterSave = (onClosed?: () => void) => {
if (!isEditing.value || drawerClosing.value) return;
if (visitScheduleDialogVisible.value) {
visitScheduleDialogVisible.value = false;
isEditing.value = false;
step1EditSection.value = "all";
clearSetupValidationErrors();
formBaselineSnapshot.value = serializeFormForCompare();
onClosed?.();
return;
}
drawerClosing.value = true;
drawerCloseTimer.value = window.setTimeout(() => {
isEditing.value = false;
@@ -4540,6 +4745,32 @@ const closeEditDrawerAfterSave = (onClosed?: () => void) => {
}, 220);
};
const cancelVisitScheduleDialogEdit = () => {
cancelEdit();
};
const saveVisitScheduleDialogEdit = async () => {
if (!canEditSetup.value) return;
drawerConfirmLoading.value = true;
try {
const projectChanged = refreshProjectDirtyState();
if (projectChanged) {
persistProjectDraftToLocal();
} else {
clearLocalProjectDraft();
markServerSyncedIfNoLocalChanges();
}
formBaselineSnapshot.value = serializeFormForCompare();
visitScheduleDialogVisible.value = false;
isEditing.value = false;
step1EditSection.value = "all";
clearSetupValidationErrors();
ElMessage.success("访视计划已保存");
} finally {
drawerConfirmLoading.value = false;
}
};
const handleDrawerConfirm = async () => {
if (!canEditSetup.value) return;
drawerConfirmLoading.value = true;
@@ -4603,14 +4834,9 @@ const saveEdit = async (closeAfterSave = true, showSuccessMessage = true): Promi
plan_end_date: form.value.plan_end_date || null,
planned_site_count: form.value.planned_site_count,
planned_enrollment_count: form.value.planned_enrollment_count,
summary_note: form.value.summary_note?.trim() || null,
objective_note: form.value.objective_note?.trim() || null,
phase: form.value.phase?.trim() || null,
status: form.value.status,
visit_interval_days: form.value.visit_interval_days,
visit_total: form.value.visit_total,
visit_window_start_offset: form.value.visit_window_start_offset,
visit_window_end_offset: form.value.visit_window_end_offset,
visit_schedule: normalizeVisitSchedule(form.value.visit_schedule),
});
project.value = data as Study;
syncFormFromProjectWithoutSetupLink(project.value);
@@ -5225,12 +5451,7 @@ const downloadVersionRaw = (versionItem: StudySetupConfigVersionItem) => {
["项目状态", projectSnapshot?.status ? statusLabel(projectSnapshot.status) : "-"],
["计划中心数", projectSnapshot?.planned_site_count ?? "-"],
["计划入组数", projectSnapshot?.planned_enrollment_count ?? "-"],
["访视总数", projectSnapshot?.visit_total ?? "-"],
["访视间隔(天)", projectSnapshot?.visit_interval_days ?? "-"],
["窗口期起始偏移", projectSnapshot?.visit_window_start_offset ?? "-"],
["窗口期结束偏移", projectSnapshot?.visit_window_end_offset ?? "-"],
["方案摘要说明", projectSnapshot?.summary_note || "-"],
["研究目标摘要", projectSnapshot?.objective_note || "-"],
["访视计划", formatVisitSchedule(projectSnapshot?.visit_schedule)],
];
const step2Headers = ["里程碑", "计划日期", "开始日期", "结束日期", "耗时(天)", "负责人", "状态", "备注"];
@@ -6962,6 +7183,7 @@ onBeforeUnmount(() => {
}
.setup-section {
position: relative;
padding: 8px 12px 12px;
border-bottom: 1px solid #edf2f8;
background: #ffffff;
@@ -7559,6 +7781,12 @@ onBeforeUnmount(() => {
gap: 8px;
}
.info-group-title-actions {
display: flex;
align-items: center;
gap: 10px;
}
.info-group-edit-btn {
min-width: 64px;
height: 28px;
@@ -7674,6 +7902,7 @@ onBeforeUnmount(() => {
.setup-info-tabs {
margin-top: -8px;
position: relative;
}
.setup-section :deep(.el-tabs__item) {
@@ -8017,6 +8246,202 @@ onBeforeUnmount(() => {
margin-bottom: 8px;
}
.summary-section-bar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin: 10px 0 8px;
}
.summary-display-group {
padding-top: 16px;
}
.visit-schedule-display {
width: 100%;
border: 1px solid #e1e9f3;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.visit-schedule-display-head,
.visit-schedule-display-row {
display: grid;
grid-template-columns: minmax(160px, 0.8fr) minmax(160px, 0.8fr) minmax(220px, 1fr);
gap: 16px;
align-items: center;
}
.visit-schedule-display-head {
min-height: 40px;
padding: 0 14px;
background: #f3f7fc;
border-bottom: 1px solid #dce6f2;
color: #35516f;
font-size: 13px;
font-weight: 800;
}
.visit-schedule-display-row {
min-height: 42px;
padding: 0 14px;
border-bottom: 1px solid #edf2f7;
color: #334155;
font-size: 13px;
}
.visit-schedule-display-row:last-of-type {
border-bottom: none;
}
.visit-schedule-display-code {
color: #0f172a;
font-weight: 700;
}
.visit-schedule-display-empty {
padding: 18px 14px;
color: #94a3b8;
font-size: 13px;
text-align: center;
}
.visit-schedule-editor {
width: 100%;
border: 1px solid #e1e9f3;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.visit-schedule-head,
.visit-schedule-row {
display: grid;
grid-template-columns: minmax(220px, 0.9fr) minmax(210px, 1fr) minmax(150px, 0.75fr) minmax(150px, 0.75fr) 48px;
gap: 12px;
align-items: center;
}
.visit-schedule-head {
min-height: 40px;
padding: 0 12px;
background: #f3f7fc;
border-bottom: 1px solid #dce6f2;
color: #35516f;
font-size: 13px;
font-weight: 800;
}
.visit-schedule-row {
position: relative;
padding: 9px 12px;
border-bottom: 1px solid #edf2f7;
cursor: grab;
transition: background-color 0.16s ease, box-shadow 0.16s ease, opacity 0.16s ease;
}
.visit-schedule-row:active {
cursor: grabbing;
}
.visit-schedule-row:last-child {
border-bottom: none;
}
.visit-schedule-row.is-dragging {
background: #f8fbff;
opacity: 0.62;
}
.visit-schedule-row.insert-before::before,
.visit-schedule-row.insert-after::after {
position: absolute;
left: 12px;
right: 12px;
z-index: 2;
height: 3px;
border-radius: 999px;
background: #2f80ed;
box-shadow: 0 0 0 3px rgba(47, 128, 237, 0.14);
content: "";
}
.visit-schedule-row.insert-before::before {
top: -2px;
}
.visit-schedule-row.insert-after::after {
bottom: -2px;
}
.visit-schedule-row.insert-before,
.visit-schedule-row.insert-after {
background: #f8fbff;
}
.visit-code-field {
min-width: 0;
}
.visit-field {
min-width: 0;
}
.visit-field-label {
display: none;
margin-bottom: 5px;
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.visit-schedule-remove {
justify-self: center;
width: 32px;
height: 32px;
}
.visit-schedule-add {
margin-top: 0;
}
.visit-schedule-dialog :deep(.el-dialog) {
border-radius: 10px;
}
.visit-schedule-dialog :deep(.el-dialog__header) {
padding: 18px 20px 12px;
margin: 0;
border-bottom: 1px solid #e6edf7;
}
.visit-schedule-dialog :deep(.el-dialog__title) {
color: #0f172a;
font-size: 18px;
font-weight: 700;
}
.visit-schedule-dialog :deep(.el-dialog__body) {
padding: 14px 20px 18px;
}
.visit-schedule-dialog :deep(.el-dialog__footer) {
padding: 12px 20px;
border-top: 1px solid #e6edf7;
}
.visit-schedule-dialog-form .summary-section-bar {
margin-top: 0;
}
.visit-schedule-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.conflict-readable-diff :deep(.el-table th.el-table__cell) {
background: #f7faff;
color: #2f4672;
@@ -8040,6 +8465,33 @@ onBeforeUnmount(() => {
grid-template-columns: 1fr;
}
.visit-schedule-head {
display: none;
}
.visit-schedule-display-head {
display: none;
}
.visit-schedule-display-row {
grid-template-columns: 1fr;
gap: 4px;
padding: 10px 14px;
}
.visit-schedule-row {
grid-template-columns: 1fr;
gap: 10px;
}
.visit-field-label {
display: block;
}
.visit-schedule-remove {
justify-self: flex-end;
}
.flow-label {
font-size: 14px;
}
+100 -38
View File
@@ -26,7 +26,7 @@
</div>
<el-card class="unified-shell subject-shell" v-loading="loading">
<el-descriptions :column="2" border>
<el-descriptions class="subject-overview-descriptions" :column="2" border>
<el-descriptions-item :label="TEXT.modules.subjectManagement.screeningNo">{{ detail.subject_no || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.site">{{ siteMap[detail.site_id] || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.status">
@@ -59,6 +59,16 @@
/>
<span v-else>{{ displayDate(detail.enrollment_date) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.baselineDate">
<el-date-picker
v-if="subjectEditing"
v-model="subjectForm.baseline_date"
type="date"
value-format="YYYY-MM-DD"
size="small"
/>
<span v-else>{{ displayDate(detail.baseline_date) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.completionDate">
<el-date-picker
v-if="subjectEditing"
@@ -76,14 +86,14 @@
</el-descriptions>
</el-card>
<el-card class="unified-shell subject-shell">
<el-card class="unified-shell subject-shell subject-tabs-shell">
<div class="subject-tabs-action">
<el-button type="primary" :disabled="isReadOnly" @click="currentTabAction.onClick">
{{ currentTabAction.label }}
</el-button>
</div>
<el-tabs v-model="activeTab">
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.history" name="history">
<div class="tab-actions">
<el-button type="primary" :disabled="isReadOnly" @click="openHistoryDialog()">
{{ TEXT.common.actions.newHistory }}
</el-button>
</div>
<el-table :data="historyItems" v-loading="loadingHistory" style="width: 100%" class="subject-detail-table" table-layout="fixed">
<el-table-column prop="record_date" :label="TEXT.common.fields.recordDate" show-overflow-tooltip>
<template #default="scope">{{ displayDate(scope.row.record_date) }}</template>
@@ -118,11 +128,6 @@
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.visits" name="visits">
<div class="tab-actions">
<el-button type="primary" :disabled="isReadOnly" @click="openVisitDialog()">
{{ TEXT.common.actions.newVisit }}
</el-button>
</div>
<el-table :data="visitItems" v-loading="loadingVisits" style="width: 100%" class="subject-detail-table" table-layout="fixed">
<el-table-column prop="visit_code" :label="TEXT.common.fields.visitCode" show-overflow-tooltip />
<el-table-column prop="planned_date" :label="TEXT.common.fields.plannedDate" show-overflow-tooltip>
@@ -201,11 +206,6 @@
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.ae" name="ae">
<div class="tab-actions">
<el-button type="primary" :disabled="isReadOnly" @click="openAeDialog()">
{{ TEXT.common.actions.newAe }}
</el-button>
</div>
<el-table :data="aeItems" v-loading="loadingAes" style="width: 100%" class="subject-detail-table" table-layout="fixed">
<el-table-column prop="onset_date" :label="TEXT.common.fields.occurDate" show-overflow-tooltip>
<template #default="scope">{{ displayDate(scope.row.onset_date) }}</template>
@@ -259,11 +259,6 @@
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.pd" name="pd">
<div class="tab-actions">
<el-button type="primary" :disabled="isReadOnly" @click="openPdDialog()">
{{ TEXT.modules.subjectDetail.newPd }}
</el-button>
</div>
<el-table :data="pdItems" v-loading="loadingPds" style="width: 100%" class="subject-detail-table" table-layout="fixed">
<el-table-column prop="pd_no" :label="TEXT.common.fields.pdNo" show-overflow-tooltip />
<el-table-column prop="pd_type" :label="TEXT.common.fields.pdType" show-overflow-tooltip />
@@ -455,6 +450,7 @@ const detail = reactive<any>({
screening_date: "",
consent_date: "",
enrollment_date: "",
baseline_date: "",
completion_date: "",
drop_reason: "",
});
@@ -464,6 +460,7 @@ const subjectSaving = ref(false);
const subjectForm = reactive({
status: "SCREENING",
enrollment_date: "",
baseline_date: "",
consent_date: "",
completion_date: "",
drop_reason: "",
@@ -615,6 +612,7 @@ const startSubjectEdit = () => {
subjectForm.status = detail.status || "SCREENING";
subjectForm.consent_date = detail.consent_date || "";
subjectForm.enrollment_date = detail.enrollment_date || "";
subjectForm.baseline_date = detail.baseline_date || "";
subjectForm.completion_date = detail.completion_date || "";
subjectForm.drop_reason = detail.drop_reason || "";
subjectEditing.value = true;
@@ -636,13 +634,15 @@ const saveSubjectEdit = async () => {
status: subjectForm.status || null,
consent_date: subjectForm.consent_date || null,
enrollment_date: subjectForm.enrollment_date || null,
baseline_date: subjectForm.baseline_date || null,
completion_date: subjectForm.completion_date || null,
drop_reason: subjectForm.drop_reason || null,
};
await updateSubject(studyId, subjectId, payload);
ElMessage.success(TEXT.common.messages.saveSuccess);
subjectEditing.value = false;
loadSubject();
await loadSubject();
await loadVisits();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
@@ -720,15 +720,7 @@ const loadVisits = async () => {
try {
const { data } = (await fetchVisits(studyId, subjectId)) as any;
const items = Array.isArray(data) ? data : data.items || [];
visitItems.value = items.sort((a: any, b: any) => {
const getNum = (code: any) => {
const text = String(code || "");
if (!text.startsWith("V")) return Number.POSITIVE_INFINITY;
const num = Number(text.slice(1));
return Number.isFinite(num) ? num : Number.POSITIVE_INFINITY;
};
return getNum(a?.visit_code) - getNum(b?.visit_code);
});
visitItems.value = items;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
@@ -1036,6 +1028,20 @@ const openPdDialog = (row?: any) => {
pdDialogVisible.value = true;
};
const currentTabAction = computed(() => {
switch (activeTab.value) {
case "visits":
return { label: TEXT.common.actions.newVisit, onClick: () => openVisitDialog() };
case "ae":
return { label: TEXT.common.actions.newAe, onClick: () => openAeDialog() };
case "pd":
return { label: TEXT.modules.subjectDetail.newPd, onClick: () => openPdDialog() };
case "history":
default:
return { label: TEXT.common.actions.newHistory, onClick: () => openHistoryDialog() };
}
});
const savePd = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
@@ -1123,6 +1129,68 @@ onMounted(async () => {
padding: 0;
}
.subject-tabs-shell {
margin-top: 12px;
border-top: 8px solid #f3f6fb;
}
.subject-tabs-shell {
position: relative;
}
.subject-tabs-shell :deep(.el-tabs__header) {
margin: 0;
padding: 0 160px 0 0;
background: #ffffff;
border-top: 1px solid #e5edf6;
border-bottom: 1px solid #e5edf6;
}
.subject-tabs-shell :deep(.el-tabs__nav-wrap) {
padding-left: 0;
}
.subject-tabs-shell :deep(.el-tabs__item) {
height: 48px;
line-height: 48px;
font-weight: 700;
}
.subject-tabs-shell :deep(.el-tabs__content) {
padding-top: 0;
background: #ffffff;
}
.subject-tabs-action {
position: absolute;
top: 8px;
right: 0;
z-index: 2;
display: flex;
align-items: center;
}
.subject-overview-descriptions :deep(.el-descriptions__table) {
table-layout: fixed;
}
.subject-overview-descriptions :deep(.el-descriptions__label) {
width: 140px;
min-width: 140px;
color: #344258;
font-weight: 700;
}
.subject-overview-descriptions :deep(.el-descriptions__content) {
width: calc(50% - 140px);
min-width: 0;
}
.subject-overview-descriptions :deep(.el-descriptions__cell) {
height: 42px;
vertical-align: middle;
}
.page-title {
margin: 0;
font-size: 22px;
@@ -1140,12 +1208,6 @@ onMounted(async () => {
gap: 8px;
}
.tab-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 10px;
}
.subject-detail-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
+265 -89
View File
@@ -1,86 +1,120 @@
<template>
<div class="page">
<div class="page-header unified-action-bar">
<div>
<h1 class="page-title">{{ isEdit ? TEXT.modules.subjectForm.titleEdit : TEXT.modules.subjectForm.titleNew }}</h1>
<p class="page-subtitle">{{ TEXT.modules.subjectForm.subtitle }}</p>
</div>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div>
<div class="page subject-form-page">
<el-card class="unified-shell subject-form-shell">
<el-form :model="form" label-position="top" class="subject-form">
<div class="form-layout">
<section class="form-section form-section-primary">
<div class="section-heading">
<span class="section-kicker">Subject</span>
<h2>参与者标识</h2>
</div>
<div class="field-grid">
<el-form-item :label="TEXT.modules.subjectManagement.screeningNo" required class="field-span-2">
<el-input
v-model="form.subject_no"
:disabled="isEdit || isReadOnly"
:placeholder="TEXT.common.placeholders.input + TEXT.modules.subjectManagement.screeningNo"
size="large"
/>
</el-form-item>
<el-form-item :label="TEXT.common.fields.site" required class="field-span-2">
<el-select v-model="form.site_id" :disabled="isEdit || isReadOnly" :placeholder="TEXT.common.placeholders.select" size="large">
<el-option v-for="site in sites" :key="site.id" :label="site.name" :value="site.id" :disabled="!site.is_active" />
</el-select>
</el-form-item>
<el-form-item v-if="isEdit" :label="TEXT.common.fields.status" class="field-span-2">
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" :disabled="isReadOnly" size="large">
<el-option :label="TEXT.enums.subjectStatus.SCREENING" value="SCREENING" />
<el-option :label="TEXT.enums.subjectStatus.ENROLLED" value="ENROLLED" />
<el-option :label="TEXT.enums.subjectStatus.COMPLETED" value="COMPLETED" />
<el-option :label="TEXT.enums.subjectStatus.DROPPED" value="DROPPED" />
</el-select>
</el-form-item>
</div>
</section>
<el-card class="unified-shell">
<el-form :model="form" label-width="120px">
<el-form-item :label="TEXT.modules.subjectManagement.screeningNo" required>
<el-input
v-model="form.subject_no"
:disabled="isEdit || isReadOnly"
:placeholder="TEXT.common.placeholders.input + TEXT.modules.subjectManagement.screeningNo"
/>
</el-form-item>
<el-form-item :label="TEXT.common.fields.site" required>
<el-select v-model="form.site_id" :disabled="isEdit || isReadOnly" :placeholder="TEXT.common.placeholders.select">
<el-option v-for="site in sites" :key="site.id" :label="site.name" :value="site.id" :disabled="!site.is_active" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.screeningDate">
<el-date-picker
v-model="form.screening_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isEdit || isReadOnly"
/>
</el-form-item>
<el-form-item :label="TEXT.common.fields.consentDate">
<el-date-picker
v-model="form.consent_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isReadOnly"
/>
</el-form-item>
<el-form-item v-if="isEdit" :label="TEXT.common.fields.status">
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" :disabled="isReadOnly">
<el-option :label="TEXT.enums.subjectStatus.SCREENING" value="SCREENING" />
<el-option :label="TEXT.enums.subjectStatus.ENROLLED" value="ENROLLED" />
<el-option :label="TEXT.enums.subjectStatus.COMPLETED" value="COMPLETED" />
<el-option :label="TEXT.enums.subjectStatus.DROPPED" value="DROPPED" />
</el-select>
</el-form-item>
<el-form-item v-if="isEdit" :label="TEXT.common.fields.enrollmentDate">
<el-date-picker
v-model="form.enrollment_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isReadOnly"
/>
</el-form-item>
<el-form-item v-if="isEdit" :label="TEXT.common.fields.completionDate">
<el-date-picker
v-model="form.completion_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isReadOnly"
/>
</el-form-item>
<el-form-item v-if="isEdit" :label="TEXT.common.fields.dropReason">
<el-input
v-model="form.drop_reason"
type="textarea"
:rows="3"
:placeholder="TEXT.common.placeholders.input"
:disabled="isReadOnly"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">
<section class="form-section">
<div class="section-heading">
<span class="section-kicker">Timeline</span>
<h2>关键日期</h2>
</div>
<div class="field-grid date-grid">
<el-form-item :label="TEXT.common.fields.screeningDate">
<el-date-picker
v-model="form.screening_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isEdit || isReadOnly"
size="large"
/>
</el-form-item>
<el-form-item :label="TEXT.common.fields.consentDate">
<el-date-picker
v-model="form.consent_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isReadOnly"
size="large"
/>
</el-form-item>
<el-form-item :label="TEXT.common.fields.enrollmentDate">
<el-date-picker
v-model="form.enrollment_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isReadOnly"
size="large"
/>
</el-form-item>
<el-form-item :label="TEXT.common.fields.baselineDate">
<el-date-picker
v-model="form.baseline_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isReadOnly"
size="large"
/>
</el-form-item>
<el-form-item v-if="isEdit" :label="TEXT.common.fields.completionDate">
<el-date-picker
v-model="form.completion_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="isReadOnly"
size="large"
/>
</el-form-item>
</div>
</section>
<section v-if="isEdit" class="form-section field-span-all">
<div class="section-heading compact-heading">
<span class="section-kicker">Status</span>
<h2>退出信息</h2>
</div>
<el-form-item :label="TEXT.common.fields.dropReason">
<el-input
v-model="form.drop_reason"
type="textarea"
:rows="3"
:placeholder="TEXT.common.placeholders.input"
:disabled="isReadOnly"
/>
</el-form-item>
</section>
</div>
<div class="form-actions">
<el-button class="form-cancel-btn" @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" class="form-submit-btn" :loading="saving" :disabled="isReadOnly" @click="submit">
{{ TEXT.common.actions.save }}
</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</el-form-item>
</div>
</el-form>
</el-card>
</div>
@@ -120,6 +154,7 @@ const form = reactive({
consent_date: "",
status: "SCREENING",
enrollment_date: "",
baseline_date: "",
completion_date: "",
drop_reason: "",
});
@@ -145,6 +180,7 @@ const load = async () => {
consent_date: data.consent_date || "",
status: data.status || "SCREENING",
enrollment_date: data.enrollment_date || "",
baseline_date: data.baseline_date || "",
completion_date: data.completion_date || "",
drop_reason: data.drop_reason || "",
});
@@ -166,6 +202,7 @@ const submit = async () => {
status: form.status || null,
consent_date: form.consent_date || null,
enrollment_date: form.enrollment_date || null,
baseline_date: form.baseline_date || null,
completion_date: form.completion_date || null,
drop_reason: form.drop_reason || null,
};
@@ -178,6 +215,8 @@ const submit = async () => {
site_id: form.site_id,
screening_date: form.screening_date || null,
consent_date: form.consent_date || null,
enrollment_date: form.enrollment_date || null,
baseline_date: form.baseline_date || null,
};
const { data } = await createSubject(studyId.value, payload);
ElMessage.success(TEXT.common.messages.createSuccess);
@@ -199,27 +238,164 @@ onMounted(async () => {
</script>
<style scoped>
.page {
.subject-form-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-header {
.subject-form-shell {
overflow: hidden;
border: 1px solid #e5edf6;
border-radius: 18px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.05);
}
.subject-form-shell :deep(.el-card__body) {
padding: 0;
}
.subject-form {
background: #ffffff;
}
.form-layout {
display: grid;
grid-template-columns: minmax(320px, 0.78fr) minmax(420px, 1.22fr);
gap: 0;
}
.form-section {
padding: 26px 28px 22px;
border-bottom: 1px solid #edf2f7;
}
.form-section-primary {
border-right: 1px solid #edf2f7;
background: linear-gradient(180deg, #f8fbff 0%, #ffffff 72%);
}
.field-span-all {
grid-column: 1 / -1;
}
.section-heading {
display: flex;
justify-content: space-between;
align-items: flex-end;
flex-direction: column;
gap: 4px;
margin-bottom: 18px;
}
.page-title {
.compact-heading {
margin-bottom: 12px;
}
.section-kicker {
color: #3b82f6;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.section-heading h2 {
margin: 0;
font-size: 22px;
font-weight: 600;
color: #102033;
font-size: 17px;
font-weight: 800;
}
.page-subtitle {
margin: 6px 0 0;
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px 18px;
}
.date-grid {
align-items: start;
}
.field-span-2 {
grid-column: 1 / -1;
}
.subject-form :deep(.el-form-item) {
margin-bottom: 0;
}
.subject-form :deep(.el-form-item__label) {
margin-bottom: 7px;
color: #53657f;
font-size: 13px;
color: var(--ctms-text-secondary);
font-weight: 750;
line-height: 1.2;
}
.subject-form :deep(.el-input__wrapper),
.subject-form :deep(.el-select__wrapper) {
min-height: 44px;
border-radius: 12px;
box-shadow: 0 0 0 1px #dfe7f1 inset;
transition: box-shadow 0.16s ease, background-color 0.16s ease;
}
.subject-form :deep(.el-input__wrapper:hover),
.subject-form :deep(.el-select__wrapper:hover) {
box-shadow: 0 0 0 1px #b8c8dc inset;
}
.subject-form :deep(.el-input__wrapper.is-focus),
.subject-form :deep(.el-select__wrapper.is-focused) {
box-shadow: 0 0 0 1px #3b82f6 inset, 0 0 0 3px rgba(59, 130, 246, 0.12);
}
.subject-form :deep(.el-date-editor),
.subject-form :deep(.el-select),
.subject-form :deep(.el-input) {
width: 100%;
}
.form-actions {
position: sticky;
bottom: 0;
z-index: 1;
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 28px;
border-top: 1px solid #e5edf6;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(10px);
}
.form-submit-btn,
.form-cancel-btn {
min-width: 88px;
height: 40px;
border-radius: 12px;
font-weight: 800;
}
.form-submit-btn {
background: #415b76;
border-color: #415b76;
}
@media (max-width: 960px) {
.form-layout {
grid-template-columns: 1fr;
}
.form-section-primary {
border-right: none;
}
.field-grid {
grid-template-columns: 1fr;
}
.form-actions {
padding: 14px 18px;
}
}
</style>