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:
@@ -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 }>> =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -121,11 +121,12 @@ export const TEXT = {
|
||||
screeningDate: "筛选日期",
|
||||
consentDate: "知情日期",
|
||||
enrollmentDate: "入组日期",
|
||||
baselineDate: "基线/治疗日期",
|
||||
completionDate: "完成日期",
|
||||
dropReason: "退出原因",
|
||||
recordDate: "日期",
|
||||
content: "内容",
|
||||
visitCode: "访视编号",
|
||||
visitCode: "访视",
|
||||
plannedDate: "计划访视",
|
||||
actualDate: "实际访视",
|
||||
windowStart: "窗口开始",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 被请求拦截
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user