立项配置页初步优化
This commit is contained in:
@@ -16,6 +16,7 @@ const instance: AxiosInstance = axios.create({
|
||||
export type ApiRequestConfig = AxiosRequestConfig & {
|
||||
ignoreIdle?: boolean;
|
||||
allowWhileLocked?: boolean;
|
||||
suppressErrorMessage?: boolean;
|
||||
_retry?: boolean;
|
||||
};
|
||||
|
||||
@@ -52,7 +53,10 @@ instance.interceptors.response.use(
|
||||
(response: AxiosResponse) => response,
|
||||
async (error: AxiosError<ApiError>) => {
|
||||
if (error instanceof LockedError) {
|
||||
ElMessage.warning(TEXT.common.messages.sessionLocked || "请解锁后继续操作");
|
||||
const lockedConfig = error.config as ApiRequestConfig | undefined;
|
||||
if (!lockedConfig?.suppressErrorMessage) {
|
||||
ElMessage.warning(TEXT.common.messages.sessionLocked || "请解锁后继续操作");
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const status = error.response?.status;
|
||||
@@ -93,10 +97,18 @@ instance.interceptors.response.use(
|
||||
}
|
||||
} else if (status === 403 && (data as any)?.detail === "账号已停用") {
|
||||
forceLogout();
|
||||
} else if (data?.message || (data as any)?.detail) {
|
||||
ElMessage.error((data as any)?.message || (data as any)?.detail);
|
||||
} else {
|
||||
ElMessage.error(TEXT.common.messages.requestFailed);
|
||||
const suppressErrorMessage = (error.config as ApiRequestConfig | undefined)?.suppressErrorMessage;
|
||||
if (!suppressErrorMessage) {
|
||||
if (data?.message || (data as any)?.detail) {
|
||||
const detail = (data as any)?.detail;
|
||||
const detailMessage =
|
||||
typeof detail === "string" ? detail : typeof detail?.message === "string" ? detail.message : undefined;
|
||||
ElMessage.error((data as any)?.message || detailMessage || TEXT.common.messages.requestFailed);
|
||||
} else {
|
||||
ElMessage.error(TEXT.common.messages.requestFailed);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
@@ -107,6 +119,8 @@ export const apiPost = <T = unknown>(url: string, data?: unknown, config?: ApiRe
|
||||
instance.post<T>(url, data, config);
|
||||
export const apiPatch = <T = unknown>(url: string, data?: unknown, config?: ApiRequestConfig) =>
|
||||
instance.patch<T>(url, data, config);
|
||||
export const apiPut = <T = unknown>(url: string, data?: unknown, config?: ApiRequestConfig) =>
|
||||
instance.put<T>(url, data, config);
|
||||
export const apiDelete = <T = unknown>(url: string, config?: ApiRequestConfig) =>
|
||||
instance.delete<T>(url, config);
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { apiGet, apiPatch, apiPost, apiDelete } from "./axios";
|
||||
import { apiGet, apiPatch, apiPost, apiDelete, apiPut } from "./axios";
|
||||
import type { ApiListResponse, Study } from "../types/api";
|
||||
import type {
|
||||
StudySetupConfigPublishPayload,
|
||||
StudySetupConfigResponse,
|
||||
StudySetupConfigRollbackPayload,
|
||||
StudySetupConfigUpsertPayload,
|
||||
StudySetupConfigVersionItem,
|
||||
} from "../types/setupConfig";
|
||||
|
||||
// Backend expects trailing slash to avoid 307 redirect
|
||||
export const fetchStudies = () => apiGet<ApiListResponse<Study>>("/api/v1/studies/");
|
||||
@@ -18,3 +25,26 @@ export const deleteStudy = (studyId: string) =>
|
||||
export const lockStudy = (studyId: string) =>
|
||||
apiPatch<Study>(`/api/v1/studies/${studyId}/lock`, {});
|
||||
|
||||
export const fetchStudySetupConfig = (studyId: string) =>
|
||||
apiGet<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config`, { suppressErrorMessage: true });
|
||||
|
||||
export const saveStudySetupConfig = (studyId: string, payload: StudySetupConfigUpsertPayload) =>
|
||||
apiPut<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config`, payload, { suppressErrorMessage: true });
|
||||
|
||||
export const publishStudySetupConfig = (studyId: string, payload: StudySetupConfigPublishPayload) =>
|
||||
apiPost<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config/publish`, payload, { suppressErrorMessage: true });
|
||||
|
||||
export const fetchStudySetupConfigVersions = (studyId: string) =>
|
||||
apiGet<StudySetupConfigVersionItem[]>(`/api/v1/studies/${studyId}/setup-config/versions`, { suppressErrorMessage: true });
|
||||
|
||||
export const deleteStudySetupConfigVersion = (studyId: string, targetVersion: number) =>
|
||||
apiDelete(`/api/v1/studies/${studyId}/setup-config/versions/${targetVersion}`, { suppressErrorMessage: true });
|
||||
|
||||
export const rollbackStudySetupConfig = (studyId: string, payload: StudySetupConfigRollbackPayload) =>
|
||||
apiPost<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config/rollback`, payload, { suppressErrorMessage: true });
|
||||
|
||||
export const exportStudySetupConfigExcel = (studyId: string) =>
|
||||
apiGet<Blob>(`/api/v1/studies/${studyId}/setup-config/export-excel`, { responseType: "blob", suppressErrorMessage: true });
|
||||
|
||||
export const importStudySetupConfigExcel = (studyId: string, payload: FormData) =>
|
||||
apiPost<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config/import-excel`, payload, { suppressErrorMessage: true });
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
<el-icon><House /></el-icon>
|
||||
<span>{{ TEXT.menu.projectOverview }}</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/project/milestones">
|
||||
<el-icon><Calendar /></el-icon>
|
||||
<span>{{ TEXT.menu.projectMilestones }}</span>
|
||||
</el-menu-item>
|
||||
<el-sub-menu index="fees">
|
||||
<template #title>
|
||||
<el-icon><Coin /></el-icon>
|
||||
@@ -47,10 +51,15 @@
|
||||
<el-menu-item index="/fees/contracts">{{ TEXT.menu.feeContracts }}</el-menu-item>
|
||||
<el-menu-item index="/fees/special">{{ TEXT.menu.feeSpecials }}</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-menu-item index="/drug/shipments">
|
||||
<el-icon><Box /></el-icon>
|
||||
<span>{{ TEXT.menu.drugShipments }}</span>
|
||||
</el-menu-item>
|
||||
<el-sub-menu index="materials">
|
||||
<template #title>
|
||||
<el-icon><Box /></el-icon>
|
||||
<span>{{ TEXT.menu.materialManagement }}</span>
|
||||
</template>
|
||||
<el-menu-item index="/drug/shipments">{{ TEXT.menu.drugShipments }}</el-menu-item>
|
||||
<el-menu-item index="/materials/equipment">{{ TEXT.menu.materialEquipment }}</el-menu-item>
|
||||
<el-menu-item index="/materials/others">{{ TEXT.menu.materialOthers }}</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-menu-item index="/file-versions">
|
||||
<el-icon><Files /></el-icon>
|
||||
<span>{{ TEXT.menu.fileVersionManagement }}</span>
|
||||
@@ -67,13 +76,22 @@
|
||||
<el-icon><User /></el-icon>
|
||||
<span>{{ TEXT.menu.subjects }}</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/monitoring">
|
||||
<el-sub-menu index="risk-issues">
|
||||
<template #title>
|
||||
<el-icon><Flag /></el-icon>
|
||||
<span>{{ TEXT.menu.riskIssues }}</span>
|
||||
</template>
|
||||
<el-menu-item index="/risk-issues/sae">{{ TEXT.menu.riskIssueSae }}</el-menu-item>
|
||||
<el-menu-item index="/risk-issues/pd">{{ TEXT.menu.riskIssuePd }}</el-menu-item>
|
||||
<el-menu-item index="/risk-issues/monitoring-visits">{{ TEXT.menu.riskIssueMonitoringVisits }}</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-menu-item index="/monitoring-audit">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
<span>{{ TEXT.menu.monitoring }}</span>
|
||||
<span>{{ TEXT.menu.monitoringAudit }}</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/audit">
|
||||
<el-menu-item index="/etmf">
|
||||
<el-icon><Flag /></el-icon>
|
||||
<span>{{ TEXT.menu.audit }}</span>
|
||||
<span>{{ TEXT.menu.etmf }}</span>
|
||||
</el-menu-item>
|
||||
<el-sub-menu index="knowledge">
|
||||
<template #title>
|
||||
@@ -152,9 +170,9 @@
|
||||
shape="square"
|
||||
class="user-avatar-comp"
|
||||
>
|
||||
{{ (auth.user?.username || auth.user?.email || TEXT.common.labels.userInitialFallback).charAt(0).toUpperCase() }}
|
||||
{{ userDisplayInitial }}
|
||||
</el-avatar>
|
||||
<span class="username">{{ auth.user?.username || auth.user?.email || TEXT.common.labels.userFallback }}</span>
|
||||
<span class="username">{{ userDisplayName }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
@@ -208,22 +226,36 @@ const route = useRoute();
|
||||
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
||||
const isPm = computed(() => auth.user?.role === "PM" || study.currentStudyRole === "PM");
|
||||
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
|
||||
const userDisplayName = computed(
|
||||
() => auth.user?.full_name || auth.user?.username || auth.user?.email || TEXT.common.labels.userFallback
|
||||
);
|
||||
const userDisplayInitial = computed(() => {
|
||||
const source = auth.user?.full_name || auth.user?.username || auth.user?.email || TEXT.common.labels.userInitialFallback;
|
||||
return source.charAt(0).toUpperCase();
|
||||
});
|
||||
const activeMenu = computed(() => {
|
||||
const path = route.path;
|
||||
if (path.startsWith("/project/milestones")) return "/project/milestones";
|
||||
if (path.startsWith("/project/")) return "/project/overview";
|
||||
if (path.startsWith("/fees/contracts")) return "/fees/contracts";
|
||||
if (path.startsWith("/fees/special")) return "/fees/special";
|
||||
if (path.startsWith("/finance/contracts")) return "/fees/contracts";
|
||||
if (path.startsWith("/finance/special")) return "/fees/special";
|
||||
if (path.startsWith("/drug/shipments")) return "/drug/shipments";
|
||||
if (path.startsWith("/materials/equipment")) return "/materials/equipment";
|
||||
if (path.startsWith("/materials/others")) return "/materials/others";
|
||||
if (path.startsWith("/file-versions") || path.startsWith("/trial/") || path.startsWith("/documents/")) return "/file-versions";
|
||||
if (path.startsWith("/startup/feasibility") || path.startsWith("/startup/ethics")) return "/startup/feasibility-ethics";
|
||||
if (path.startsWith("/startup/meeting-auth") || path.startsWith("/startup/kickoff") || path.startsWith("/startup/training")) {
|
||||
return "/startup/meeting-auth";
|
||||
}
|
||||
if (path.startsWith("/subjects")) return "/subjects";
|
||||
if (path.startsWith("/monitoring")) return "/monitoring";
|
||||
if (path.startsWith("/audit")) return "/audit";
|
||||
if (path.startsWith("/risk-issues/sae")) return "/risk-issues/sae";
|
||||
if (path.startsWith("/risk-issues/pd")) return "/risk-issues/pd";
|
||||
if (path.startsWith("/risk-issues/monitoring-visits")) return "/risk-issues/monitoring-visits";
|
||||
if (path.startsWith("/risk-issues")) return "/risk-issues/sae";
|
||||
if (path.startsWith("/monitoring-audit") || path.startsWith("/monitoring") || path.startsWith("/audit")) return "/monitoring-audit";
|
||||
if (path.startsWith("/etmf")) return "/etmf";
|
||||
if (path.startsWith("/knowledge/medical-consult")) return "/knowledge/medical-consult";
|
||||
if (path.startsWith("/knowledge/notes")) return "/knowledge/notes";
|
||||
if (path.startsWith("/knowledge/support-files")) return "/knowledge/support-files";
|
||||
@@ -284,8 +316,16 @@ const breadcrumbs = computed(() => {
|
||||
const moduleMap: Record<string, { label: string, path: string }> = {
|
||||
"/fees/contracts": { label: TEXT.menu.feeContracts, path: "/fees/contracts" },
|
||||
"/fees/special": { label: TEXT.menu.feeSpecials, path: "/fees/special" },
|
||||
"/project/milestones": { label: TEXT.menu.projectMilestones, path: "/project/milestones" },
|
||||
"/drug/shipments": { label: TEXT.menu.drugShipments, path: "/drug/shipments" },
|
||||
"/materials/equipment": { label: TEXT.menu.materialEquipment, path: "/materials/equipment" },
|
||||
"/materials/others": { label: TEXT.menu.materialOthers, path: "/materials/others" },
|
||||
"/subjects": { label: TEXT.menu.subjects, path: "/subjects" },
|
||||
"/risk-issues/sae": { label: TEXT.menu.riskIssueSae, path: "/risk-issues/sae" },
|
||||
"/risk-issues/pd": { label: TEXT.menu.riskIssuePd, path: "/risk-issues/pd" },
|
||||
"/risk-issues/monitoring-visits": { label: TEXT.menu.riskIssueMonitoringVisits, path: "/risk-issues/monitoring-visits" },
|
||||
"/monitoring-audit": { label: TEXT.menu.monitoringAudit, path: "/monitoring-audit" },
|
||||
"/etmf": { label: TEXT.menu.etmf, path: "/etmf" },
|
||||
"/startup/feasibility": { label: TEXT.menu.startupFeasibilityEthics, path: "/startup/feasibility-ethics" },
|
||||
"/startup/ethics": { label: TEXT.menu.startupFeasibilityEthics, path: "/startup/feasibility-ethics" },
|
||||
"/startup/kickoff": { label: TEXT.menu.startupMeetingAuth, path: "/startup/meeting-auth" },
|
||||
@@ -359,7 +399,15 @@ const onLogout = () => {
|
||||
router.replace("/login");
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
if (auth.token && !auth.user) {
|
||||
try {
|
||||
await auth.fetchMe();
|
||||
} catch {
|
||||
onLogout();
|
||||
return;
|
||||
}
|
||||
}
|
||||
loadStudies();
|
||||
});
|
||||
|
||||
@@ -382,7 +430,7 @@ const onCommand = (cmd: string) => {
|
||||
background-color: #2c3e50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 0 12px rgba(0, 0, 0, 0.08);
|
||||
z-index: 10;
|
||||
border-right: none;
|
||||
transition: width 0.18s ease;
|
||||
@@ -409,10 +457,10 @@ const onCommand = (cmd: string) => {
|
||||
}
|
||||
|
||||
.aside-logo {
|
||||
height: 64px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
padding: 0 14px;
|
||||
background-color: transparent;
|
||||
transition: padding 0.18s ease;
|
||||
}
|
||||
@@ -438,12 +486,12 @@ const onCommand = (cmd: string) => {
|
||||
border-right: none;
|
||||
background-color: transparent;
|
||||
flex: 1;
|
||||
padding: 12px 0;
|
||||
padding: 4px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.menu-divider {
|
||||
padding: 16px 16px 8px;
|
||||
padding: 8px 14px 2px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
@@ -452,10 +500,10 @@ const onCommand = (cmd: string) => {
|
||||
}
|
||||
|
||||
.layout-aside :deep(.el-menu-item) {
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
margin: 4px 10px;
|
||||
border-radius: 8px;
|
||||
height: 34px;
|
||||
line-height: 34px;
|
||||
margin: 1px 8px;
|
||||
border-radius: 7px;
|
||||
color: #f1f5f9 !important;
|
||||
transition: var(--ctms-transition);
|
||||
}
|
||||
@@ -475,19 +523,19 @@ const onCommand = (cmd: string) => {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 3px;
|
||||
background-color: #3b82f6;
|
||||
background-color: #94b8d1;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.layout-aside :deep(.el-sub-menu__title) {
|
||||
color: #f1f5f9 !important;
|
||||
margin: 4px 10px;
|
||||
border-radius: 8px;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
margin: 1px 8px;
|
||||
border-radius: 7px;
|
||||
height: 34px;
|
||||
line-height: 34px;
|
||||
}
|
||||
|
||||
.layout-aside :deep(.el-sub-menu__title:hover) {
|
||||
@@ -505,8 +553,8 @@ const onCommand = (cmd: string) => {
|
||||
}
|
||||
|
||||
.layout-aside :deep(.el-menu--inline .el-menu-item) {
|
||||
padding-left: 48px !important;
|
||||
margin: 2px 12px;
|
||||
padding-left: 40px !important;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
@@ -514,20 +562,24 @@ const onCommand = (cmd: string) => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
padding: 0 10px;
|
||||
box-shadow: none;
|
||||
height: 64px !important;
|
||||
height: 52px !important;
|
||||
z-index: 9;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
background: #f7f8fb;
|
||||
}
|
||||
|
||||
.header-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
color: var(--ctms-text-regular);
|
||||
font-size: 14px;
|
||||
margin-left: 20px;
|
||||
font-size: 13px;
|
||||
margin-left: 14px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -570,7 +622,7 @@ const onCommand = (cmd: string) => {
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
@@ -603,7 +655,7 @@ const onCommand = (cmd: string) => {
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
color: var(--ctms-text-regular);
|
||||
transition: var(--ctms-transition);
|
||||
height: 40px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.dropdown-trigger.compact:hover {
|
||||
@@ -633,11 +685,13 @@ const onCommand = (cmd: string) => {
|
||||
.layout-main {
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
background: #f7f8fb;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
padding: 16px;
|
||||
min-height: calc(100vh - 64px);
|
||||
padding: 6px 8px;
|
||||
min-height: calc(100vh - 52px);
|
||||
background: #f7f8fb;
|
||||
}
|
||||
|
||||
/* 过渡动画 */
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
<div class="lock-backdrop" />
|
||||
<div class="lock-panel">
|
||||
<h2 class="lock-title">已锁定</h2>
|
||||
<p class="lock-desc">30 分钟无操作,请输入密码解锁</p>
|
||||
<p class="lock-desc">30 分钟无操作将锁定,2 小时无操作将自动退出登录</p>
|
||||
<p class="lock-countdown">剩余自动登出:{{ autoLogoutCountdown }}</p>
|
||||
<el-input
|
||||
ref="passwordInput"
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
name="lock-password"
|
||||
placeholder="请输入登录密码"
|
||||
show-password
|
||||
@keyup.enter="onUnlock"
|
||||
@@ -24,11 +27,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, ref, watch } from "vue";
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { useSessionStore } from "../store/session";
|
||||
import { UNLOCK_MAX_ATTEMPTS, unlockWithPassword, forceLogout } from "../session/sessionManager";
|
||||
import {
|
||||
AUTO_LOGOUT_TIMEOUT_HOURS,
|
||||
UNLOCK_MAX_ATTEMPTS,
|
||||
unlockWithPassword,
|
||||
forceLogout,
|
||||
} from "../session/sessionManager";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const session = useSessionStore();
|
||||
@@ -36,6 +44,41 @@ const password = ref("");
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const passwordInput = ref<{ focus?: () => void } | null>(null);
|
||||
const nowTs = ref(Date.now());
|
||||
let countdownTimer: number | null = null;
|
||||
|
||||
const pad2 = (value: number) => String(value).padStart(2, "0");
|
||||
|
||||
const autoLogoutCountdown = computed(() => {
|
||||
const fallbackDeadlineAt =
|
||||
Math.min(session.lastUserActiveAt, session.lastNetworkActiveAt) + AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
|
||||
const deadlineAt = session.lockAutoLogoutDeadlineAt || fallbackDeadlineAt;
|
||||
const remainMs = Math.max(0, deadlineAt - nowTs.value);
|
||||
const remainSec = Math.floor(remainMs / 1000);
|
||||
const hours = Math.floor(remainSec / 3600);
|
||||
const minutes = Math.floor((remainSec % 3600) / 60);
|
||||
const seconds = remainSec % 60;
|
||||
if (hours > 0) {
|
||||
return `${pad2(hours)}:${pad2(minutes)}:${pad2(seconds)}`;
|
||||
}
|
||||
return `${pad2(minutes)}:${pad2(seconds)}`;
|
||||
});
|
||||
|
||||
const startCountdown = () => {
|
||||
if (countdownTimer) {
|
||||
window.clearInterval(countdownTimer);
|
||||
}
|
||||
nowTs.value = Date.now();
|
||||
countdownTimer = window.setInterval(() => {
|
||||
nowTs.value = Date.now();
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const stopCountdown = () => {
|
||||
if (!countdownTimer) return;
|
||||
window.clearInterval(countdownTimer);
|
||||
countdownTimer = null;
|
||||
};
|
||||
|
||||
const focusPassword = () => {
|
||||
nextTick(() => {
|
||||
@@ -43,10 +86,23 @@ const focusPassword = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const resolveUnlockEmail = async () => {
|
||||
if (session.lockEmail) return session.lockEmail;
|
||||
if (auth.user?.email) return auth.user.email;
|
||||
const cachedEmail = localStorage.getItem("ctms_last_login_email");
|
||||
if (cachedEmail) return cachedEmail;
|
||||
try {
|
||||
const me = await auth.fetchMe();
|
||||
return me?.email || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const onUnlock = async () => {
|
||||
if (loading.value) return;
|
||||
errorMessage.value = "";
|
||||
const email = auth.user?.email;
|
||||
const email = await resolveUnlockEmail();
|
||||
if (!email) {
|
||||
ElMessage.error("无法获取用户信息,请重新登录");
|
||||
forceLogout();
|
||||
@@ -63,7 +119,7 @@ const onUnlock = async () => {
|
||||
password.value = "";
|
||||
} catch (err: any) {
|
||||
session.incrementUnlockAttempt();
|
||||
const message = err?.response?.data?.detail || err?.response?.data?.message || "密码错误";
|
||||
const message = err?.response?.data?.detail || err?.response?.data?.message || err?.message || "密码错误";
|
||||
errorMessage.value = message;
|
||||
if (session.unlockAttempts >= UNLOCK_MAX_ATTEMPTS) {
|
||||
ElMessage.error("错误次数过多,请重新登录");
|
||||
@@ -80,17 +136,33 @@ const onLogout = () => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
password.value = "";
|
||||
errorMessage.value = "";
|
||||
focusPassword();
|
||||
if (session.locked) {
|
||||
startCountdown();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => session.locked,
|
||||
(value) => {
|
||||
if (value) {
|
||||
password.value = "";
|
||||
errorMessage.value = "";
|
||||
focusPassword();
|
||||
startCountdown();
|
||||
return;
|
||||
}
|
||||
password.value = "";
|
||||
errorMessage.value = "";
|
||||
stopCountdown();
|
||||
}
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
stopCountdown();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -135,6 +207,13 @@ watch(
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.lock-countdown {
|
||||
margin: -4px 0 0;
|
||||
color: #1d4ed8;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lock-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
deleteStudySetupConfigVersion,
|
||||
exportStudySetupConfigExcel,
|
||||
fetchStudySetupConfig,
|
||||
fetchStudySetupConfigVersions,
|
||||
importStudySetupConfigExcel,
|
||||
publishStudySetupConfig,
|
||||
rollbackStudySetupConfig,
|
||||
saveStudySetupConfig,
|
||||
} from "../api/studies";
|
||||
import type {
|
||||
StudySetupConfigPublishPayload,
|
||||
StudySetupConfigRollbackPayload,
|
||||
StudySetupConfigUpsertPayload,
|
||||
} from "../types/setupConfig";
|
||||
|
||||
export const useSetupConfig = () => {
|
||||
const getConfig = (studyId: string) => fetchStudySetupConfig(studyId);
|
||||
const saveConfig = (studyId: string, payload: StudySetupConfigUpsertPayload) => saveStudySetupConfig(studyId, payload);
|
||||
const publishConfig = (studyId: string, payload: StudySetupConfigPublishPayload) => publishStudySetupConfig(studyId, payload);
|
||||
const listVersions = (studyId: string) => fetchStudySetupConfigVersions(studyId);
|
||||
const rollbackConfig = (studyId: string, payload: StudySetupConfigRollbackPayload) => rollbackStudySetupConfig(studyId, payload);
|
||||
const deleteVersion = (studyId: string, targetVersion: number) => deleteStudySetupConfigVersion(studyId, targetVersion);
|
||||
const exportExcel = (studyId: string) => exportStudySetupConfigExcel(studyId);
|
||||
const importExcel = (studyId: string, payload: FormData) => importStudySetupConfigExcel(studyId, payload);
|
||||
|
||||
return {
|
||||
getConfig,
|
||||
saveConfig,
|
||||
publishConfig,
|
||||
listVersions,
|
||||
rollbackConfig,
|
||||
deleteVersion,
|
||||
exportExcel,
|
||||
importExcel,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -141,6 +141,9 @@ export const TEXT = {
|
||||
severity: "严重等级",
|
||||
causality: "关联性",
|
||||
outcome: "结局",
|
||||
aeType: "AE 类型",
|
||||
isSae: "是否 SAE",
|
||||
isSusar: "是否 SUSAR",
|
||||
submitDate: "递交日期",
|
||||
acceptDate: "受理日期",
|
||||
approvedDate: "批准日期",
|
||||
@@ -236,6 +239,11 @@ export const TEXT = {
|
||||
FINANCE_REJECTED: { label: "费用驳回", actionText: "驳回了费用", targetLabel: "费用" },
|
||||
IMP_CONFIRMED: { label: "药品出入库确认", actionText: "确认了药品出入库记录", targetLabel: "药品交易" },
|
||||
QUERY_RESOLVED: { label: "数据问题解决", actionText: "标记数据问题已解决", targetLabel: "数据问题" },
|
||||
CREATE_SETUP_CONFIG: { label: "初始化立项配置", actionText: "初始化了立项配置", targetLabel: "立项配置" },
|
||||
UPDATE_SETUP_CONFIG: { label: "保存立项配置", actionText: "更新了立项配置草稿", targetLabel: "立项配置" },
|
||||
PUBLISH_SETUP_CONFIG: { label: "发布立项配置", actionText: "发布了立项配置", targetLabel: "立项配置" },
|
||||
IMPORT_SETUP_CONFIG: { label: "导入立项配置", actionText: "导入了立项配置", targetLabel: "立项配置" },
|
||||
IMPORT_SETUP_CONFIG_EXCEL: { label: "导入立项配置(Excel)", actionText: "通过 Excel 导入了立项配置", targetLabel: "立项配置" },
|
||||
},
|
||||
},
|
||||
menu: {
|
||||
@@ -246,17 +254,27 @@ export const TEXT = {
|
||||
auditLogs: "审计日志",
|
||||
currentProject: "当前项目",
|
||||
projectOverview: "项目总览",
|
||||
projectMilestones: "项目里程碑",
|
||||
finance: "费用管理",
|
||||
financeContracts: "合同管理",
|
||||
financeSpecials: "特殊费用管理",
|
||||
feeContracts: "合同管理",
|
||||
feeSpecials: "特殊费用管理",
|
||||
drug: "药品管理",
|
||||
materialManagement: "物资管理",
|
||||
drugShipments: "药品流向管理",
|
||||
materialEquipment: "设备管理",
|
||||
materialOthers: "其他",
|
||||
fileVersionManagement: "文件版本管理",
|
||||
startupFeasibilityEthics: "立项与伦理",
|
||||
startupMeetingAuth: "启动与授权",
|
||||
subjects: "参与者管理",
|
||||
riskIssues: "风险问题",
|
||||
riskIssueSae: "AE/SAE",
|
||||
riskIssuePd: "PD",
|
||||
riskIssueMonitoringVisits: "监查访视问题",
|
||||
monitoringAudit: "监查稽查",
|
||||
etmf: "eTMF",
|
||||
monitoring: "监查",
|
||||
audit: "稽查",
|
||||
sharedLibrary: "共享库",
|
||||
@@ -316,6 +334,12 @@ export const TEXT = {
|
||||
notificationsSubtitle: "文件版本更新将推送在此处",
|
||||
notificationsEmpty: "暂无通知",
|
||||
},
|
||||
projectMilestones: {
|
||||
title: "项目里程碑",
|
||||
subtitle: "里程碑计划与执行跟踪",
|
||||
listTitle: "里程碑列表",
|
||||
emptyDescription: "项目里程碑模块正在建设中,敬请期待。",
|
||||
},
|
||||
financeContracts: {
|
||||
title: "合同费用",
|
||||
subtitle: "维护分中心合同费用与附件",
|
||||
@@ -443,6 +467,18 @@ export const TEXT = {
|
||||
detailTitle: "运输记录详情",
|
||||
detailSubtitle: "查看药品流向信息与附件",
|
||||
},
|
||||
materialEquipment: {
|
||||
title: "设备管理",
|
||||
subtitle: "设备台账与状态跟踪",
|
||||
listTitle: "设备列表",
|
||||
emptyDescription: "设备管理模块正在建设中,敬请期待。",
|
||||
},
|
||||
materialOthers: {
|
||||
title: "其他物资",
|
||||
subtitle: "其他物资登记与管理",
|
||||
listTitle: "物资列表",
|
||||
emptyDescription: "其他物资模块正在建设中,敬请期待。",
|
||||
},
|
||||
fileVersionManagement: {
|
||||
title: "文件版本管理",
|
||||
subtitle: "管理试验文档与版本审批",
|
||||
@@ -610,19 +646,55 @@ export const TEXT = {
|
||||
},
|
||||
subjectDetail: {
|
||||
title: "参与者详情",
|
||||
subtitle: "查看病史、访视与 AE 信息",
|
||||
subtitle: "查看病史、访视、AE 与 PD 信息",
|
||||
tabs: {
|
||||
history: "病史",
|
||||
visits: "访视",
|
||||
ae: "AE 信息",
|
||||
pd: "PD",
|
||||
},
|
||||
aeType: {
|
||||
ae: "AE",
|
||||
sae: "SAE",
|
||||
susar: "SUSAR",
|
||||
},
|
||||
emptyHistory: "暂无病史记录",
|
||||
emptyVisits: "暂无访视记录",
|
||||
emptyAe: "暂无 AE 记录",
|
||||
emptyPd: "暂无 PD 记录",
|
||||
dialogHistory: "病史记录",
|
||||
dialogVisit: "访视记录",
|
||||
dialogAe: "AE 记录",
|
||||
},
|
||||
riskIssues: {
|
||||
title: "风险问题",
|
||||
subtitle: "项目风险与问题跟踪管理",
|
||||
listTitle: "风险问题列表",
|
||||
emptyDescription: "风险问题模块正在建设中,敬请期待。",
|
||||
},
|
||||
riskIssueSae: {
|
||||
title: "AE/SAE",
|
||||
subtitle: "同步参与者 AE/SAE 信息列表",
|
||||
listTitle: "AE/SAE 列表",
|
||||
emptyDescription: "暂无 AE/SAE 记录",
|
||||
subjectNo: "参与者编号",
|
||||
toSubject: "查看参与者",
|
||||
},
|
||||
riskIssuePd: {
|
||||
title: "PD",
|
||||
subtitle: "同步参与者 PD 相关信息列表",
|
||||
listTitle: "PD 列表",
|
||||
emptyDescription: "暂无参与者记录",
|
||||
syncStatus: "同步状态",
|
||||
synced: "已同步",
|
||||
toSubject: "查看参与者",
|
||||
},
|
||||
riskIssueMonitoringVisits: {
|
||||
title: "监查访视问题",
|
||||
subtitle: "功能建设中",
|
||||
listTitle: "监查访视问题列表",
|
||||
emptyDescription: "监查访视问题模块正在建设中,敬请期待。",
|
||||
},
|
||||
knowledgeMedicalConsult: {
|
||||
title: "医学咨询",
|
||||
subtitle: "常见问题与答复",
|
||||
@@ -695,6 +767,18 @@ export const TEXT = {
|
||||
listTitle: "稽查记录列表",
|
||||
emptyDescription: "稽查模块正在准备基础框架,敬请期待。",
|
||||
},
|
||||
monitoringAudit: {
|
||||
title: "监查稽查",
|
||||
subtitle: "功能建设中",
|
||||
listTitle: "监查稽查列表",
|
||||
emptyDescription: "监查稽查模块正在建设中,敬请期待。",
|
||||
},
|
||||
etmf: {
|
||||
title: "eTMF",
|
||||
subtitle: "功能建设中",
|
||||
listTitle: "eTMF 列表",
|
||||
emptyDescription: "eTMF 模块正在建设中,敬请期待。",
|
||||
},
|
||||
adminUserApproval: {
|
||||
title: "注册审核",
|
||||
subtitle: "仅显示待审核账号,审核通过后即可登录",
|
||||
@@ -770,7 +854,6 @@ export const TEXT = {
|
||||
createdAt: "创建时间",
|
||||
newTitle: "新建项目",
|
||||
editTitle: "编辑项目",
|
||||
visitTemplate: "访视模板",
|
||||
sponsorPlaceholder: "申办方(可选)",
|
||||
protocolPlaceholder: "方案号(可选)",
|
||||
phasePlaceholder: "研究分期(可选)",
|
||||
@@ -780,6 +863,9 @@ export const TEXT = {
|
||||
detailTitle: "项目详情",
|
||||
basicInfo: "项目基本信息",
|
||||
filesTitle: "项目通用文件",
|
||||
setupStepCenterConfirm: "中心确认",
|
||||
setupSaveSuccess: "配置草稿已保存",
|
||||
setupSaveFailed: "配置草稿保存失败",
|
||||
lockConfirmPrompt: "请输入“确认锁定”以继续",
|
||||
lockConfirmTitle: "二次确认",
|
||||
lockConfirmPlaceholder: "请输入确认锁定",
|
||||
@@ -828,6 +914,7 @@ export const TEXT = {
|
||||
contactPlaceholder: "选择项目成员作为负责人,可多选",
|
||||
updateSuccess: "中心已更新",
|
||||
createSuccess: "中心已创建",
|
||||
leadUnitTag: "组长单位",
|
||||
disableConfirm: "该操作将影响中心数据,请确认是否继续",
|
||||
disableTitle: "停用中心",
|
||||
disableSuccess: "已停用",
|
||||
@@ -842,6 +929,7 @@ export const TEXT = {
|
||||
},
|
||||
adminAuditLogs: {
|
||||
title: "审计日志",
|
||||
filterEntityType: "对象类型",
|
||||
filterEvent: "操作类型",
|
||||
filterOperator: "操作人",
|
||||
filterResult: "结果",
|
||||
|
||||
@@ -6,6 +6,7 @@ import dayjs from "dayjs";
|
||||
import "dayjs/locale/zh-cn";
|
||||
import "element-plus/dist/index.css";
|
||||
import "./styles/main.css";
|
||||
import "./styles/unified-page.css";
|
||||
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
|
||||
@@ -17,6 +17,7 @@ import AdminSites from "../views/admin/Sites.vue";
|
||||
import ProjectDetail from "../views/admin/ProjectDetail.vue";
|
||||
import ProfileSettings from "../views/ProfileSettings.vue";
|
||||
import ProjectOverview from "../views/ia/ProjectOverview.vue";
|
||||
import ProjectMilestones from "../views/ia/ProjectMilestones.vue";
|
||||
import FinanceContracts from "../views/ia/FinanceContracts.vue";
|
||||
import FinanceSpecial from "../views/ia/FinanceSpecial.vue";
|
||||
import FeeContracts from "../views/fees/ContractFees.vue";
|
||||
@@ -26,14 +27,19 @@ import FeeSpecials from "../views/fees/SpecialExpenses.vue";
|
||||
import FeeSpecialForm from "../views/fees/SpecialExpenseForm.vue";
|
||||
import FeeSpecialDetail from "../views/fees/SpecialExpenseDetail.vue";
|
||||
import DrugShipments from "../views/ia/DrugShipments.vue";
|
||||
import MaterialEquipment from "../views/ia/MaterialEquipment.vue";
|
||||
import MaterialOthers from "../views/ia/MaterialOthers.vue";
|
||||
import FileVersionManagement from "../views/ia/FileVersionManagement.vue";
|
||||
import DocumentList from "../views/documents/DocumentList.vue";
|
||||
import DocumentDetail from "../views/documents/DocumentDetail.vue";
|
||||
import StartupFeasibilityEthics from "../views/ia/StartupFeasibilityEthics.vue";
|
||||
import StartupMeetingAuth from "../views/ia/StartupMeetingAuth.vue";
|
||||
import SubjectManagement from "../views/ia/SubjectManagement.vue";
|
||||
import MonitoringPlaceholder from "../views/ia/MonitoringPlaceholder.vue";
|
||||
import AuditPlaceholder from "../views/ia/AuditPlaceholder.vue";
|
||||
import RiskIssueSae from "../views/ia/RiskIssueSae.vue";
|
||||
import RiskIssuePd from "../views/ia/RiskIssuePd.vue";
|
||||
import RiskIssueMonitoringVisits from "../views/ia/RiskIssueMonitoringVisits.vue";
|
||||
import MonitoringAuditPlaceholder from "../views/ia/MonitoringAuditPlaceholder.vue";
|
||||
import EtmfPlaceholder from "../views/ia/EtmfPlaceholder.vue";
|
||||
import KnowledgeMedicalConsult from "../views/ia/KnowledgeMedicalConsult.vue";
|
||||
import KnowledgeNotes from "../views/ia/KnowledgeNotes.vue";
|
||||
import KnowledgeSupportFiles from "../views/ia/KnowledgeSupportFiles.vue";
|
||||
@@ -100,6 +106,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: ProjectOverview,
|
||||
meta: { title: TEXT.menu.projectOverview, requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "project/milestones",
|
||||
name: "ProjectMilestones",
|
||||
component: ProjectMilestones,
|
||||
meta: { title: TEXT.menu.projectMilestones, requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "finance/contracts",
|
||||
name: "FinanceContracts",
|
||||
@@ -202,6 +214,18 @@ const routes: RouteRecordRaw[] = [
|
||||
component: DrugShipments,
|
||||
meta: { title: TEXT.menu.drugShipments, requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "materials/equipment",
|
||||
name: "MaterialEquipment",
|
||||
component: MaterialEquipment,
|
||||
meta: { title: TEXT.menu.materialEquipment, requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "materials/others",
|
||||
name: "MaterialOthers",
|
||||
component: MaterialOthers,
|
||||
meta: { title: TEXT.menu.materialOthers, requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "drug/shipments/new",
|
||||
name: "DrugShipmentNew",
|
||||
@@ -346,17 +370,47 @@ const routes: RouteRecordRaw[] = [
|
||||
component: SubjectForm,
|
||||
meta: { title: TEXT.common.actions.edit + TEXT.menu.subjects, requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "risk-issues",
|
||||
redirect: "/risk-issues/sae",
|
||||
},
|
||||
{
|
||||
path: "risk-issues/sae",
|
||||
name: "RiskIssueSae",
|
||||
component: RiskIssueSae,
|
||||
meta: { title: TEXT.menu.riskIssueSae, requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "risk-issues/pd",
|
||||
name: "RiskIssuePd",
|
||||
component: RiskIssuePd,
|
||||
meta: { title: TEXT.menu.riskIssuePd, requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "risk-issues/monitoring-visits",
|
||||
name: "RiskIssueMonitoringVisits",
|
||||
component: RiskIssueMonitoringVisits,
|
||||
meta: { title: TEXT.menu.riskIssueMonitoringVisits, requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "monitoring-audit",
|
||||
name: "MonitoringAuditPlaceholder",
|
||||
component: MonitoringAuditPlaceholder,
|
||||
meta: { title: TEXT.menu.monitoringAudit, requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "monitoring",
|
||||
name: "MonitoringPlaceholder",
|
||||
component: MonitoringPlaceholder,
|
||||
meta: { title: TEXT.menu.monitoring, requiresStudy: true },
|
||||
redirect: "/monitoring-audit",
|
||||
},
|
||||
{
|
||||
path: "audit",
|
||||
name: "AuditPlaceholder",
|
||||
component: AuditPlaceholder,
|
||||
meta: { title: TEXT.menu.audit, requiresStudy: true },
|
||||
redirect: "/monitoring-audit",
|
||||
},
|
||||
{
|
||||
path: "etmf",
|
||||
name: "EtmfPlaceholder",
|
||||
component: EtmfPlaceholder,
|
||||
meta: { title: TEXT.menu.etmf, requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "knowledge/medical-consult",
|
||||
@@ -431,6 +485,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: AdminProjects,
|
||||
meta: { title: TEXT.menu.projectManagement, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "projects/:projectId",
|
||||
name: "AdminProjectDetail",
|
||||
component: ProjectDetail,
|
||||
meta: { title: TEXT.modules.adminProjects.detailTitle, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "projects/:projectId/members",
|
||||
name: "AdminProjectMembers",
|
||||
@@ -453,9 +513,7 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
{
|
||||
path: "/projects/:projectId",
|
||||
name: "ProjectDetail",
|
||||
component: ProjectDetail,
|
||||
meta: { title: TEXT.modules.adminProjects.detailTitle, requiresAdmin: true },
|
||||
redirect: (to) => `/admin/projects/${to.params.projectId}`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -468,14 +526,18 @@ router.beforeEach(async (to, _from, next) => {
|
||||
const auth = useAuthStore();
|
||||
const studyStore = useStudyStore();
|
||||
const getToken = () => auth.token;
|
||||
let token = getToken();
|
||||
if (!auth.user && getToken()) {
|
||||
try {
|
||||
await auth.fetchMe();
|
||||
} catch {
|
||||
// 由统一的认证处理流程处理 401/403
|
||||
// 有 token 但无法获取用户,强制回登录,避免进入“无用户上下文”页面
|
||||
auth.logout();
|
||||
next({ path: "/login" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const token = getToken();
|
||||
token = getToken();
|
||||
const isAdmin = auth.user?.role === "ADMIN";
|
||||
if (token && !studyStore.currentStudy) {
|
||||
if (isAdmin) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ElMessage } from "element-plus";
|
||||
import router from "../router";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { useSessionStore } from "../store/session";
|
||||
@@ -7,16 +6,19 @@ import { extendToken, unlockSession } from "../api/authClient";
|
||||
import { parseJwtExp } from "./jwt";
|
||||
|
||||
export const IDLE_TIMEOUT_MINUTES = 30;
|
||||
export const AUTO_LOGOUT_TIMEOUT_HOURS = 2;
|
||||
export const EXTEND_EARLY_SECONDS = 120;
|
||||
export const EXTEND_MIN_INTERVAL_SECONDS = 60;
|
||||
export const UNLOCK_MAX_ATTEMPTS = 5;
|
||||
export const LOGOUT_REASON_TIMEOUT = "timeout";
|
||||
const LOGOUT_REASON_STORAGE_KEY = "ctms_logout_reason";
|
||||
|
||||
type BroadcastMessage =
|
||||
| { type: "ACTIVE"; at: number }
|
||||
| { type: "NETWORK"; at: number }
|
||||
| { type: "LOCK"; reason: string }
|
||||
| { type: "LOCK"; reason: string; email?: string; deadlineAt?: number }
|
||||
| { type: "TOKEN_UPDATED"; token: string }
|
||||
| { type: "LOGOUT" };
|
||||
| { type: "LOGOUT"; reason?: string };
|
||||
|
||||
let idleTimer: number | null = null;
|
||||
let extendPromise: Promise<{ token: string | null; authFailed: boolean }> | null = null;
|
||||
@@ -38,16 +40,19 @@ const handleBroadcast = (message: BroadcastMessage) => {
|
||||
const session = useSessionStore();
|
||||
const auth = useAuthStore();
|
||||
if (message.type === "ACTIVE") {
|
||||
if (session.locked) return;
|
||||
session.recordUserActivity(message.at);
|
||||
scheduleIdleCheck();
|
||||
}
|
||||
if (message.type === "NETWORK") {
|
||||
if (session.locked) return;
|
||||
session.recordNetworkActivity(message.at);
|
||||
scheduleIdleCheck();
|
||||
}
|
||||
if (message.type === "LOCK") {
|
||||
const reason = message.reason as "idle" | "extend_failed" | "token_invalid";
|
||||
session.lock(reason || "idle");
|
||||
session.lock(reason || "idle", message.email, message.deadlineAt);
|
||||
scheduleIdleCheck();
|
||||
}
|
||||
if (message.type === "TOKEN_UPDATED") {
|
||||
auth.setToken(message.token);
|
||||
@@ -55,7 +60,7 @@ const handleBroadcast = (message: BroadcastMessage) => {
|
||||
session.unlock();
|
||||
}
|
||||
if (message.type === "LOGOUT") {
|
||||
forceLogout();
|
||||
performLogout(message.reason);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -64,9 +69,22 @@ const scheduleIdleCheck = () => {
|
||||
window.clearTimeout(idleTimer);
|
||||
}
|
||||
const session = useSessionStore();
|
||||
const timeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
|
||||
const nextCheck =
|
||||
Math.min(session.lastUserActiveAt, session.lastNetworkActiveAt) + timeoutMs - Date.now();
|
||||
const now = Date.now();
|
||||
if (session.locked) {
|
||||
const remain = (session.lockAutoLogoutDeadlineAt || 0) - now;
|
||||
if (remain <= 0) {
|
||||
checkIdle();
|
||||
return;
|
||||
}
|
||||
idleTimer = window.setTimeout(checkIdle, remain);
|
||||
return;
|
||||
}
|
||||
const idleTimeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
|
||||
const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
|
||||
const baseActiveAt = Math.min(session.lastUserActiveAt, session.lastNetworkActiveAt);
|
||||
const idleRemain = baseActiveAt + idleTimeoutMs - now;
|
||||
const logoutRemain = baseActiveAt + autoLogoutMs - now;
|
||||
const nextCheck = Math.min(idleRemain, logoutRemain);
|
||||
if (nextCheck <= 0) {
|
||||
checkIdle();
|
||||
return;
|
||||
@@ -76,12 +94,28 @@ const scheduleIdleCheck = () => {
|
||||
|
||||
const checkIdle = () => {
|
||||
const session = useSessionStore();
|
||||
const timeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
|
||||
const now = Date.now();
|
||||
if (now - session.lastUserActiveAt > timeoutMs && now - session.lastNetworkActiveAt > timeoutMs) {
|
||||
lockSession("idle");
|
||||
if (session.locked) {
|
||||
const deadlineAt = session.lockAutoLogoutDeadlineAt || 0;
|
||||
if (deadlineAt > 0 && Date.now() >= deadlineAt) {
|
||||
forceLogout(LOGOUT_REASON_TIMEOUT);
|
||||
return;
|
||||
}
|
||||
scheduleIdleCheck();
|
||||
return;
|
||||
}
|
||||
const idleTimeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
|
||||
const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const idleElapsed = now - session.lastUserActiveAt > idleTimeoutMs && now - session.lastNetworkActiveAt > idleTimeoutMs;
|
||||
const autoLogoutElapsed =
|
||||
now - session.lastUserActiveAt > autoLogoutMs && now - session.lastNetworkActiveAt > autoLogoutMs;
|
||||
if (autoLogoutElapsed) {
|
||||
forceLogout(LOGOUT_REASON_TIMEOUT);
|
||||
return;
|
||||
}
|
||||
if (idleElapsed) {
|
||||
lockSession("idle");
|
||||
}
|
||||
scheduleIdleCheck();
|
||||
};
|
||||
|
||||
@@ -115,6 +149,7 @@ export const initSessionManager = () => {
|
||||
|
||||
export const markUserActive = (at: number = Date.now()) => {
|
||||
const session = useSessionStore();
|
||||
if (session.locked) return;
|
||||
session.recordUserActivity(at);
|
||||
broadcast({ type: "ACTIVE", at });
|
||||
scheduleIdleCheck();
|
||||
@@ -122,6 +157,7 @@ export const markUserActive = (at: number = Date.now()) => {
|
||||
|
||||
export const markNetworkActive = (at: number = Date.now()) => {
|
||||
const session = useSessionStore();
|
||||
if (session.locked) return;
|
||||
session.recordNetworkActivity(at);
|
||||
broadcast({ type: "NETWORK", at });
|
||||
scheduleIdleCheck();
|
||||
@@ -129,21 +165,53 @@ export const markNetworkActive = (at: number = Date.now()) => {
|
||||
|
||||
export const lockSession = (reason: "idle" | "extend_failed" | "token_invalid") => {
|
||||
const session = useSessionStore();
|
||||
const auth = useAuthStore();
|
||||
if (session.locked) return;
|
||||
session.lock(reason);
|
||||
broadcast({ type: "LOCK", reason });
|
||||
const email = auth.user?.email || "";
|
||||
const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
|
||||
const deadlineAt = Math.min(session.lastUserActiveAt, session.lastNetworkActiveAt) + autoLogoutMs;
|
||||
session.lock(reason, email, deadlineAt);
|
||||
broadcast({ type: "LOCK", reason, email, deadlineAt });
|
||||
scheduleIdleCheck();
|
||||
};
|
||||
|
||||
export const forceLogout = () => {
|
||||
const setLogoutReason = (reason?: string) => {
|
||||
try {
|
||||
if (!reason) {
|
||||
sessionStorage.removeItem(LOGOUT_REASON_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(LOGOUT_REASON_STORAGE_KEY, reason);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
export const consumeLogoutReason = () => {
|
||||
try {
|
||||
const reason = sessionStorage.getItem(LOGOUT_REASON_STORAGE_KEY);
|
||||
sessionStorage.removeItem(LOGOUT_REASON_STORAGE_KEY);
|
||||
return reason;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const performLogout = (reason?: string) => {
|
||||
setLogoutReason(reason);
|
||||
const auth = useAuthStore();
|
||||
const session = useSessionStore();
|
||||
auth.logout();
|
||||
session.unlock();
|
||||
clearToken();
|
||||
broadcast({ type: "LOGOUT" });
|
||||
router.replace("/login");
|
||||
};
|
||||
|
||||
export const forceLogout = (reason?: string) => {
|
||||
performLogout(reason);
|
||||
broadcast({ type: "LOGOUT", reason });
|
||||
};
|
||||
|
||||
const updateToken = (token: string) => {
|
||||
const auth = useAuthStore();
|
||||
auth.setToken(token);
|
||||
@@ -205,8 +273,16 @@ export const startTokenKeepAlive = () => {
|
||||
|
||||
export const unlockWithPassword = async (email: string, password: string) => {
|
||||
const session = useSessionStore();
|
||||
const auth = useAuthStore();
|
||||
const { data } = await unlockSession({ email, password });
|
||||
updateToken(data.accessToken);
|
||||
session.unlock();
|
||||
try {
|
||||
await auth.fetchMe();
|
||||
} catch {
|
||||
// token已更新但用户信息拉取失败时,避免界面进入无角色状态
|
||||
forceLogout();
|
||||
throw new Error("无法获取用户信息,请重新登录");
|
||||
}
|
||||
markUserActive();
|
||||
};
|
||||
|
||||
@@ -4,8 +4,10 @@ import { login as apiLogin, fetchMe } from "../api/auth";
|
||||
import { setToken, clearToken, getToken } from "../utils/auth";
|
||||
import type { UserInfo } from "../types/api";
|
||||
import { useStudyStore } from "./study";
|
||||
import { useSessionStore } from "./session";
|
||||
|
||||
export const useAuthStore = defineStore("auth", () => {
|
||||
const LAST_LOGIN_EMAIL_KEY = "ctms_last_login_email";
|
||||
const token = ref<string | null>(getToken());
|
||||
const user = ref<UserInfo | null>(null);
|
||||
const loading = ref(false);
|
||||
@@ -17,6 +19,9 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
const { data } = await apiLogin({ email, password });
|
||||
token.value = data.access_token;
|
||||
setToken(data.access_token);
|
||||
// 避免锁屏态下 fetchMe 被请求拦截
|
||||
useSessionStore().unlock();
|
||||
localStorage.setItem(LAST_LOGIN_EMAIL_KEY, email);
|
||||
await fetchMeAction();
|
||||
forceLogin.value = false;
|
||||
} finally {
|
||||
@@ -27,6 +32,9 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
const fetchMeAction = async () => {
|
||||
const { data } = await fetchMe();
|
||||
user.value = data;
|
||||
if (data?.email) {
|
||||
localStorage.setItem(LAST_LOGIN_EMAIL_KEY, data.email);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ export type LockReason = "idle" | "extend_failed" | "token_invalid";
|
||||
export const useSessionStore = defineStore("session", () => {
|
||||
const locked = ref(false);
|
||||
const lockReason = ref<LockReason | null>(null);
|
||||
const lockEmail = ref<string>("");
|
||||
const unlockAttempts = ref(0);
|
||||
const lockAutoLogoutDeadlineAt = ref(0);
|
||||
const lastUserActiveAt = ref(Date.now());
|
||||
const lastNetworkActiveAt = ref(Date.now());
|
||||
const lastExtendAt = ref(0);
|
||||
@@ -19,14 +21,18 @@ export const useSessionStore = defineStore("session", () => {
|
||||
lastNetworkActiveAt.value = ts;
|
||||
};
|
||||
|
||||
const lock = (reason: LockReason) => {
|
||||
const lock = (reason: LockReason, email?: string, autoLogoutDeadlineAt?: number) => {
|
||||
locked.value = true;
|
||||
lockReason.value = reason;
|
||||
lockEmail.value = (email || "").trim();
|
||||
lockAutoLogoutDeadlineAt.value = autoLogoutDeadlineAt || 0;
|
||||
};
|
||||
|
||||
const unlock = () => {
|
||||
locked.value = false;
|
||||
lockReason.value = null;
|
||||
lockEmail.value = "";
|
||||
lockAutoLogoutDeadlineAt.value = 0;
|
||||
unlockAttempts.value = 0;
|
||||
};
|
||||
|
||||
@@ -41,6 +47,8 @@ export const useSessionStore = defineStore("session", () => {
|
||||
return {
|
||||
locked,
|
||||
lockReason,
|
||||
lockEmail,
|
||||
lockAutoLogoutDeadlineAt,
|
||||
unlockAttempts,
|
||||
lastUserActiveAt,
|
||||
lastNetworkActiveAt,
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
:root {
|
||||
--unified-shell-bg: #ffffff;
|
||||
--unified-shell-border: #d8e2ef;
|
||||
--unified-shell-divider: #eaf0f8;
|
||||
--unified-shell-radius: 16px;
|
||||
--unified-shell-padding-x: 20px;
|
||||
--unified-shell-padding-y: 14px;
|
||||
--unified-title-color: #0f2345;
|
||||
--unified-muted-color: #6f84a8;
|
||||
}
|
||||
|
||||
.unified-shell {
|
||||
background: var(--unified-shell-bg);
|
||||
border: 0;
|
||||
border-radius: var(--unified-shell-radius);
|
||||
overflow: hidden;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.el-card.unified-shell {
|
||||
margin-bottom: 0;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.main-content-card.unified-shell {
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.el-card.unified-shell > .el-card__body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.unified-action-bar {
|
||||
padding: var(--unified-shell-padding-y) var(--unified-shell-padding-x);
|
||||
border-bottom: 1px solid var(--unified-shell-divider);
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.ctms-page > .ctms-page-header.unified-action-bar,
|
||||
.page > .page-header.unified-action-bar {
|
||||
border: 0;
|
||||
border-radius: var(--unified-shell-radius);
|
||||
background: #ffffff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.unified-section {
|
||||
padding: var(--unified-shell-padding-y) var(--unified-shell-padding-x);
|
||||
border-bottom: 1px solid var(--unified-shell-divider);
|
||||
}
|
||||
|
||||
.unified-section:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.unified-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.unified-shell .ctms-section-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.unified-action-bar .ctms-page-actions,
|
||||
.unified-action-bar .actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.unified-action-bar .ctms-page-title,
|
||||
.unified-action-bar .page-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--unified-title-color);
|
||||
}
|
||||
|
||||
.unified-action-bar .ctms-page-subtitle,
|
||||
.unified-action-bar .page-subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--unified-muted-color);
|
||||
}
|
||||
|
||||
.filter-container.unified-action-bar .filter-form,
|
||||
.filter-container.unified-action-bar .ctms-filter-row {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.unified-shell .el-table th.el-table__cell {
|
||||
background-color: #f8fbff;
|
||||
color: #35527d;
|
||||
font-weight: 600;
|
||||
height: 42px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.unified-shell .el-table td.el-table__cell {
|
||||
border-bottom-color: #edf2f8;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.unified-shell .el-button,
|
||||
.unified-action-bar .el-button {
|
||||
border-radius: 10px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.unified-shell .el-tabs__nav-wrap::after {
|
||||
background-color: #edf2f8;
|
||||
}
|
||||
|
||||
.unified-shell .el-tabs__item {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.unified-shell .el-form-item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.unified-shell .el-form-item__label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--unified-muted-color);
|
||||
}
|
||||
|
||||
.unified-shell .filter-form,
|
||||
.unified-shell .ctms-filter-row {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.unified-shell .ctms-table-card,
|
||||
.unified-shell .ctms-section-card,
|
||||
.unified-shell .detail-card,
|
||||
.unified-shell .section-card,
|
||||
.unified-shell .form-card {
|
||||
margin-bottom: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.unified-shell .ctms-table-card + .ctms-table-card,
|
||||
.unified-shell .ctms-section-card + .ctms-section-card,
|
||||
.unified-shell .detail-card + .section-card,
|
||||
.unified-shell .section-card + .section-card,
|
||||
.unified-shell .form-card + .form-card {
|
||||
border-top: 1px solid var(--unified-shell-divider);
|
||||
}
|
||||
|
||||
.unified-shell .ctms-section-title,
|
||||
.unified-shell .section-title,
|
||||
.unified-shell .header-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--unified-title-color);
|
||||
}
|
||||
|
||||
.unified-shell .text-secondary,
|
||||
.unified-shell .info-label {
|
||||
color: var(--unified-muted-color);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.unified-action-bar,
|
||||
.unified-section {
|
||||
padding-left: 14px;
|
||||
padding-right: 14px;
|
||||
}
|
||||
}
|
||||
@@ -61,8 +61,26 @@ export interface Study {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
project_full_name?: string | null;
|
||||
sponsor?: string | null;
|
||||
protocol_no?: string | null;
|
||||
lead_unit?: string | null;
|
||||
principal_investigator?: string | null;
|
||||
main_pm?: string | null;
|
||||
research_analysis?: string | null;
|
||||
research_product?: string | null;
|
||||
control_product?: string | null;
|
||||
indication?: string | null;
|
||||
research_population?: string | null;
|
||||
research_design?: string | null;
|
||||
plan_start_date?: string | null;
|
||||
plan_end_date?: string | null;
|
||||
planned_site_count?: number | null;
|
||||
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;
|
||||
@@ -92,6 +110,9 @@ export interface Site {
|
||||
pi_name?: string | null;
|
||||
contact?: string | null;
|
||||
enrollment_target?: number | null;
|
||||
enrollment_plan_start_date?: string | null;
|
||||
enrollment_plan_end_date?: string | null;
|
||||
enrollment_plan_note?: string | null;
|
||||
is_active: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
export interface ProjectMilestoneDraft {
|
||||
id: string;
|
||||
name: string;
|
||||
planDate: string;
|
||||
owner: string;
|
||||
remark: string;
|
||||
status: "未开始" | "进行中" | "已完成" | string;
|
||||
}
|
||||
|
||||
export interface EnrollmentPlanDraft {
|
||||
totalTarget: number;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
monthlyGoalNote: string;
|
||||
stageBreakdown: string;
|
||||
}
|
||||
|
||||
export interface SiteMilestoneDraft {
|
||||
id: string;
|
||||
milestone: string;
|
||||
planDate: string;
|
||||
owner: string;
|
||||
remark: string;
|
||||
status: "未开始" | "进行中" | "已完成" | string;
|
||||
}
|
||||
|
||||
export interface SiteEnrollmentPlanDraft {
|
||||
id: string;
|
||||
siteId: string;
|
||||
siteName: string;
|
||||
target: number;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
note: string;
|
||||
stageBreakdown: string;
|
||||
}
|
||||
|
||||
export interface MonitoringStrategyDraft {
|
||||
id: string;
|
||||
strategyType: string;
|
||||
detail: string;
|
||||
frequency: string;
|
||||
updatedAt: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface CenterConfirmDraft {
|
||||
id: string;
|
||||
siteId: string;
|
||||
siteName: string;
|
||||
confirmer: string;
|
||||
confirmStatus: "待确认" | "已确认" | "退回" | string;
|
||||
confirmDate: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface SetupConfigDraft {
|
||||
projectMilestones: ProjectMilestoneDraft[];
|
||||
enrollmentPlan: EnrollmentPlanDraft;
|
||||
siteMilestones: SiteMilestoneDraft[];
|
||||
siteEnrollmentPlans: SiteEnrollmentPlanDraft[];
|
||||
monitoringStrategies: MonitoringStrategyDraft[];
|
||||
centerConfirm: CenterConfirmDraft[];
|
||||
}
|
||||
|
||||
export interface StudySetupConfigResponse {
|
||||
id: string;
|
||||
study_id: string;
|
||||
version: number;
|
||||
data: SetupConfigDraft;
|
||||
publish_status: "DRAFT" | "PUBLISHED" | string;
|
||||
published_data?: SetupConfigDraft | null;
|
||||
published_by?: string | null;
|
||||
published_by_name?: string | null;
|
||||
published_at?: string | null;
|
||||
saved_by?: string | null;
|
||||
saved_by_name?: string | null;
|
||||
projection_status?: "success" | "partial_success" | "failed" | string | null;
|
||||
projection_summary?: {
|
||||
study_updated?: boolean;
|
||||
site_updated_count?: number;
|
||||
site_skipped_count?: number;
|
||||
warnings?: string[];
|
||||
skipped_items?: Array<{ site_id: string; reason: string }>;
|
||||
} | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface StudySetupConfigUpsertPayload {
|
||||
expected_version?: number | null;
|
||||
data: SetupConfigDraft;
|
||||
}
|
||||
|
||||
export interface StudySetupConfigPublishPayload {
|
||||
expected_version?: number | null;
|
||||
}
|
||||
|
||||
export interface StudySetupConfigRollbackPayload {
|
||||
expected_version?: number | null;
|
||||
target_version: number;
|
||||
}
|
||||
|
||||
export interface StudySetupConfigVersionItem {
|
||||
id: string;
|
||||
study_id: string;
|
||||
study_setup_config_id: string;
|
||||
version: number;
|
||||
display_version: number;
|
||||
source_version?: number | null;
|
||||
config: SetupConfigDraft;
|
||||
published_by?: string | null;
|
||||
published_by_name?: string | null;
|
||||
published_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { SetupConfigDraft } from "../types/setupConfig";
|
||||
|
||||
export type SetupDiffRow = {
|
||||
moduleLabel: string;
|
||||
path: string;
|
||||
changeType: "新增" | "删除" | "修改";
|
||||
localValue: string;
|
||||
serverValue: string;
|
||||
};
|
||||
|
||||
export type SetupModuleLabel = {
|
||||
key: keyof SetupConfigDraft;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const setupFieldLabelMap: Record<keyof SetupConfigDraft, Record<string, string>> = {
|
||||
projectMilestones: {
|
||||
id: "ID",
|
||||
name: "里程碑",
|
||||
planDate: "计划日期",
|
||||
owner: "负责人",
|
||||
remark: "备注",
|
||||
status: "状态",
|
||||
},
|
||||
enrollmentPlan: {
|
||||
totalTarget: "计划总入组例数",
|
||||
startDate: "计划开始日期",
|
||||
endDate: "计划结束日期",
|
||||
monthlyGoalNote: "月度目标说明",
|
||||
stageBreakdown: "分阶段计划",
|
||||
},
|
||||
siteMilestones: {
|
||||
id: "ID",
|
||||
milestone: "里程碑",
|
||||
planDate: "计划日期",
|
||||
owner: "负责人",
|
||||
remark: "备注",
|
||||
},
|
||||
siteEnrollmentPlans: {
|
||||
id: "ID",
|
||||
siteId: "中心ID",
|
||||
siteName: "中心名称",
|
||||
target: "计划例数",
|
||||
startDate: "启动日期",
|
||||
endDate: "完成日期",
|
||||
note: "备注",
|
||||
stageBreakdown: "分阶段计划",
|
||||
},
|
||||
monitoringStrategies: {
|
||||
id: "ID",
|
||||
strategyType: "监查类型",
|
||||
detail: "策略详情",
|
||||
frequency: "监查次数",
|
||||
updatedAt: "更新时间",
|
||||
enabled: "是否启用",
|
||||
},
|
||||
centerConfirm: {
|
||||
id: "ID",
|
||||
siteId: "中心ID",
|
||||
siteName: "中心名称",
|
||||
confirmer: "确认人",
|
||||
confirmStatus: "确认状态",
|
||||
confirmDate: "确认日期",
|
||||
note: "备注",
|
||||
},
|
||||
};
|
||||
|
||||
const formatReadablePath = (moduleKey: keyof SetupConfigDraft, path: string): string => {
|
||||
const normalized = path.startsWith(`${moduleKey}.`) ? path.slice(moduleKey.length + 1) : path;
|
||||
if (!normalized || normalized === moduleKey) return "根节点";
|
||||
const parts = normalized.split(".");
|
||||
const labels = parts.map((part, idx) => {
|
||||
const match = part.match(/^([^\[]+)\[(\d+)\]$/);
|
||||
if (match) {
|
||||
const fieldKey = match[1];
|
||||
const rowNo = Number(match[2]) + 1;
|
||||
const label = setupFieldLabelMap[moduleKey][fieldKey] || fieldKey;
|
||||
return idx === 0 ? `${label} 第${rowNo}行` : `第${rowNo}行`;
|
||||
}
|
||||
return setupFieldLabelMap[moduleKey][part] || part;
|
||||
});
|
||||
return labels.join(" / ");
|
||||
};
|
||||
|
||||
export const serializeDiffValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
if (typeof value === "string") return value || '""';
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return JSON.stringify(value);
|
||||
};
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
const collectDiffRows = (
|
||||
moduleLabel: string,
|
||||
localValue: unknown,
|
||||
serverValue: unknown,
|
||||
path: string,
|
||||
rows: SetupDiffRow[]
|
||||
) => {
|
||||
if (Array.isArray(localValue) || Array.isArray(serverValue)) {
|
||||
const localArr = Array.isArray(localValue) ? localValue : [];
|
||||
const serverArr = Array.isArray(serverValue) ? serverValue : [];
|
||||
const maxLen = Math.max(localArr.length, serverArr.length);
|
||||
for (let i = 0; i < maxLen; i += 1) {
|
||||
collectDiffRows(moduleLabel, localArr[i], serverArr[i], `${path}[${i}]`, rows);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlainObject(localValue) || isPlainObject(serverValue)) {
|
||||
const localObj = isPlainObject(localValue) ? localValue : {};
|
||||
const serverObj = isPlainObject(serverValue) ? serverValue : {};
|
||||
const keys = Array.from(new Set([...Object.keys(localObj), ...Object.keys(serverObj)]));
|
||||
keys.forEach((key) => {
|
||||
const childPath = path ? `${path}.${key}` : key;
|
||||
collectDiffRows(moduleLabel, localObj[key], serverObj[key], childPath, rows);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (localValue === serverValue) return;
|
||||
const hasLocal = localValue !== undefined;
|
||||
const hasServer = serverValue !== undefined;
|
||||
const changeType: SetupDiffRow["changeType"] = hasLocal && hasServer ? "修改" : hasLocal ? "新增" : "删除";
|
||||
rows.push({
|
||||
moduleLabel,
|
||||
path: path || "(root)",
|
||||
changeType,
|
||||
localValue: serializeDiffValue(localValue),
|
||||
serverValue: serializeDiffValue(serverValue),
|
||||
});
|
||||
};
|
||||
|
||||
export const buildSetupReadableDiffRows = (
|
||||
localDraft: SetupConfigDraft | null,
|
||||
serverDraft: SetupConfigDraft | null,
|
||||
moduleLabels: SetupModuleLabel[],
|
||||
limit = 300
|
||||
): SetupDiffRow[] => {
|
||||
if (!localDraft || !serverDraft) return [];
|
||||
const rows: SetupDiffRow[] = [];
|
||||
moduleLabels.forEach((item) => {
|
||||
collectDiffRows(item.label, localDraft[item.key], serverDraft[item.key], String(item.key), rows);
|
||||
});
|
||||
return rows.slice(0, limit).map((row) => {
|
||||
const moduleKey = moduleLabels.find((item) => item.label === row.moduleLabel)?.key;
|
||||
if (!moduleKey) return row;
|
||||
return {
|
||||
...row,
|
||||
path: formatReadablePath(moduleKey, row.path),
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
export type SetupValidationError = {
|
||||
field: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ParsedFieldPath = {
|
||||
section: string;
|
||||
index: number;
|
||||
field: string;
|
||||
};
|
||||
|
||||
export const parseFieldPath = (path: string): ParsedFieldPath | null => {
|
||||
const matched = path.match(/^([a-zA-Z0-9_]+)\[(\d+)\]\.([a-zA-Z0-9_]+)$/);
|
||||
if (!matched) return null;
|
||||
return {
|
||||
section: matched[1],
|
||||
index: Number(matched[2]),
|
||||
field: matched[3],
|
||||
};
|
||||
};
|
||||
|
||||
export const groupErrorsBySection = (errors: SetupValidationError[]): Record<string, SetupValidationError[]> => {
|
||||
const grouped: Record<string, SetupValidationError[]> = {};
|
||||
errors.forEach((error) => {
|
||||
const parsed = parseFieldPath(error.field);
|
||||
const section = parsed?.section || error.field.split(".")[0] || "global";
|
||||
if (!grouped[section]) {
|
||||
grouped[section] = [];
|
||||
}
|
||||
grouped[section].push(error);
|
||||
});
|
||||
return grouped;
|
||||
};
|
||||
+25
-21
@@ -9,8 +9,8 @@
|
||||
/>
|
||||
</el-col>
|
||||
<el-col :span="19">
|
||||
<el-card class="mb-12">
|
||||
<div class="filters">
|
||||
<div class="faq-main unified-shell">
|
||||
<div class="filters unified-action-bar">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
:placeholder="TEXT.common.placeholders.keyword"
|
||||
@@ -24,23 +24,23 @@
|
||||
<el-button type="primary" @click="openForm()">{{ TEXT.modules.knowledgeMedicalConsult.newItem }}</el-button>
|
||||
</PermissionAction>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<FaqList
|
||||
:items="faqs"
|
||||
:categories="categories"
|
||||
:loading="loading"
|
||||
:can-edit="canEdit"
|
||||
@refresh="loadFaqs"
|
||||
/>
|
||||
<el-pagination
|
||||
class="pagination"
|
||||
layout="prev, pager, next"
|
||||
:page-size="pageSize"
|
||||
:current-page="page"
|
||||
:total="total"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
<FaqList
|
||||
:items="faqs"
|
||||
:categories="categories"
|
||||
:loading="loading"
|
||||
:can-edit="canEdit"
|
||||
@refresh="loadFaqs"
|
||||
/>
|
||||
<el-pagination
|
||||
class="pagination"
|
||||
layout="prev, pager, next"
|
||||
:page-size="pageSize"
|
||||
:current-page="page"
|
||||
:total="total"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
@@ -146,10 +146,17 @@ onMounted(async () => {
|
||||
.page {
|
||||
padding: 16px;
|
||||
}
|
||||
.faq-main {
|
||||
background: #fff;
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
@@ -158,7 +165,4 @@ onMounted(async () => {
|
||||
margin-top: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card v-loading="loading">
|
||||
<div class="page unified-shell">
|
||||
<el-card class="unified-shell" v-loading="loading">
|
||||
<div class="content">
|
||||
<div class="question-header">
|
||||
<div class="question-label">{{ TEXT.modules.knowledgeMedicalConsult.questionDesc }}</div>
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="mt-12" v-loading="repliesLoading">
|
||||
<el-card class="mt-12 unified-shell" v-loading="repliesLoading">
|
||||
<template #header>
|
||||
<div class="reply-header">
|
||||
<span>{{ TEXT.modules.knowledgeMedicalConsult.replies }}</span>
|
||||
|
||||
@@ -118,6 +118,7 @@ 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";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
@@ -143,6 +144,10 @@ let vantaEffect: any = null;
|
||||
|
||||
// 初始化 Vanta.js 效果
|
||||
onMounted(() => {
|
||||
const logoutReason = consumeLogoutReason();
|
||||
if (logoutReason === LOGOUT_REASON_TIMEOUT) {
|
||||
ElMessage.warning("长时间未操作,已自动退出登录,请重新登录");
|
||||
}
|
||||
const cached = getCachedCredential();
|
||||
if (cached) {
|
||||
form.email = cached.email;
|
||||
@@ -228,8 +233,13 @@ const onSubmit = async () => {
|
||||
router.push("/");
|
||||
}
|
||||
} catch (error: any) {
|
||||
const status = error?.response?.status;
|
||||
const detail = error?.response?.data?.detail || error?.response?.data?.message;
|
||||
ElMessage.error(detail || TEXT.modules.auth.authFailed);
|
||||
if (!status || status >= 500) {
|
||||
ElMessage.error("服务暂时不可用,请稍后重试");
|
||||
} else {
|
||||
ElMessage.error(detail || TEXT.modules.auth.authFailed);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card>
|
||||
<el-card class="unified-shell">
|
||||
<h3 class="title">{{ TEXT.modules.profile.title }}</h3>
|
||||
<p class="subtitle">{{ TEXT.modules.profile.subtitle }}</p>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" class="form">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="study-home-container" v-if="study.currentStudy">
|
||||
<div class="study-home-container unified-shell" v-if="study.currentStudy">
|
||||
<!-- 概览头部 -->
|
||||
<div class="home-header">
|
||||
<div class="header-content">
|
||||
@@ -68,7 +68,7 @@
|
||||
/>
|
||||
|
||||
<!-- 通知 -->
|
||||
<el-card class="notifications-card">
|
||||
<el-card class="notifications-card unified-shell">
|
||||
<div class="notifications-header">
|
||||
<span class="notifications-title">{{ TEXT.modules.projectOverview.notificationsTitle }}</span>
|
||||
<span class="notifications-subtitle">{{ TEXT.modules.projectOverview.notificationsSubtitle }}</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<div class="filter-container unified-action-bar">
|
||||
<div class="filter-form">
|
||||
<el-tag type="info" effect="plain" class="filter-label-tag">{{ TEXT.common.fields.status }}</el-tag>
|
||||
<el-select v-model="status" style="width: 140px" @change="loadUsers" class="filter-select-comp">
|
||||
@@ -13,45 +13,47 @@
|
||||
<div class="filter-spacer"></div>
|
||||
</div>
|
||||
</div>
|
||||
<el-table :data="users" v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="email" :label="TEXT.common.fields.email" min-width="200" />
|
||||
<el-table-column prop="full_name" :label="TEXT.common.fields.name" min-width="140" />
|
||||
<el-table-column prop="role" :label="TEXT.common.labels.role" width="120">
|
||||
<template #default="scope">{{ displayEnum(TEXT.enums.userRole, scope.row.role) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="department" :label="TEXT.common.fields.department" min-width="160" />
|
||||
<el-table-column prop="created_at" :label="TEXT.modules.adminUserApproval.createdAt" width="200">
|
||||
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusType(scope.row.status)">{{ displayEnum(TEXT.enums.userStatus, scope.row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="220" align="center">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
v-if="scope.row.status === 'PENDING'"
|
||||
type="success"
|
||||
link
|
||||
size="small"
|
||||
@click="onApprove(scope.row.id)"
|
||||
>
|
||||
{{ TEXT.modules.adminUserApproval.approve }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="scope.row.status === 'PENDING'"
|
||||
type="danger"
|
||||
link
|
||||
size="small"
|
||||
@click="onReject(scope.row.id)"
|
||||
>
|
||||
{{ TEXT.modules.adminUserApproval.reject }}
|
||||
</el-button>
|
||||
<span v-else>{{ TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="unified-section approval-table-section">
|
||||
<el-table :data="users" v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="email" :label="TEXT.common.fields.email" min-width="200" />
|
||||
<el-table-column prop="full_name" :label="TEXT.common.fields.name" min-width="140" />
|
||||
<el-table-column prop="role" :label="TEXT.common.labels.role" width="120">
|
||||
<template #default="scope">{{ displayEnum(TEXT.enums.userRole, scope.row.role) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="department" :label="TEXT.common.fields.department" min-width="160" />
|
||||
<el-table-column prop="created_at" :label="TEXT.modules.adminUserApproval.createdAt" width="200">
|
||||
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusType(scope.row.status)">{{ displayEnum(TEXT.enums.userStatus, scope.row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="220" align="center">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
v-if="scope.row.status === 'PENDING'"
|
||||
type="success"
|
||||
link
|
||||
size="small"
|
||||
@click="onApprove(scope.row.id)"
|
||||
>
|
||||
{{ TEXT.modules.adminUserApproval.approve }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="scope.row.status === 'PENDING'"
|
||||
type="danger"
|
||||
link
|
||||
size="small"
|
||||
@click="onReject(scope.row.id)"
|
||||
>
|
||||
{{ TEXT.modules.adminUserApproval.reject }}
|
||||
</el-button>
|
||||
<span v-else>{{ TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -124,14 +126,6 @@ onMounted(() => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
@@ -150,4 +144,9 @@ onMounted(() => {
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.approval-table-section {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<div class="filter-container unified-action-bar">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-select v-model="filters.entityType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEntityType" @change="loadLogs" class="filter-select-comp">
|
||||
<el-option v-for="opt in entityTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-select v-model="filters.eventType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEvent" @change="loadLogs" class="filter-select-comp">
|
||||
<el-option v-for="opt in eventTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
@@ -43,53 +48,55 @@
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-table :data="logs" v-loading="loading" style="width: 100%" stripe>
|
||||
<el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="180" align="center">
|
||||
<template #default="scope">
|
||||
<span class="text-gray-500">{{ displayDateTime(scope.row.timestamp) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="140" />
|
||||
<el-table-column prop="actorRoleLabel" :label="TEXT.common.labels.role" width="120" />
|
||||
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" width="160">
|
||||
<template #default="scope">
|
||||
<el-tag effect="plain">{{ scope.row.eventLabel }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actionText" :label="TEXT.modules.adminAuditLogs.columns.action" min-width="180" />
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" min-width="180">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.targetTypeLabel">
|
||||
<strong>{{ scope.row.targetTypeLabel }}</strong>
|
||||
<span class="text-gray-500" v-if="scope.row.targetName">({{ scope.row.targetName }})</span>
|
||||
</span>
|
||||
<span v-else>{{ TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" min-width="260">
|
||||
<template #default="scope">
|
||||
<div v-if="scope.row.diffText?.length" class="diff-container">
|
||||
<div v-for="(line, idx) in scope.row.diffText" :key="idx" class="diff-line">{{ line }}</div>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">{{ TEXT.audit.emptyValue }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.result === 'SUCCESS' ? 'success' : 'danger'" effect="dark" size="small">
|
||||
{{ scope.row.resultLabel }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
class="pagination"
|
||||
layout="prev, pager, next"
|
||||
:page-size="pageSize"
|
||||
:current-page="page"
|
||||
:total="total"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
<div class="unified-section table-section">
|
||||
<el-table :data="logs" v-loading="loading" style="width: 100%" stripe>
|
||||
<el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="180" align="center">
|
||||
<template #default="scope">
|
||||
<span class="text-gray-500">{{ displayDateTime(scope.row.timestamp) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="140" />
|
||||
<el-table-column prop="actorRoleLabel" :label="TEXT.common.labels.role" width="120" />
|
||||
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" width="160">
|
||||
<template #default="scope">
|
||||
<el-tag effect="plain">{{ scope.row.eventLabel }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actionText" :label="TEXT.modules.adminAuditLogs.columns.action" min-width="180" />
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" min-width="180">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.targetTypeLabel">
|
||||
<strong>{{ scope.row.targetTypeLabel }}</strong>
|
||||
<span class="text-gray-500" v-if="scope.row.targetName">({{ scope.row.targetName }})</span>
|
||||
</span>
|
||||
<span v-else>{{ TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" min-width="260">
|
||||
<template #default="scope">
|
||||
<div v-if="scope.row.diffText?.length" class="diff-container">
|
||||
<div v-for="(line, idx) in scope.row.diffText" :key="idx" class="diff-line">{{ line }}</div>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">{{ TEXT.audit.emptyValue }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.result === 'SUCCESS' ? 'success' : 'danger'" effect="dark" size="small">
|
||||
{{ scope.row.resultLabel }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
class="pagination"
|
||||
layout="prev, pager, next"
|
||||
:page-size="pageSize"
|
||||
:current-page="page"
|
||||
:total="total"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -141,6 +148,7 @@ const pageSize = 20;
|
||||
const total = ref(0);
|
||||
|
||||
const filters = ref({
|
||||
entityType: "",
|
||||
eventType: "",
|
||||
operatorId: "",
|
||||
result: "",
|
||||
@@ -151,6 +159,15 @@ const eventTypeOptions = Object.entries(auditDict).map(([value, cfg]) => ({
|
||||
value,
|
||||
label: cfg.label,
|
||||
}));
|
||||
const entityTypeOptions = [
|
||||
{ value: "study_setup_config", label: "立项配置" },
|
||||
{ value: "study", label: "项目" },
|
||||
{ value: "subject", label: "参与者" },
|
||||
{ value: "site", label: "中心" },
|
||||
{ value: "finance", label: "费用" },
|
||||
{ value: "drug_shipment", label: "药品流向" },
|
||||
{ value: "audit_log", label: "审计日志" },
|
||||
];
|
||||
const userOptions = computed(() => users.value.map((u: any) => ({ label: u.username, value: u.id })));
|
||||
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
||||
const canProjectExport = computed(() => !!study.currentStudy && auth.user?.role === "ADMIN");
|
||||
@@ -188,6 +205,7 @@ const loadLogs = async () => {
|
||||
const params: Record<string, any> = {
|
||||
skip: (page.value - 1) * pageSize,
|
||||
limit: pageSize,
|
||||
entity_type: filters.value.entityType || undefined,
|
||||
action: filters.value.eventType || undefined,
|
||||
operator_id: filters.value.operatorId || undefined,
|
||||
};
|
||||
@@ -258,6 +276,7 @@ const fetchAllForExport = async () => {
|
||||
const params: Record<string, any> = {
|
||||
skip: 0,
|
||||
limit: 2000,
|
||||
entity_type: filters.value.entityType || undefined,
|
||||
action: filters.value.eventType || undefined,
|
||||
operator_id: filters.value.operatorId || undefined,
|
||||
};
|
||||
@@ -327,12 +346,9 @@ onMounted(async () => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
.table-section {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,19 +20,6 @@
|
||||
<el-option :label="TEXT.enums.projectStatus.CLOSED" value="CLOSED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">{{ TEXT.modules.adminProjects.visitTemplate }}</el-divider>
|
||||
<el-form-item :label="TEXT.common.fields.visitTotal">
|
||||
<el-input-number v-model="form.visit_total" :min="1" :max="50" :step="1" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.visitIntervalDays">
|
||||
<el-input-number v-model="form.visit_interval_days" :min="1" :max="365" :step="1" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.visitWindowStartOffset">
|
||||
<el-input-number v-model="form.visit_window_start_offset" :min="-365" :max="365" :step="1" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.visitWindowEndOffset">
|
||||
<el-input-number v-model="form.visit_window_end_offset" :min="-365" :max="365" :step="1" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visibleProxy = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
@@ -71,14 +58,11 @@ const form = reactive({
|
||||
protocol_no: "",
|
||||
phase: "",
|
||||
status: "DRAFT",
|
||||
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,
|
||||
});
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
name: [{ required: true, message: requiredMessage(TEXT.common.fields.projectName), trigger: "blur" }],
|
||||
code: [{ required: true, message: requiredMessage(TEXT.common.fields.projectCode), trigger: "blur" }],
|
||||
status: [{ required: true, message: requiredMessage(TEXT.common.fields.status), trigger: "change" }],
|
||||
});
|
||||
|
||||
@@ -88,10 +72,6 @@ const resetForm = () => {
|
||||
form.protocol_no = "";
|
||||
form.phase = "";
|
||||
form.status = "DRAFT";
|
||||
form.visit_interval_days = null;
|
||||
form.visit_total = null;
|
||||
form.visit_window_start_offset = null;
|
||||
form.visit_window_end_offset = null;
|
||||
};
|
||||
|
||||
watch(
|
||||
@@ -105,10 +85,6 @@ watch(
|
||||
form.protocol_no = props.project.protocol_no || "";
|
||||
form.phase = props.project.phase || "";
|
||||
form.status = props.project.status || "DRAFT";
|
||||
form.visit_interval_days = props.project.visit_interval_days ?? null;
|
||||
form.visit_total = props.project.visit_total ?? null;
|
||||
form.visit_window_start_offset = props.project.visit_window_start_offset ?? null;
|
||||
form.visit_window_end_offset = props.project.visit_window_end_offset ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,27 +98,19 @@ const onSubmit = async () => {
|
||||
if (props.project) {
|
||||
await updateStudy(props.project.id, {
|
||||
name: form.name,
|
||||
code: form.code,
|
||||
code: form.code.trim(),
|
||||
protocol_no: form.protocol_no,
|
||||
phase: form.phase,
|
||||
status: form.status,
|
||||
visit_interval_days: form.visit_interval_days,
|
||||
visit_total: form.visit_total,
|
||||
visit_window_start_offset: form.visit_window_start_offset,
|
||||
visit_window_end_offset: form.visit_window_end_offset,
|
||||
});
|
||||
ElMessage.success(TEXT.modules.adminProjects.updateSuccess);
|
||||
} else {
|
||||
await createStudy({
|
||||
name: form.name,
|
||||
code: form.code,
|
||||
code: form.code.trim(),
|
||||
protocol_no: form.protocol_no,
|
||||
phase: form.phase,
|
||||
status: form.status,
|
||||
visit_interval_days: form.visit_interval_days,
|
||||
visit_total: form.visit_total,
|
||||
visit_window_start_offset: form.visit_window_start_offset,
|
||||
visit_window_end_offset: form.visit_window_end_offset,
|
||||
});
|
||||
ElMessage.success(TEXT.modules.adminProjects.createSuccess);
|
||||
}
|
||||
|
||||
@@ -1,67 +1,67 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<div class="filter-form">
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="openAdd" class="header-action-btn">
|
||||
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<div class="unified-action-bar actions-only-bar">
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="openAdd">
|
||||
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="unified-section member-table-section">
|
||||
<el-table :data="memberRows" v-loading="loading" stripe>
|
||||
<el-table-column prop="full_name" :label="TEXT.modules.adminProjectMembers.username" min-width="160" />
|
||||
<el-table-column prop="role_in_study" :label="TEXT.modules.adminProjectMembers.projectRole" min-width="140">
|
||||
<template #default="scope">
|
||||
<el-tag>{{ displayEnum(TEXT.enums.userRole, scope.row.role_in_study) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.status" width="100">
|
||||
<template #default="scope">
|
||||
<el-tooltip
|
||||
v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
|
||||
:content="TEXT.modules.adminProjectMembers.disabledGlobalHint"
|
||||
placement="top"
|
||||
>
|
||||
<el-tag type="danger">{{ TEXT.modules.adminProjectMembers.disabledGlobal }}</el-tag>
|
||||
</el-tooltip>
|
||||
<el-tag v-else :type="scope.row.is_active ? 'success' : 'danger'">
|
||||
{{ scope.row.is_active ? TEXT.common.actions.enable : TEXT.modules.adminProjectMembers.disabled }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="added_at" :label="TEXT.modules.adminProjectMembers.addedAt" min-width="180">
|
||||
<template #default="scope">{{ displayDateTime(scope.row.added_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="300" align="center" class-name="action-column">
|
||||
<template #default="scope">
|
||||
<el-select
|
||||
v-model="scope.row.role_in_study"
|
||||
size="small"
|
||||
style="width: 120px"
|
||||
@change="(val: string) => updateRole(scope.row.id, val)"
|
||||
:disabled="!scope.row.is_active || scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
|
||||
>
|
||||
<el-option :label="TEXT.enums.userRole.PM" value="PM" />
|
||||
<el-option :label="TEXT.enums.userRole.CRA" value="CRA" />
|
||||
<el-option :label="TEXT.enums.userRole.PV" value="PV" />
|
||||
<el-option :label="TEXT.enums.userRole.IMP" value="IMP" />
|
||||
<el-option :label="TEXT.enums.userRole.ADMIN" value="ADMIN" />
|
||||
</el-select>
|
||||
<span v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'" class="hint">{{ TEXT.modules.adminProjectMembers.disabledGlobalDesc }}</span>
|
||||
<el-button link type="danger" size="small" @click="onDelete(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
||||
<el-button
|
||||
link
|
||||
:type="scope.row.is_active ? 'danger' : 'primary'"
|
||||
size="small"
|
||||
:disabled="scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
|
||||
@click="toggleActive(scope.row)"
|
||||
>
|
||||
{{ scope.row.is_active ? TEXT.common.actions.disable : TEXT.common.actions.enable }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<el-table :data="memberRows" v-loading="loading" stripe>
|
||||
<el-table-column prop="full_name" :label="TEXT.modules.adminProjectMembers.username" min-width="160" />
|
||||
<el-table-column prop="role_in_study" :label="TEXT.modules.adminProjectMembers.projectRole" min-width="140">
|
||||
<template #default="scope">
|
||||
<el-tag>{{ displayEnum(TEXT.enums.userRole, scope.row.role_in_study) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.status" width="100">
|
||||
<template #default="scope">
|
||||
<el-tooltip
|
||||
v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
|
||||
:content="TEXT.modules.adminProjectMembers.disabledGlobalHint"
|
||||
placement="top"
|
||||
>
|
||||
<el-tag type="danger">{{ TEXT.modules.adminProjectMembers.disabledGlobal }}</el-tag>
|
||||
</el-tooltip>
|
||||
<el-tag v-else :type="scope.row.is_active ? 'success' : 'danger'">
|
||||
{{ scope.row.is_active ? TEXT.common.actions.enable : TEXT.modules.adminProjectMembers.disabled }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="added_at" :label="TEXT.modules.adminProjectMembers.addedAt" min-width="180">
|
||||
<template #default="scope">{{ displayDateTime(scope.row.added_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="300" align="center" class-name="action-column">
|
||||
<template #default="scope">
|
||||
<el-select
|
||||
v-model="scope.row.role_in_study"
|
||||
size="small"
|
||||
style="width: 120px"
|
||||
@change="(val: string) => updateRole(scope.row.id, val)"
|
||||
:disabled="!scope.row.is_active || scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
|
||||
>
|
||||
<el-option :label="TEXT.enums.userRole.PM" value="PM" />
|
||||
<el-option :label="TEXT.enums.userRole.CRA" value="CRA" />
|
||||
<el-option :label="TEXT.enums.userRole.PV" value="PV" />
|
||||
<el-option :label="TEXT.enums.userRole.IMP" value="IMP" />
|
||||
<el-option :label="TEXT.enums.userRole.ADMIN" value="ADMIN" />
|
||||
</el-select>
|
||||
<span v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'" class="hint">{{ TEXT.modules.adminProjectMembers.disabledGlobalDesc }}</span>
|
||||
<el-button link type="danger" size="small" @click="onDelete(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
||||
<el-button
|
||||
link
|
||||
:type="scope.row.is_active ? 'danger' : 'primary'"
|
||||
size="small"
|
||||
:disabled="scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
|
||||
@click="toggleActive(scope.row)"
|
||||
>
|
||||
{{ scope.row.is_active ? TEXT.common.actions.disable : TEXT.common.actions.enable }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog :title="TEXT.modules.adminProjectMembers.newTitle" width="520px" v-model="addVisible" :close-on-click-modal="false">
|
||||
@@ -399,29 +399,15 @@ onMounted(async () => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
.actions-only-bar {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
.member-table-section {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
|
||||
@@ -1,62 +1,62 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<div class="filter-form">
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="openCreate" class="header-action-btn">
|
||||
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjects.projectLabel }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<div class="unified-action-bar actions-only-bar">
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="openCreate">
|
||||
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjects.projectLabel }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="unified-section">
|
||||
<el-table :data="projects" v-loading="loading" stripe>
|
||||
<el-table-column prop="name" :label="TEXT.common.fields.projectName" min-width="300">
|
||||
<template #default="scope">
|
||||
<el-link type="primary" @click="goDetail(scope.row)" class="font-medium">{{ scope.row.name }}</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="code" :label="TEXT.common.fields.projectCode" min-width="160">
|
||||
<template #default="scope">
|
||||
<span class="text-gray-500">{{ scope.row.code || "-" }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status" min-width="120" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusTag(scope.row.status)" effect="plain" round>{{ statusLabel(scope.row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="is_locked" label="锁定状态" min-width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.is_locked" type="warning" effect="plain" round>已锁定</el-tag>
|
||||
<el-tag v-else type="success" effect="plain" round>未锁定</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" min-width="280" align="center">
|
||||
<template #default="scope">
|
||||
<el-tooltip :content="TEXT.modules.adminProjects.members" placement="top">
|
||||
<el-button link type="primary" :icon="User" class="action-btn" @click="goMembers(scope.row)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="TEXT.modules.adminProjects.sites" placement="top">
|
||||
<el-button link type="primary" :icon="OfficeBuilding" class="action-btn" @click="goSites(scope.row)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="TEXT.modules.adminProjects.enter" placement="top">
|
||||
<el-button link type="success" :icon="ArrowRight" class="action-btn enter-btn" @click="enterStudy(scope.row)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip v-if="!scope.row.is_locked" content="锁定项目" placement="top">
|
||||
<el-button
|
||||
link
|
||||
type="info"
|
||||
:icon="Lock"
|
||||
class="action-btn"
|
||||
@click="handleLockToggle(scope.row)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="TEXT.common.actions.delete" placement="top">
|
||||
<el-button link type="danger" :icon="Delete" class="action-btn" @click="handleDelete(scope.row)" />
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<el-table :data="projects" v-loading="loading" stripe>
|
||||
<el-table-column prop="name" :label="TEXT.common.fields.projectName" min-width="300">
|
||||
<template #default="scope">
|
||||
<el-link type="primary" @click="goDetail(scope.row)" class="font-medium">{{ scope.row.name }}</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="code" :label="TEXT.common.fields.projectCode" min-width="160">
|
||||
<template #default="scope">
|
||||
<span class="text-gray-500">{{ scope.row.code || "-" }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status" min-width="120" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusTag(scope.row.status)" effect="plain" round>{{ statusLabel(scope.row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="is_locked" label="锁定状态" min-width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.is_locked" type="warning" effect="plain" round>已锁定</el-tag>
|
||||
<el-tag v-else type="success" effect="plain" round>未锁定</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" min-width="280" align="center">
|
||||
<template #default="scope">
|
||||
<el-tooltip :content="TEXT.modules.adminProjects.members" placement="top">
|
||||
<el-button link type="primary" :icon="User" class="action-btn" @click="goMembers(scope.row)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="TEXT.modules.adminProjects.sites" placement="top">
|
||||
<el-button link type="primary" :icon="OfficeBuilding" class="action-btn" @click="goSites(scope.row)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="TEXT.modules.adminProjects.enter" placement="top">
|
||||
<el-button link type="success" :icon="ArrowRight" class="action-btn enter-btn" @click="enterStudy(scope.row)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip v-if="!scope.row.is_locked" content="锁定项目" placement="top">
|
||||
<el-button
|
||||
link
|
||||
type="info"
|
||||
:icon="Lock"
|
||||
class="action-btn"
|
||||
@click="handleLockToggle(scope.row)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="TEXT.common.actions.delete" placement="top">
|
||||
<el-button link type="danger" :icon="Delete" class="action-btn" @click="handleDelete(scope.row)" />
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<ProjectForm v-model:visible="formVisible" :project="editingProject" @saved="loadProjects" />
|
||||
</div>
|
||||
@@ -197,7 +197,7 @@ const enterStudy = (row: Study) => {
|
||||
};
|
||||
|
||||
const goDetail = (row: Study) => {
|
||||
router.push(`/projects/${row.id}`);
|
||||
router.push(`/admin/projects/${row.id}`);
|
||||
};
|
||||
|
||||
const statusLabel = (status: string) =>
|
||||
@@ -221,52 +221,19 @@ onMounted(() => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
.actions-only-bar {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
.unified-section {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 表格样式美化 */
|
||||
:deep(.el-table) {
|
||||
--el-table-header-bg-color: #f9fafb;
|
||||
--el-table-header-text-color: #374151;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.el-table th.el-table__cell) {
|
||||
background-color: #f9fafb;
|
||||
font-weight: 600;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
:deep(.el-table .el-table__cell) {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* 操作按钮美化 */
|
||||
.action-btn {
|
||||
font-size: 18px; /* 图标变更大 */
|
||||
font-size: 18px;
|
||||
padding: 4px;
|
||||
margin: 0 2px;
|
||||
transition: all 0.3s;
|
||||
|
||||
@@ -28,9 +28,6 @@
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="入组目标" prop="enrollment_target">
|
||||
<el-input-number v-model="form.enrollment_target" :min="0" :step="1" :placeholder="'请输入入组目标人数'" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visibleProxy = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
@@ -77,7 +74,6 @@ const form = reactive({
|
||||
phone: "",
|
||||
contact: "",
|
||||
craSelections: [] as string[],
|
||||
enrollment_target: 0,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
@@ -103,7 +99,6 @@ const resetForm = () => {
|
||||
form.phone = "";
|
||||
form.contact = "";
|
||||
form.craSelections = [];
|
||||
form.enrollment_target = 0;
|
||||
form.is_active = true;
|
||||
};
|
||||
|
||||
@@ -127,7 +122,6 @@ watch(
|
||||
optionByLabel[opt.label] = opt.value;
|
||||
});
|
||||
form.craSelections = rawSelections.map((s) => optionByLabel[s] || s);
|
||||
form.enrollment_target = (props.site as any)?.enrollment_target || 0;
|
||||
form.is_active = props.site.is_active;
|
||||
}
|
||||
}
|
||||
@@ -161,7 +155,6 @@ const onSubmit = async () => {
|
||||
city: form.city,
|
||||
pi_name: form.pi_name,
|
||||
contact,
|
||||
enrollment_target: form.enrollment_target,
|
||||
is_active: form.is_active,
|
||||
});
|
||||
ElMessage.success(TEXT.modules.adminSites.updateSuccess);
|
||||
@@ -177,7 +170,6 @@ const onSubmit = async () => {
|
||||
city: form.city,
|
||||
pi_name: form.pi_name,
|
||||
contact,
|
||||
enrollment_target: form.enrollment_target,
|
||||
});
|
||||
ElMessage.success(TEXT.modules.adminSites.createSuccess);
|
||||
logAudit("SITE_STATUS_CHANGED", {
|
||||
@@ -187,6 +179,7 @@ const onSubmit = async () => {
|
||||
severity: "normal",
|
||||
});
|
||||
}
|
||||
|
||||
emit("saved");
|
||||
visibleProxy.value = false;
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -1,57 +1,66 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<div class="filter-form">
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="openCreate" class="header-action-btn">
|
||||
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminSites.siteLabel }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<div class="unified-action-bar actions-only-bar">
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="openCreate">
|
||||
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminSites.siteLabel }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="unified-section site-table-section">
|
||||
<el-table :data="displaySites" v-loading="loading" stripe :row-class-name="siteRowClass">
|
||||
<el-table-column prop="name" :label="TEXT.common.fields.siteName" width="220">
|
||||
<template #default="scope">
|
||||
<div class="site-name-cell">
|
||||
<span>{{ scope.row.name }}</span>
|
||||
<el-tag v-if="isLeadUnitSite(scope.row)" type="warning" effect="light" size="small">
|
||||
{{ TEXT.modules.adminSites.leadUnitTag }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="city" :label="TEXT.common.fields.city" width="100" />
|
||||
<el-table-column prop="pi_name" :label="TEXT.modules.adminSites.piLabel" width="120" />
|
||||
<el-table-column :label="TEXT.common.fields.phone" width="140">
|
||||
<template #default="scope">
|
||||
{{ phoneText(scope.row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="contact" :label="TEXT.common.fields.contact" width="160">
|
||||
<template #default="scope">
|
||||
{{ contactLabel(scope.row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.status" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag type="success" v-if="scope.row.is_active">已解锁</el-tag>
|
||||
<el-tag type="danger" v-else>已锁定</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="200" align="center" class-name="action-column">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" size="small" @click="openEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button
|
||||
link
|
||||
:type="scope.row.is_active ? 'danger' : 'primary'"
|
||||
size="small"
|
||||
@click="toggleSite(scope.row)"
|
||||
>
|
||||
{{ scope.row.is_active ? "锁定" : "解锁" }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isAdmin"
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="confirmDelete(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<el-table :data="sites" v-loading="loading" stripe :row-class-name="siteRowClass">
|
||||
<el-table-column prop="name" :label="TEXT.common.fields.siteName" width="180" />
|
||||
<el-table-column prop="city" :label="TEXT.common.fields.city" width="100" />
|
||||
<el-table-column prop="pi_name" :label="TEXT.modules.adminSites.piLabel" width="120" />
|
||||
<el-table-column :label="TEXT.common.fields.phone" width="140">
|
||||
<template #default="scope">
|
||||
{{ phoneText(scope.row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="contact" :label="TEXT.common.fields.contact" width="160">
|
||||
<template #default="scope">
|
||||
{{ contactLabel(scope.row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.status" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag type="success" v-if="scope.row.is_active">已解锁</el-tag>
|
||||
<el-tag type="danger" v-else>已锁定</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="200" align="center" class-name="action-column">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" size="small" @click="openEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button
|
||||
link
|
||||
:type="scope.row.is_active ? 'danger' : 'primary'"
|
||||
size="small"
|
||||
@click="toggleSite(scope.row)"
|
||||
>
|
||||
{{ scope.row.is_active ? "锁定" : "解锁" }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isAdmin"
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="confirmDelete(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<SiteForm
|
||||
v-if="projectId"
|
||||
@@ -158,7 +167,32 @@ const contactLabel = (row: any) => {
|
||||
|
||||
const phoneText = (row: any) => row?.phone || row?.contact_phone || TEXT.common.fallback;
|
||||
|
||||
const siteRowClass = ({ row }: { row: Site }) => (row.is_active ? "" : "row-inactive");
|
||||
const normalizeSiteName = (value?: string | null) => String(value || "").trim().replace(/\s+/g, "").toLowerCase();
|
||||
const isLeadUnitSite = (row: Site): boolean => {
|
||||
const leadUnitName = normalizeSiteName(project.value?.lead_unit);
|
||||
const siteName = normalizeSiteName(row.name);
|
||||
if (!leadUnitName || !siteName) return false;
|
||||
return siteName === leadUnitName;
|
||||
};
|
||||
|
||||
const displaySites = computed(() => {
|
||||
return sites.value
|
||||
.map((item, index) => ({ item, index }))
|
||||
.sort((a, b) => {
|
||||
const aLead = isLeadUnitSite(a.item) ? 1 : 0;
|
||||
const bLead = isLeadUnitSite(b.item) ? 1 : 0;
|
||||
if (aLead !== bLead) return bLead - aLead;
|
||||
return a.index - b.index;
|
||||
})
|
||||
.map((entry) => entry.item);
|
||||
});
|
||||
|
||||
const siteRowClass = ({ row }: { row: Site }) => {
|
||||
const classes: string[] = [];
|
||||
if (!row.is_active) classes.push("row-inactive");
|
||||
if (isLeadUnitSite(row)) classes.push("row-lead-unit");
|
||||
return classes.join(" ");
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
loadUsers();
|
||||
@@ -271,29 +305,21 @@ onMounted(async () => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
.actions-only-bar {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
.site-table-section {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
.site-name-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -308,6 +334,14 @@ onMounted(async () => {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.row-lead-unit td {
|
||||
background-color: #f3f8ff !important;
|
||||
}
|
||||
|
||||
.row-lead-unit.row-inactive td {
|
||||
background-color: #edf2fb !important;
|
||||
}
|
||||
|
||||
.el-table .action-column .cell {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<div class="filter-container unified-action-bar">
|
||||
<div class="filter-form">
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-input
|
||||
@@ -23,62 +23,64 @@
|
||||
<el-button @click="loadUsers">{{ TEXT.common.actions.search }}</el-button>
|
||||
</el-form-item>
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="openCreate" class="header-action-btn">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }}</el-button>
|
||||
<el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="users" stripe v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="email" :label="TEXT.common.fields.email" min-width="200" />
|
||||
<el-table-column prop="full_name" :label="TEXT.common.fields.name" min-width="140" />
|
||||
<el-table-column prop="department" :label="TEXT.common.fields.department" min-width="140" />
|
||||
<el-table-column :label="TEXT.common.fields.status" width="140">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusType(scope.row.status)">
|
||||
{{ statusLabel(scope.row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" width="200">
|
||||
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" min-width="200" align="center">
|
||||
<template #default="scope">
|
||||
<el-tooltip :content="TEXT.common.actions.edit" placement="top">
|
||||
<el-button link type="primary" :icon="Edit" @click="openEdit(scope.row)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="scope.row.status === 'ACTIVE' ? TEXT.common.actions.disable : TEXT.common.actions.enable" placement="top">
|
||||
<el-button
|
||||
link
|
||||
:type="scope.row.status === 'ACTIVE' ? 'warning' : 'success'"
|
||||
:icon="scope.row.status === 'ACTIVE' ? Lock : Unlock"
|
||||
:disabled="isLastAdmin(scope.row)"
|
||||
@click="toggleStatus(scope.row)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="TEXT.modules.adminUsers.resetPassword" placement="top">
|
||||
<el-button link type="warning" :icon="Key" @click="openReset(scope.row)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="TEXT.common.actions.delete" placement="top">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
:disabled="scope.row.id === auth.user?.id"
|
||||
@click="onDelete(scope.row)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, total"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
v-model:current-page="page"
|
||||
@current-change="loadUsers"
|
||||
class="pagination"
|
||||
/>
|
||||
<div class="unified-section user-table-section">
|
||||
<el-table :data="users" stripe v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="email" :label="TEXT.common.fields.email" min-width="200" />
|
||||
<el-table-column prop="full_name" :label="TEXT.common.fields.name" min-width="140" />
|
||||
<el-table-column prop="department" :label="TEXT.common.fields.department" min-width="140" />
|
||||
<el-table-column :label="TEXT.common.fields.status" width="140">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusType(scope.row.status)">
|
||||
{{ statusLabel(scope.row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" width="200">
|
||||
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" min-width="200" align="center">
|
||||
<template #default="scope">
|
||||
<el-tooltip :content="TEXT.common.actions.edit" placement="top">
|
||||
<el-button link type="primary" :icon="Edit" @click="openEdit(scope.row)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="scope.row.status === 'ACTIVE' ? TEXT.common.actions.disable : TEXT.common.actions.enable" placement="top">
|
||||
<el-button
|
||||
link
|
||||
:type="scope.row.status === 'ACTIVE' ? 'warning' : 'success'"
|
||||
:icon="scope.row.status === 'ACTIVE' ? Lock : Unlock"
|
||||
:disabled="isLastAdmin(scope.row)"
|
||||
@click="toggleStatus(scope.row)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="TEXT.modules.adminUsers.resetPassword" placement="top">
|
||||
<el-button link type="warning" :icon="Key" @click="openReset(scope.row)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="TEXT.common.actions.delete" placement="top">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
:disabled="scope.row.id === auth.user?.id"
|
||||
@click="onDelete(scope.row)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, total"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
v-model:current-page="page"
|
||||
@current-change="loadUsers"
|
||||
class="pagination"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
<UserForm v-model:visible="formVisible" :user="editingUser" :admin-count="activeAdminCount" @saved="loadUsers" />
|
||||
<UserResetPassword v-model:visible="resetVisible" :user="resetUser" @reset="loadUsers" />
|
||||
@@ -282,14 +284,6 @@ onMounted(() => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
@@ -310,10 +304,9 @@ onMounted(() => {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
.user-table-section {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
|
||||
@@ -1,43 +1,44 @@
|
||||
<template>
|
||||
<div class="ctms-page">
|
||||
<div class="ctms-page-header">
|
||||
<div>
|
||||
<h1 class="ctms-page-title">
|
||||
{{ detail.title || TEXT.modules.fileVersionManagement.title }}
|
||||
</h1>
|
||||
<p class="ctms-page-subtitle">
|
||||
<el-tag size="small" effect="plain" type="info" class="mr-2">{{ displayText(detail.doc_type, TEXT.enums.documentType) }}</el-tag>
|
||||
<el-tag size="small" effect="plain" type="warning" class="mr-2">{{ displaySite(detail.site_id) }}</el-tag>
|
||||
<el-tag v-if="isReadOnly" size="small" effect="plain" type="info">中心已停用</el-tag>
|
||||
<span class="text-secondary text-sm">
|
||||
{{ TEXT.modules.fileVersionManagement.labels.currentEffective }}:
|
||||
<span class="font-medium text-main">{{ detail.current_effective_version?.version_no || TEXT.common.fallback }}</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="ctms-page-actions">
|
||||
<el-button @click="goBack">
|
||||
<el-icon class="el-icon--left"><ArrowLeft /></el-icon>
|
||||
{{ TEXT.common.actions.back }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page">
|
||||
<div class="unified-shell">
|
||||
<section class="unified-section" v-loading="loading">
|
||||
<div class="unified-section-header">
|
||||
<div>
|
||||
<div class="ctms-section-title">
|
||||
{{ detail.title || TEXT.modules.fileVersionManagement.title }}
|
||||
</div>
|
||||
<div class="doc-meta-row mt-1">
|
||||
<el-tag size="small" effect="plain" type="info" class="mr-2">{{ displayText(detail.doc_type, TEXT.enums.documentType) }}</el-tag>
|
||||
<el-tag size="small" effect="plain" type="warning" class="mr-2">{{ displaySite(detail.site_id) }}</el-tag>
|
||||
<el-tag v-if="isReadOnly" size="small" effect="plain" type="info">中心已停用</el-tag>
|
||||
<span class="text-secondary text-sm">
|
||||
{{ TEXT.modules.fileVersionManagement.labels.currentEffective }}:
|
||||
<span class="font-medium text-main">{{ detail.current_effective_version?.version_no || TEXT.common.fallback }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button @click="goBack">
|
||||
<el-icon class="el-icon--left"><ArrowLeft /></el-icon>
|
||||
{{ TEXT.common.actions.back }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-descriptions :column="3" border class="ctms-descriptions">
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.fields.docType">{{ displayText(detail.doc_type, TEXT.enums.documentType) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.fields.site">{{ displaySite(detail.site_id) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.labels.currentEffective">
|
||||
{{ currentEffectiveVersion?.version_no || TEXT.common.fallback }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="版本日期">
|
||||
{{ formatDateOnly(currentEffectiveVersion?.effective_at || currentEffectiveVersion?.created_at || null) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.columns.updatedAt">{{ formatDate(detail.updated_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</section>
|
||||
|
||||
<el-card class="ctms-section-card" v-loading="loading">
|
||||
<el-descriptions :column="3" border class="ctms-descriptions">
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.fields.docType">{{ displayText(detail.doc_type, TEXT.enums.documentType) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.fields.site">{{ displaySite(detail.site_id) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.labels.currentEffective">
|
||||
{{ currentEffectiveVersion?.version_no || TEXT.common.fallback }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="版本日期">
|
||||
{{ formatDateOnly(currentEffectiveVersion?.effective_at || currentEffectiveVersion?.created_at || null) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.columns.updatedAt">{{ formatDate(detail.updated_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-tabs v-model="activeTab" @tab-change="handleTabChange" class="ctms-tabs">
|
||||
<section class="unified-section">
|
||||
<el-tabs v-model="activeTab" @tab-change="handleTabChange" class="ctms-tabs">
|
||||
<el-tab-pane :label="TEXT.modules.fileVersionManagement.tabs.versions" name="versions">
|
||||
<div class="ctms-section-header">
|
||||
<div class="ctms-section-title"> </div>
|
||||
@@ -48,7 +49,7 @@
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-card class="ctms-table-card">
|
||||
<el-card class="ctms-table-card unified-shell">
|
||||
<el-table :data="detail.version_timeline" v-loading="versionLoading" class="ctms-table">
|
||||
<el-table-column prop="version_no" :label="TEXT.modules.fileVersionManagement.fields.versionNo" width="120">
|
||||
<template #default="{ row }">
|
||||
@@ -106,7 +107,7 @@
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-card class="ctms-table-card">
|
||||
<el-card class="ctms-table-card unified-shell">
|
||||
<el-table :data="distributions" v-loading="distributionLoading" class="ctms-table">
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.fields.targetType" width="120">
|
||||
<template #default="{ row }">
|
||||
@@ -127,7 +128,9 @@
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
</el-tabs>
|
||||
</el-tabs>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="uploadVisible" :title="TEXT.modules.fileVersionManagement.dialog.uploadTitle" width="520px" destroy-on-close class="ctms-dialog">
|
||||
<el-form ref="uploadFormRef" :model="uploadForm" :rules="uploadRules" label-width="110px" class="ctms-form">
|
||||
@@ -671,6 +674,19 @@ watch(
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.doc-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<template>
|
||||
<div class="ctms-page">
|
||||
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<div class="page">
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<div class="filter-container unified-action-bar">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-select v-model="filters.scope_type" :placeholder="TEXT.modules.fileVersionManagement.filters.scope" clearable class="filter-select-comp">
|
||||
@@ -29,60 +28,62 @@
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-table
|
||||
:data="sortedItems"
|
||||
v-loading="loading"
|
||||
@row-click="handleRowClick"
|
||||
:row-class-name="documentRowClass"
|
||||
class="ctms-table"
|
||||
>
|
||||
<el-table-column prop="title" :label="TEXT.modules.fileVersionManagement.columns.title" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="font-semibold">{{ row.title }}</span>
|
||||
<section class="unified-section">
|
||||
<el-table
|
||||
:data="sortedItems"
|
||||
v-loading="loading"
|
||||
@row-click="handleRowClick"
|
||||
:row-class-name="documentRowClass"
|
||||
class="ctms-table"
|
||||
>
|
||||
<el-table-column prop="title" :label="TEXT.modules.fileVersionManagement.columns.title" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="font-semibold">{{ row.title }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.scope" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
effect="plain"
|
||||
:type="row.scope_type === 'SITE' ? 'warning' : 'success'"
|
||||
>
|
||||
{{ displayEnum(TEXT.enums.scopeType, row.scope_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.site" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span>{{ displaySite(row.site_id) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.docType" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag effect="plain" type="info">{{ displayText(row.doc_type, TEXT.enums.documentType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.currentVersion" width="140">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.current_effective_version">{{ row.current_effective_version.version_no }}</span>
|
||||
<span v-else class="text-secondary">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" :label="TEXT.modules.fileVersionManagement.columns.updatedAt" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="text-secondary">{{ formatDate(row.updated_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="isAdmin" link type="danger" :disabled="isInactiveSite(row.site_id)" @click.stop="confirmDelete(row)">
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty :description="TEXT.modules.fileVersionManagement.emptyDescription" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.scope" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
effect="plain"
|
||||
:type="row.scope_type === 'SITE' ? 'warning' : 'success'"
|
||||
>
|
||||
{{ displayEnum(TEXT.enums.scopeType, row.scope_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.site" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span>{{ displaySite(row.site_id) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.docType" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag effect="plain" type="info">{{ displayText(row.doc_type, TEXT.enums.documentType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.currentVersion" width="140">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.current_effective_version">{{ row.current_effective_version.version_no }}</span>
|
||||
<span v-else class="text-secondary">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" :label="TEXT.modules.fileVersionManagement.columns.updatedAt" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="text-secondary">{{ formatDate(row.updated_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="isAdmin" link type="danger" :disabled="isInactiveSite(row.site_id)" @click.stop="confirmDelete(row)">
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty :description="TEXT.modules.fileVersionManagement.emptyDescription" />
|
||||
</template>
|
||||
</el-table>
|
||||
</el-table>
|
||||
</section>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="createVisible" :title="TEXT.modules.fileVersionManagement.dialog.createTitle" width="520px" destroy-on-close class="ctms-dialog">
|
||||
@@ -339,17 +340,18 @@ watch(
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ctms-page {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
@@ -365,7 +367,7 @@ watch(
|
||||
}
|
||||
|
||||
.filter-select-comp {
|
||||
width: 140px;
|
||||
width: 132px;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
@@ -373,9 +375,9 @@ watch(
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ TEXT.modules.drugShipments.detailTitle }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.drugShipments.detailSubtitle }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>{{ TEXT.common.labels.basicInfo }}</span>
|
||||
<el-tag :type="detail.status === 'PENDING' ? 'info' : detail.status === 'SIGNED' ? 'success' : 'primary'">
|
||||
{{ displayEnum(TEXT.enums.shipmentStatus, detail.status) }}
|
||||
</el-tag>
|
||||
<div class="unified-shell">
|
||||
<section class="unified-section">
|
||||
<div class="unified-section-header">
|
||||
<div class="ctms-section-title">{{ TEXT.modules.drugShipments.detailTitle }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="section-title-row">
|
||||
<span class="ctms-section-title">{{ TEXT.common.labels.basicInfo }}</span>
|
||||
<el-tag :type="detail.status === 'PENDING' ? 'info' : detail.status === 'SIGNED' ? 'success' : 'primary'">
|
||||
{{ displayEnum(TEXT.enums.shipmentStatus, detail.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item :label="TEXT.common.fields.site">{{ detail.site_name || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.direction">
|
||||
@@ -29,9 +23,9 @@
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card :header="TEXT.modules.drugShipments.recordLabel">
|
||||
</section>
|
||||
<section class="unified-section">
|
||||
<div class="ctms-section-title section-title-inline">{{ TEXT.modules.drugShipments.recordLabel }}</div>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item :label="TEXT.common.fields.trackingNo">{{ detail.tracking_no || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.carrier">{{ detail.carrier || TEXT.common.fallback }}</el-descriptions-item>
|
||||
@@ -41,15 +35,16 @@
|
||||
<el-descriptions-item :label="TEXT.common.fields.batchNo">{{ detail.batch_no || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</section>
|
||||
<section class="unified-section">
|
||||
<AttachmentList
|
||||
v-if="shipmentId"
|
||||
:study-id="studyId"
|
||||
entity-type="drug_shipment"
|
||||
:entity-id="shipmentId"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<AttachmentList
|
||||
v-if="shipmentId"
|
||||
:study-id="studyId"
|
||||
entity-type="drug_shipment"
|
||||
:entity-id="shipmentId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -111,41 +106,17 @@ onMounted(load);
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
.section-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
.section-title-inline {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,109 +1,108 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ isEdit ? TEXT.modules.drugShipments.editTitle : TEXT.modules.drugShipments.newTitle }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.drugShipments.formSubtitle }}</p>
|
||||
</div>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-card>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" label-position="top">
|
||||
<h3 class="section-title">{{ TEXT.common.labels.basicInfo }}</h3>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.site" prop="center_id">
|
||||
<el-select v-model="form.center_id" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
|
||||
<el-option
|
||||
v-for="site in sites"
|
||||
:key="site.id"
|
||||
:label="site.name || TEXT.common.fallback"
|
||||
:value="site.id"
|
||||
:disabled="!site.is_active"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.direction" prop="direction">
|
||||
<el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
|
||||
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
|
||||
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.status" prop="status">
|
||||
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
|
||||
<el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
|
||||
<el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" />
|
||||
<el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
|
||||
<el-option :label="TEXT.enums.shipmentStatus.RETURNED" value="RETURNED" />
|
||||
<el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<h3 class="section-title">{{ TEXT.modules.drugShipments.recordLabel }}</h3>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.shipDate" prop="ship_date">
|
||||
<el-date-picker v-model="form.ship_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.receiveDate" prop="receive_date">
|
||||
<el-date-picker v-model="form.receive_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.quantity" prop="quantity">
|
||||
<el-input-number v-model="form.quantity" :min="0" :controls="false" :placeholder="TEXT.common.placeholders.input" style="width: 100%" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.batchNo" prop="batch_no">
|
||||
<el-input v-model="form.batch_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.batchNo" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.carrier" prop="carrier">
|
||||
<el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.trackingNo" prop="tracking_no">
|
||||
<el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item :label="TEXT.common.fields.remark" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">
|
||||
{{ TEXT.common.actions.save }}
|
||||
</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<div class="unified-shell">
|
||||
<section class="unified-section">
|
||||
<div class="unified-section-header">
|
||||
<div class="ctms-section-title">{{ isEdit ? TEXT.modules.drugShipments.editTitle : TEXT.modules.drugShipments.newTitle }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" label-position="top">
|
||||
<h3 class="section-title">{{ TEXT.common.labels.basicInfo }}</h3>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.site" prop="center_id">
|
||||
<el-select v-model="form.center_id" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
|
||||
<el-option
|
||||
v-for="site in sites"
|
||||
:key="site.id"
|
||||
:label="site.name || TEXT.common.fallback"
|
||||
:value="site.id"
|
||||
:disabled="!site.is_active"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.direction" prop="direction">
|
||||
<el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
|
||||
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
|
||||
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.status" prop="status">
|
||||
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
|
||||
<el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
|
||||
<el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" />
|
||||
<el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
|
||||
<el-option :label="TEXT.enums.shipmentStatus.RETURNED" value="RETURNED" />
|
||||
<el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<AttachmentList
|
||||
v-if="isEdit && shipmentId"
|
||||
:study-id="studyId"
|
||||
entity-type="drug_shipment"
|
||||
:entity-id="shipmentId"
|
||||
/>
|
||||
<el-card v-else class="hint-card">
|
||||
<StateEmpty :description="TEXT.modules.drugShipments.uploadHint" />
|
||||
</el-card>
|
||||
<h3 class="section-title">{{ TEXT.modules.drugShipments.recordLabel }}</h3>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.shipDate" prop="ship_date">
|
||||
<el-date-picker v-model="form.ship_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.receiveDate" prop="receive_date">
|
||||
<el-date-picker v-model="form.receive_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.quantity" prop="quantity">
|
||||
<el-input-number v-model="form.quantity" :min="0" :controls="false" :placeholder="TEXT.common.placeholders.input" style="width: 100%" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.batchNo" prop="batch_no">
|
||||
<el-input v-model="form.batch_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.batchNo" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.carrier" prop="carrier">
|
||||
<el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.trackingNo" prop="tracking_no">
|
||||
<el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item :label="TEXT.common.fields.remark" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">
|
||||
{{ TEXT.common.actions.save }}
|
||||
</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</section>
|
||||
<section class="unified-section">
|
||||
<AttachmentList
|
||||
v-if="isEdit && shipmentId"
|
||||
:study-id="studyId"
|
||||
entity-type="drug_shipment"
|
||||
:entity-id="shipmentId"
|
||||
/>
|
||||
<StateEmpty v-else :description="TEXT.modules.drugShipments.uploadHint" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -245,29 +244,7 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.hint-card {
|
||||
padding: 12px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="actions">
|
||||
<el-button type="primary" :disabled="!canWrite || isReadOnly" @click="goEdit" class="header-action-btn copy-btn">
|
||||
<el-icon class="el-icon--left"><Edit /></el-icon>
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button @click="goBack" class="header-action-btn">
|
||||
{{ TEXT.common.actions.back }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<StateError v-if="errorMessage" :description="errorMessage">
|
||||
<template #action>
|
||||
<el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
|
||||
@@ -19,10 +9,19 @@
|
||||
<StateLoading v-else-if="loading" :rows="6" />
|
||||
|
||||
<template v-else>
|
||||
<el-card class="detail-card" shadow="never">
|
||||
<el-card class="detail-card unified-shell" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="card-header actions-header">
|
||||
<span class="header-title">{{ TEXT.common.labels.basicInfo }}</span>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :disabled="!canWrite || isReadOnly" @click="goEdit" class="header-action-btn copy-btn">
|
||||
<el-icon class="el-icon--left"><Edit /></el-icon>
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button @click="goBack" class="header-action-btn">
|
||||
{{ TEXT.common.actions.back }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-descriptions :column="2" border class="custom-descriptions">
|
||||
@@ -47,7 +46,7 @@
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card class="section-card" shadow="never">
|
||||
<el-card class="section-card unified-shell" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="header-title">{{ TEXT.modules.feeContracts.paymentTitle }}</span>
|
||||
@@ -89,7 +88,7 @@
|
||||
<StateEmpty v-if="detail.payments.length === 0" :description="TEXT.modules.feeContracts.paymentEmpty" />
|
||||
</el-card>
|
||||
|
||||
<el-card class="section-card" shadow="never">
|
||||
<el-card class="section-card unified-shell" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="header-title">{{ TEXT.modules.feeContracts.attachmentTitle }}</span>
|
||||
@@ -232,26 +231,7 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--ctms-text-secondary);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -275,6 +255,12 @@ onMounted(async () => {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.actions-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ isEdit ? TEXT.modules.feeContracts.editTitle : TEXT.modules.feeContracts.newTitle }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.feeContracts.formSubtitle }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button @click="goBack" class="header-action-btn">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StateError v-if="errorMessage" :description="errorMessage">
|
||||
<template #action>
|
||||
<el-button type="primary" @click="loadDetail">{{ TEXT.common.actions.retry }}</el-button>
|
||||
@@ -19,11 +9,12 @@
|
||||
<StateLoading v-else-if="loading" :rows="6" />
|
||||
|
||||
<template v-else>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="140px" label-position="top">
|
||||
<el-card class="form-card" shadow="never">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="140px" label-position="top">
|
||||
<el-card class="form-card unified-shell" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="card-header actions-header">
|
||||
<span class="header-title">{{ TEXT.common.labels.basicInfo }}</span>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-row :gutter="24">
|
||||
@@ -97,7 +88,7 @@
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<el-card class="form-card section-margin" shadow="never">
|
||||
<el-card class="form-card section-margin unified-shell" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header actions-header">
|
||||
<span class="header-title">{{ TEXT.modules.feeContracts.paymentTitle }}</span>
|
||||
@@ -183,7 +174,7 @@
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="form-card section-margin" shadow="never">
|
||||
<el-card class="form-card section-margin unified-shell" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="header-title">{{ TEXT.modules.feeContracts.attachmentTitle }}</span>
|
||||
@@ -512,36 +503,10 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
|
||||
<div class="overview">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
@@ -46,8 +45,16 @@
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="main-content-card" v-if="!errorMessage && !loading">
|
||||
<div class="filter-container">
|
||||
<StateError v-if="errorMessage" :description="errorMessage">
|
||||
<template #action>
|
||||
<el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
|
||||
</template>
|
||||
</StateError>
|
||||
|
||||
<StateLoading v-else-if="loading" :rows="6" />
|
||||
|
||||
<el-card shadow="never" class="main-content-card unified-shell" v-else>
|
||||
<div class="filter-container unified-action-bar">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item">
|
||||
<el-select v-model="filters.centerId" clearable :placeholder="TEXT.common.fields.site" class="filter-select">
|
||||
@@ -79,94 +86,87 @@
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<StateError v-if="errorMessage" :description="errorMessage">
|
||||
<template #action>
|
||||
<el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
|
||||
</template>
|
||||
</StateError>
|
||||
|
||||
<StateLoading v-else-if="loading" :rows="6" />
|
||||
|
||||
<el-table
|
||||
:data="sortedContracts"
|
||||
style="width: 100%"
|
||||
@row-click="onRowClick"
|
||||
:row-class-name="contractRowClass"
|
||||
:header-cell-style="{ background: '#f8f9fb' }"
|
||||
>
|
||||
<el-table-column prop="center_name" :label="TEXT.common.fields.site" min-width="160">
|
||||
<template #default="scope">
|
||||
<div class="site-cell">
|
||||
<span class="site-name">{{ scope.row.center_name || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.feeContracts.contractAmount" width="140" align="right">
|
||||
<template #default="scope">
|
||||
<span class="amount-text">{{ formatAmountWan(scope.row.contract_amount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.feeContracts.caseProgress" min-width="140" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag effect="plain" type="info">
|
||||
{{ displayCases(scope.row.contract_cases, scope.row.actual_cases) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.feeContracts.paidSummary" min-width="200">
|
||||
<template #default="scope">
|
||||
<div class="summary-line">
|
||||
<span>{{ TEXT.modules.feeContracts.paidTotal }}</span>
|
||||
<span class="amount-highlight success">{{ formatAmountWan(scope.row.paid_total) }}</span>
|
||||
</div>
|
||||
<div class="summary-line">
|
||||
<span>{{ TEXT.modules.feeContracts.unpaidBalance }}</span>
|
||||
<span class="amount-highlight warning" v-if="scope.row.unpaid_balance > 0">{{ formatAmountWan(scope.row.unpaid_balance) }}</span>
|
||||
<span class="amount-muted" v-else>0.00</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.feeContracts.verifySummary" min-width="200">
|
||||
<template #default="scope">
|
||||
<div class="summary-line">
|
||||
<span>{{ TEXT.modules.feeContracts.verifiedTotal }}</span>
|
||||
<span class="amount-highlight info">{{ formatAmountWan(scope.row.verified_total) }}</span>
|
||||
</div>
|
||||
<div class="summary-line">
|
||||
<span>{{ TEXT.modules.feeContracts.unverifiedBalance }}</span>
|
||||
<span class="amount-highlight warning" v-if="scope.row.unverified_balance > 0">{{ formatAmountWan(scope.row.unverified_balance) }}</span>
|
||||
<span class="amount-muted" v-else>0.00</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.feeContracts.recentDates" min-width="180">
|
||||
<template #default="scope">
|
||||
<div class="date-line">
|
||||
<span class="date-label">{{ TEXT.modules.feeContracts.lastPaid }}</span>
|
||||
<span class="date-value">{{ displayDate(scope.row.last_paid_date) }}</span>
|
||||
</div>
|
||||
<div class="date-line">
|
||||
<span class="date-label">{{ TEXT.modules.feeContracts.lastVerified }}</span>
|
||||
<span class="date-value">{{ displayDate(scope.row.last_verified_date) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="100">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="!canWrite || isInactiveSite(scope.row.center_id)"
|
||||
@click.stop="remove(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="sortedContracts.length === 0" :description="TEXT.modules.feeContracts.empty" />
|
||||
<section class="unified-section">
|
||||
<el-table
|
||||
:data="sortedContracts"
|
||||
style="width: 100%"
|
||||
@row-click="onRowClick"
|
||||
:row-class-name="contractRowClass"
|
||||
:header-cell-style="{ background: '#f8f9fb' }"
|
||||
>
|
||||
<el-table-column prop="center_name" :label="TEXT.common.fields.site" min-width="160">
|
||||
<template #default="scope">
|
||||
<div class="site-cell">
|
||||
<span class="site-name">{{ scope.row.center_name || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.feeContracts.contractAmount" width="140" align="right">
|
||||
<template #default="scope">
|
||||
<span class="amount-text">{{ formatAmountWan(scope.row.contract_amount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.feeContracts.caseProgress" min-width="140" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag effect="plain" type="info">
|
||||
{{ displayCases(scope.row.contract_cases, scope.row.actual_cases) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.feeContracts.paidSummary" min-width="200">
|
||||
<template #default="scope">
|
||||
<div class="summary-line">
|
||||
<span>{{ TEXT.modules.feeContracts.paidTotal }}</span>
|
||||
<span class="amount-highlight success">{{ formatAmountWan(scope.row.paid_total) }}</span>
|
||||
</div>
|
||||
<div class="summary-line">
|
||||
<span>{{ TEXT.modules.feeContracts.unpaidBalance }}</span>
|
||||
<span class="amount-highlight warning" v-if="scope.row.unpaid_balance > 0">{{ formatAmountWan(scope.row.unpaid_balance) }}</span>
|
||||
<span class="amount-muted" v-else>0.00</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.feeContracts.verifySummary" min-width="200">
|
||||
<template #default="scope">
|
||||
<div class="summary-line">
|
||||
<span>{{ TEXT.modules.feeContracts.verifiedTotal }}</span>
|
||||
<span class="amount-highlight info">{{ formatAmountWan(scope.row.verified_total) }}</span>
|
||||
</div>
|
||||
<div class="summary-line">
|
||||
<span>{{ TEXT.modules.feeContracts.unverifiedBalance }}</span>
|
||||
<span class="amount-highlight warning" v-if="scope.row.unverified_balance > 0">{{ formatAmountWan(scope.row.unverified_balance) }}</span>
|
||||
<span class="amount-muted" v-else>0.00</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.feeContracts.recentDates" min-width="180">
|
||||
<template #default="scope">
|
||||
<div class="date-line">
|
||||
<span class="date-label">{{ TEXT.modules.feeContracts.lastPaid }}</span>
|
||||
<span class="date-value">{{ displayDate(scope.row.last_paid_date) }}</span>
|
||||
</div>
|
||||
<div class="date-line">
|
||||
<span class="date-label">{{ TEXT.modules.feeContracts.lastVerified }}</span>
|
||||
<span class="date-value">{{ displayDate(scope.row.last_verified_date) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="100">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="!canWrite || isInactiveSite(scope.row.center_id)"
|
||||
@click.stop="remove(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="sortedContracts.length === 0" :description="TEXT.modules.feeContracts.empty" />
|
||||
</section>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -347,14 +347,15 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="actions">
|
||||
<el-button type="primary" :disabled="!canWrite || isReadOnly" @click="goEdit" class="header-action-btn copy-btn">
|
||||
<el-icon class="el-icon--left"><Edit /></el-icon>
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button @click="goBack" class="header-action-btn">
|
||||
{{ TEXT.common.actions.back }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<StateError v-if="errorMessage" :description="errorMessage">
|
||||
<template #action>
|
||||
<el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
|
||||
@@ -19,16 +9,23 @@
|
||||
<StateLoading v-else-if="loading" :rows="6" />
|
||||
|
||||
<template v-else>
|
||||
<el-card class="detail-card" shadow="never">
|
||||
<el-card class="detail-card unified-shell" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="header-title">{{ TEXT.common.labels.basicInfo }}</span>
|
||||
<div class="status-badges">
|
||||
<div class="status-badges actions">
|
||||
<el-tag v-if="detail.is_paid" type="success" effect="dark" size="default">{{ TEXT.modules.feeSpecials.isPaid }}</el-tag>
|
||||
<el-tag v-else type="info" effect="plain" size="default">{{ TEXT.modules.feeSpecials.unpaid }}</el-tag>
|
||||
|
||||
|
||||
<el-tag v-if="detail.is_verified" type="info" effect="dark" size="default">{{ TEXT.modules.feeSpecials.isVerified }}</el-tag>
|
||||
<el-tag v-else type="info" effect="plain" size="default">{{ TEXT.modules.feeSpecials.unverified }}</el-tag>
|
||||
<el-button type="primary" :disabled="!canWrite || isReadOnly" @click="goEdit" class="header-action-btn copy-btn">
|
||||
<el-icon class="el-icon--left"><Edit /></el-icon>
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button @click="goBack" class="header-action-btn">
|
||||
{{ TEXT.common.actions.back }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -65,7 +62,7 @@
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card class="section-card" shadow="never">
|
||||
<el-card class="section-card unified-shell" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="header-title">{{ TEXT.modules.feeSpecials.attachmentTitle }}</span>
|
||||
@@ -216,26 +213,7 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--ctms-text-secondary);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ isEdit ? TEXT.modules.feeSpecials.editTitle : TEXT.modules.feeSpecials.newTitle }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.feeSpecials.formSubtitle }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button @click="goBack" class="header-action-btn">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StateError v-if="errorMessage" :description="errorMessage">
|
||||
<template #action>
|
||||
<el-button type="primary" @click="loadDetail">{{ TEXT.common.actions.retry }}</el-button>
|
||||
@@ -20,10 +10,11 @@
|
||||
|
||||
<template v-else>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" label-position="top">
|
||||
<el-card class="form-card" shadow="never">
|
||||
<el-card class="form-card unified-shell" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="card-header header-actions-row">
|
||||
<span class="header-title">{{ TEXT.common.labels.basicInfo }}</span>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-row :gutter="24">
|
||||
@@ -65,7 +56,7 @@
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<el-card class="form-card section-margin" shadow="never">
|
||||
<el-card class="form-card section-margin unified-shell" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="header-title">{{ TEXT.common.fields.status }}</span>
|
||||
@@ -116,7 +107,7 @@
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="form-card section-margin" shadow="never">
|
||||
<el-card class="form-card section-margin unified-shell" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="header-title">{{ TEXT.modules.feeSpecials.attachmentTitle }}</span>
|
||||
@@ -389,37 +380,10 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
@@ -434,6 +398,12 @@ onMounted(async () => {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.header-actions-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
|
||||
<div class="overview">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
@@ -45,8 +44,16 @@
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="main-content-card" v-if="!errorMessage && !loading">
|
||||
<div class="filter-container">
|
||||
<StateError v-if="errorMessage" :description="errorMessage">
|
||||
<template #action>
|
||||
<el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
|
||||
</template>
|
||||
</StateError>
|
||||
|
||||
<StateLoading v-else-if="loading" :rows="6" />
|
||||
|
||||
<el-card shadow="never" class="main-content-card unified-shell" v-else>
|
||||
<div class="filter-container unified-action-bar">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item">
|
||||
<el-select v-model="filters.centerId" clearable :placeholder="TEXT.common.fields.site" class="filter-select">
|
||||
@@ -69,7 +76,7 @@
|
||||
<el-option v-for="option in categoryOptions" :key="option.value" :label="option.label" :value="option.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="" class="filter-item">
|
||||
<el-form-item label="" class="filter-item">
|
||||
<el-date-picker
|
||||
v-model="filters.dateRange"
|
||||
type="daterange"
|
||||
@@ -90,73 +97,75 @@
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-table
|
||||
:data="sortedExpenses"
|
||||
style="width: 100%"
|
||||
@row-click="onRowClick"
|
||||
:header-cell-style="{ background: '#f8f9fb' }"
|
||||
:row-class-name="expenseRowClass"
|
||||
>
|
||||
<el-table-column :label="TEXT.common.fields.occurDate" width="140">
|
||||
<template #default="scope">
|
||||
<span class="date-text">{{ displayDate(scope.row.happen_date) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.site" min-width="160">
|
||||
<template #default="scope">
|
||||
<span class="site-text">{{ scope.row.center_name || TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.category" width="140">
|
||||
<template #default="scope">
|
||||
<el-tag size="small" :type="getCategoryType(scope.row.category)" effect="light" class="category-tag">
|
||||
{{ displayEnum(TEXT.enums.feeSpecialCategory, scope.row.category) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.amount" width="110" align="right">
|
||||
<template #default="scope">
|
||||
<span class="amount-text">{{ formatAmount(scope.row.amount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" :label="TEXT.common.fields.remark" min-width="200" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
{{ scope.row.description || TEXT.common.fallback }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column width="140" align="center" :label="TEXT.common.fields.status">
|
||||
<template #default="scope">
|
||||
<el-tooltip :content="scope.row.is_paid ? `${TEXT.modules.feeSpecials.isPaid} (${displayDate(scope.row.paid_date)})` : TEXT.modules.feeSpecials.unpaid" placement="top">
|
||||
<el-icon :class="scope.row.is_paid ? 'status-icon success' : 'status-icon muted'"><Wallet /></el-icon>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="scope.row.is_verified ? `${TEXT.modules.feeSpecials.isVerified} (${displayDate(scope.row.verified_date)})` : TEXT.modules.feeSpecials.unverified" placement="top">
|
||||
<el-icon :class="scope.row.is_verified ? 'status-icon info' : 'status-icon muted'"><CircleCheck /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.feeSpecials.attachmentCount" width="120" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.attachments_count > 0" effect="plain" type="info" round size="small">
|
||||
{{ scope.row.attachments_count }}
|
||||
</el-tag>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="!canWrite || isInactiveSite(scope.row.center_id)"
|
||||
@click.stop="remove(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="sortedExpenses.length === 0" :description="TEXT.modules.feeSpecials.empty" />
|
||||
<section class="unified-section">
|
||||
<el-table
|
||||
:data="sortedExpenses"
|
||||
style="width: 100%"
|
||||
@row-click="onRowClick"
|
||||
:header-cell-style="{ background: '#f8f9fb' }"
|
||||
:row-class-name="expenseRowClass"
|
||||
>
|
||||
<el-table-column :label="TEXT.common.fields.occurDate" width="140">
|
||||
<template #default="scope">
|
||||
<span class="date-text">{{ displayDate(scope.row.happen_date) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.site" min-width="160">
|
||||
<template #default="scope">
|
||||
<span class="site-text">{{ scope.row.center_name || TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.category" width="140">
|
||||
<template #default="scope">
|
||||
<el-tag size="small" :type="getCategoryType(scope.row.category)" effect="light" class="category-tag">
|
||||
{{ displayEnum(TEXT.enums.feeSpecialCategory, scope.row.category) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.amount" width="110" align="right">
|
||||
<template #default="scope">
|
||||
<span class="amount-text">{{ formatAmount(scope.row.amount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" :label="TEXT.common.fields.remark" min-width="200" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
{{ scope.row.description || TEXT.common.fallback }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column width="140" align="center" :label="TEXT.common.fields.status">
|
||||
<template #default="scope">
|
||||
<el-tooltip :content="scope.row.is_paid ? `${TEXT.modules.feeSpecials.isPaid} (${displayDate(scope.row.paid_date)})` : TEXT.modules.feeSpecials.unpaid" placement="top">
|
||||
<el-icon :class="scope.row.is_paid ? 'status-icon success' : 'status-icon muted'"><Wallet /></el-icon>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="scope.row.is_verified ? `${TEXT.modules.feeSpecials.isVerified} (${displayDate(scope.row.verified_date)})` : TEXT.modules.feeSpecials.unverified" placement="top">
|
||||
<el-icon :class="scope.row.is_verified ? 'status-icon info' : 'status-icon muted'"><CircleCheck /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.feeSpecials.attachmentCount" width="120" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.attachments_count > 0" effect="plain" type="info" round size="small">
|
||||
{{ scope.row.attachments_count }}
|
||||
</el-tag>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="!canWrite || isInactiveSite(scope.row.center_id)"
|
||||
@click.stop="remove(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="sortedExpenses.length === 0" :description="TEXT.modules.feeSpecials.empty" />
|
||||
</section>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -343,14 +352,15 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
|
||||
@@ -1,35 +1,34 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ TEXT.modules.financeContracts.detailTitle }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.financeContracts.detailSubtitle }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :disabled="isReadOnly" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
<div class="unified-shell">
|
||||
<section class="unified-section" v-loading="loading">
|
||||
<div class="unified-section-header">
|
||||
<div class="ctms-section-title">{{ TEXT.modules.financeContracts.detailTitle }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button type="primary" :disabled="isReadOnly" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item :label="TEXT.common.fields.site">{{ detail.site_name || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.contractNo">{{ detail.contract_no || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.signedDate">{{ displayDate(detail.signed_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.amount">
|
||||
{{ detail.amount }} {{ detail.currency || TEXT.common.fields.currencyDefault }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</section>
|
||||
<section class="unified-section">
|
||||
<AttachmentList
|
||||
v-if="contractId"
|
||||
:study-id="studyId"
|
||||
entity-type="finance_contract"
|
||||
:entity-id="contractId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<el-card v-loading="loading">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item :label="TEXT.common.fields.site">{{ detail.site_name || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.contractNo">{{ detail.contract_no || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.signedDate">{{ displayDate(detail.signed_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.amount">
|
||||
{{ detail.amount }} {{ detail.currency || TEXT.common.fields.currencyDefault }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<AttachmentList
|
||||
v-if="contractId"
|
||||
:study-id="studyId"
|
||||
entity-type="finance_contract"
|
||||
:entity-id="contractId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -112,29 +111,6 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,54 +1,53 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ isEdit ? TEXT.modules.financeContracts.editTitle : TEXT.modules.financeContracts.newTitle }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.financeContracts.formSubtitle }}</p>
|
||||
</div>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
<div class="unified-shell">
|
||||
<section class="unified-section">
|
||||
<div class="unified-section-header">
|
||||
<div class="ctms-section-title">{{ isEdit ? TEXT.modules.financeContracts.editTitle : TEXT.modules.financeContracts.newTitle }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-form ref="formRef" :model="form" label-width="110px">
|
||||
<el-form-item :label="TEXT.common.fields.site" prop="site_name" required>
|
||||
<el-input v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.contractNo" prop="contract_no" required>
|
||||
<el-input v-model="form.contract_no" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.contractNo" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.signedDate">
|
||||
<el-date-picker v-model="form.signed_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.amount" prop="amount" required>
|
||||
<el-input-number v-model="form.amount" :disabled="isReadOnly" :min="0" :precision="2" :step="1000" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.currency">
|
||||
<el-select v-model="form.currency" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select">
|
||||
<el-option label="CNY" value="CNY" />
|
||||
<el-option label="USD" value="USD" />
|
||||
<el-option label="EUR" value="EUR" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.remark">
|
||||
<el-input v-model="form.remark" :disabled="isReadOnly" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :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>
|
||||
</el-form>
|
||||
</section>
|
||||
<section class="unified-section">
|
||||
<AttachmentList
|
||||
v-if="isEdit && contractId"
|
||||
:study-id="studyId"
|
||||
entity-type="finance_contract"
|
||||
:entity-id="contractId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
<StateEmpty v-else :description="TEXT.modules.financeContracts.uploadHint" />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<el-card>
|
||||
<el-form ref="formRef" :model="form" label-width="110px">
|
||||
<el-form-item :label="TEXT.common.fields.site" prop="site_name" required>
|
||||
<el-input v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.contractNo" prop="contract_no" required>
|
||||
<el-input v-model="form.contract_no" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.contractNo" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.signedDate">
|
||||
<el-date-picker v-model="form.signed_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.amount" prop="amount" required>
|
||||
<el-input-number v-model="form.amount" :disabled="isReadOnly" :min="0" :precision="2" :step="1000" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.currency">
|
||||
<el-select v-model="form.currency" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select">
|
||||
<el-option label="CNY" value="CNY" />
|
||||
<el-option label="USD" value="USD" />
|
||||
<el-option label="EUR" value="EUR" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.remark">
|
||||
<el-input v-model="form.remark" :disabled="isReadOnly" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :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>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<AttachmentList
|
||||
v-if="isEdit && contractId"
|
||||
:study-id="studyId"
|
||||
entity-type="finance_contract"
|
||||
:entity-id="contractId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
<el-card v-else class="hint-card">
|
||||
<StateEmpty :description="TEXT.modules.financeContracts.uploadHint" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -170,28 +169,6 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.hint-card {
|
||||
padding: 12px;
|
||||
gap: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,34 +1,33 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ TEXT.modules.financeSpecials.detailTitle }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.financeSpecials.detailSubtitle }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :disabled="isReadOnly" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
<div class="unified-shell">
|
||||
<section class="unified-section" v-loading="loading">
|
||||
<div class="unified-section-header">
|
||||
<div class="ctms-section-title">{{ TEXT.modules.financeSpecials.detailTitle }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button type="primary" :disabled="isReadOnly" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item :label="TEXT.common.fields.site">{{ detail.site_name || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.type">{{ displayEnum(TEXT.enums.financeSpecialType, detail.fee_type) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.occurDate">{{ displayDate(detail.occur_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.staff">{{ detail.staff_name || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.amount">{{ detail.amount || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</section>
|
||||
<section class="unified-section">
|
||||
<AttachmentList
|
||||
v-if="specialId"
|
||||
:study-id="studyId"
|
||||
entity-type="finance_special"
|
||||
:entity-id="specialId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<el-card v-loading="loading">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item :label="TEXT.common.fields.site">{{ detail.site_name || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.type">{{ displayEnum(TEXT.enums.financeSpecialType, detail.fee_type) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.occurDate">{{ displayDate(detail.occur_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.staff">{{ detail.staff_name || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.amount">{{ detail.amount || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<AttachmentList
|
||||
v-if="specialId"
|
||||
:study-id="studyId"
|
||||
entity-type="finance_special"
|
||||
:entity-id="specialId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -111,29 +110,6 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,55 +1,54 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ isEdit ? TEXT.modules.financeSpecials.editTitle : TEXT.modules.financeSpecials.newTitle }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.financeSpecials.subtitle }}</p>
|
||||
</div>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
<div class="unified-shell">
|
||||
<section class="unified-section">
|
||||
<div class="unified-section-header">
|
||||
<div class="ctms-section-title">{{ isEdit ? TEXT.modules.financeSpecials.editTitle : TEXT.modules.financeSpecials.newTitle }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-form :model="form" label-width="110px">
|
||||
<el-form-item :label="TEXT.common.fields.site" required>
|
||||
<el-input v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.type" required>
|
||||
<el-select v-model="form.fee_type" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select">
|
||||
<el-option :label="TEXT.enums.financeSpecialType.TRAVEL" value="TRAVEL" />
|
||||
<el-option :label="TEXT.enums.financeSpecialType.HOTEL" value="HOTEL" />
|
||||
<el-option :label="TEXT.enums.financeSpecialType.TRANSPORT" value="TRANSPORT" />
|
||||
<el-option :label="TEXT.enums.financeSpecialType.OTHER" value="OTHER" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.amount" required>
|
||||
<el-input-number v-model="form.amount" :disabled="isReadOnly" :min="0" :precision="2" :step="100" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.occurDate">
|
||||
<el-date-picker v-model="form.occur_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.staff">
|
||||
<el-input v-model="form.staff_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.staff" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.remark">
|
||||
<el-input v-model="form.remark" :disabled="isReadOnly" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :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>
|
||||
</el-form>
|
||||
</section>
|
||||
<section class="unified-section">
|
||||
<AttachmentList
|
||||
v-if="isEdit && specialId"
|
||||
:study-id="studyId"
|
||||
entity-type="finance_special"
|
||||
:entity-id="specialId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
<StateEmpty v-else :description="TEXT.modules.financeSpecials.uploadHint" />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<el-card>
|
||||
<el-form :model="form" label-width="110px">
|
||||
<el-form-item :label="TEXT.common.fields.site" required>
|
||||
<el-input v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.type" required>
|
||||
<el-select v-model="form.fee_type" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select">
|
||||
<el-option :label="TEXT.enums.financeSpecialType.TRAVEL" value="TRAVEL" />
|
||||
<el-option :label="TEXT.enums.financeSpecialType.HOTEL" value="HOTEL" />
|
||||
<el-option :label="TEXT.enums.financeSpecialType.TRANSPORT" value="TRANSPORT" />
|
||||
<el-option :label="TEXT.enums.financeSpecialType.OTHER" value="OTHER" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.amount" required>
|
||||
<el-input-number v-model="form.amount" :disabled="isReadOnly" :min="0" :precision="2" :step="100" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.occurDate">
|
||||
<el-date-picker v-model="form.occur_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.staff">
|
||||
<el-input v-model="form.staff_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.staff" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.remark">
|
||||
<el-input v-model="form.remark" :disabled="isReadOnly" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :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>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<AttachmentList
|
||||
v-if="isEdit && specialId"
|
||||
:study-id="studyId"
|
||||
entity-type="finance_special"
|
||||
:entity-id="specialId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
<el-card v-else class="hint-card">
|
||||
<StateEmpty :description="TEXT.modules.financeSpecials.uploadHint" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -171,28 +170,6 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.hint-card {
|
||||
padding: 12px;
|
||||
gap: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<div class="filter-container unified-action-bar">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item">
|
||||
<el-select v-model="filters.center_id" clearable :placeholder="TEXT.common.fields.site" class="filter-select">
|
||||
@@ -42,66 +42,68 @@
|
||||
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
|
||||
</el-form-item>
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="goNew" class="header-action-btn">
|
||||
<el-button type="primary" @click="goNew">
|
||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||||
{{ TEXT.modules.drugShipments.newTitle }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-table
|
||||
:data="sortedItems"
|
||||
v-loading="loading"
|
||||
style="width: 100%"
|
||||
:row-class-name="shipmentRowClass"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160">
|
||||
<template #default="scope">{{ scope.row.site_name || TEXT.common.fallback }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="direction" :label="TEXT.common.fields.direction" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :class="['type-tag', scope.row.direction === 'SEND' ? 'type-send' : 'type-return']" effect="light">
|
||||
{{ displayEnum(TEXT.enums.shipmentDirection, scope.row.direction) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate" width="140">
|
||||
<template #default="scope">{{ displayDate(scope.row.ship_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="receive_date" :label="TEXT.common.fields.receiveDate" width="140">
|
||||
<template #default="scope">{{ displayDate(scope.row.receive_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quantity" :label="TEXT.common.fields.quantity" width="120" align="right">
|
||||
<template #default="scope">{{ typeof scope.row.quantity === "number" ? scope.row.quantity : TEXT.common.fallback }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="batch_no" :label="TEXT.common.fields.batchNo" min-width="140">
|
||||
<template #default="scope">{{ scope.row.batch_no || TEXT.common.fallback }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status" width="140">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusType(scope.row.status)" effect="plain" round size="small" class="status-tag">
|
||||
{{ displayEnum(TEXT.enums.shipmentStatus, scope.row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" :label="TEXT.common.fields.remark" min-width="160" show-overflow-tooltip>
|
||||
<template #default="scope">{{ scope.row.remark || TEXT.common.fallback }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120" align="center">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isInactiveSite(scope.row.center_id)"
|
||||
@click.stop="remove(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.drugShipments.empty" />
|
||||
<div class="unified-section table-section">
|
||||
<el-table
|
||||
:data="sortedItems"
|
||||
v-loading="loading"
|
||||
style="width: 100%"
|
||||
:row-class-name="shipmentRowClass"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160">
|
||||
<template #default="scope">{{ scope.row.site_name || TEXT.common.fallback }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="direction" :label="TEXT.common.fields.direction" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :class="['type-tag', scope.row.direction === 'SEND' ? 'type-send' : 'type-return']" effect="light">
|
||||
{{ displayEnum(TEXT.enums.shipmentDirection, scope.row.direction) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate" width="140">
|
||||
<template #default="scope">{{ displayDate(scope.row.ship_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="receive_date" :label="TEXT.common.fields.receiveDate" width="140">
|
||||
<template #default="scope">{{ displayDate(scope.row.receive_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quantity" :label="TEXT.common.fields.quantity" width="120" align="right">
|
||||
<template #default="scope">{{ typeof scope.row.quantity === "number" ? scope.row.quantity : TEXT.common.fallback }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="batch_no" :label="TEXT.common.fields.batchNo" min-width="140">
|
||||
<template #default="scope">{{ scope.row.batch_no || TEXT.common.fallback }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status" width="140">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusType(scope.row.status)" effect="plain" round size="small" class="status-tag">
|
||||
{{ displayEnum(TEXT.enums.shipmentStatus, scope.row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" :label="TEXT.common.fields.remark" min-width="160" show-overflow-tooltip>
|
||||
<template #default="scope">{{ scope.row.remark || TEXT.common.fallback }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120" align="center">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isInactiveSite(scope.row.center_id)"
|
||||
@click.stop="remove(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.drugShipments.empty" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -247,14 +249,6 @@ onMounted(async () => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
@@ -291,10 +285,9 @@ onMounted(async () => {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
.table-section {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<ModulePlaceholder
|
||||
:title="TEXT.modules.etmf.title"
|
||||
:subtitle="TEXT.modules.etmf.subtitle"
|
||||
:list-title="TEXT.modules.etmf.listTitle"
|
||||
:empty-description="TEXT.modules.etmf.emptyDescription"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
</script>
|
||||
@@ -1,8 +1,7 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
|
||||
<el-card class="filter-card" shadow="never">
|
||||
<div class="filter-row">
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<div class="filter-container unified-action-bar">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-input
|
||||
@@ -24,54 +23,53 @@
|
||||
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
|
||||
</el-form-item>
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="goNew" class="header-action-btn">
|
||||
<el-button type="primary" @click="goNew">
|
||||
{{ TEXT.modules.financeContracts.newTitle }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
<el-table
|
||||
:data="sortedItems"
|
||||
v-loading="loading"
|
||||
style="width: 100%"
|
||||
class="ctms-table"
|
||||
@row-click="onRowClick"
|
||||
:row-class-name="contractRowClass"
|
||||
>
|
||||
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
|
||||
<el-table-column prop="contract_no" :label="TEXT.common.fields.contractNo" min-width="160" />
|
||||
<el-table-column prop="signed_date" :label="TEXT.common.fields.signedDate" width="140">
|
||||
<template #default="scope">
|
||||
{{ displayDate(scope.row.signed_date) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.amount" width="160">
|
||||
<template #default="scope">
|
||||
{{ scope.row.amount }} {{ scope.row.currency || TEXT.common.fields.currencyDefault }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" width="180">
|
||||
<template #default="scope">
|
||||
{{ displayDateTime(scope.row.updated_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isInactiveSite(scope.row.site_name)"
|
||||
@click.stop="remove(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.financeContracts.empty" />
|
||||
<div class="unified-section table-section">
|
||||
<el-table
|
||||
:data="sortedItems"
|
||||
v-loading="loading"
|
||||
style="width: 100%"
|
||||
class="ctms-table"
|
||||
@row-click="onRowClick"
|
||||
:row-class-name="contractRowClass"
|
||||
>
|
||||
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
|
||||
<el-table-column prop="contract_no" :label="TEXT.common.fields.contractNo" min-width="160" />
|
||||
<el-table-column prop="signed_date" :label="TEXT.common.fields.signedDate" width="140">
|
||||
<template #default="scope">
|
||||
{{ displayDate(scope.row.signed_date) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.amount" width="160">
|
||||
<template #default="scope">
|
||||
{{ scope.row.amount }} {{ scope.row.currency || TEXT.common.fields.currencyDefault }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" width="180">
|
||||
<template #default="scope">
|
||||
{{ displayDateTime(scope.row.updated_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isInactiveSite(scope.row.site_name)"
|
||||
@click.stop="remove(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.financeContracts.empty" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -185,17 +183,6 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-card :deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
@@ -203,18 +190,6 @@ onMounted(async () => {
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
background: white;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-item-form {
|
||||
@@ -230,10 +205,9 @@ onMounted(async () => {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
.table-section {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
|
||||
<el-card class="filter-card" shadow="never">
|
||||
<div class="filter-row">
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<div class="filter-container unified-action-bar">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-input
|
||||
@@ -29,59 +28,58 @@
|
||||
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
|
||||
</el-form-item>
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="goNew" class="header-action-btn">
|
||||
<el-button type="primary" @click="goNew">
|
||||
{{ TEXT.modules.financeSpecials.newTitle }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
<el-table
|
||||
:data="sortedItems"
|
||||
v-loading="loading"
|
||||
style="width: 100%"
|
||||
class="ctms-table"
|
||||
@row-click="onRowClick"
|
||||
:row-class-name="specialRowClass"
|
||||
>
|
||||
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
|
||||
<el-table-column prop="fee_type" :label="TEXT.common.fields.type" width="120">
|
||||
<template #default="scope">
|
||||
{{ displayEnum(TEXT.enums.financeSpecialType, scope.row.fee_type) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="occur_date" :label="TEXT.common.fields.occurDate" width="140">
|
||||
<template #default="scope">
|
||||
{{ displayDate(scope.row.occur_date) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="staff_name" :label="TEXT.common.fields.staff" min-width="120" />
|
||||
<el-table-column :label="TEXT.common.fields.amount" width="140">
|
||||
<template #default="scope">
|
||||
{{ scope.row.amount }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" width="180">
|
||||
<template #default="scope">
|
||||
{{ displayDateTime(scope.row.updated_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isInactiveSite(scope.row.site_name)"
|
||||
@click.stop="remove(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.financeSpecials.empty" />
|
||||
<div class="unified-section table-section">
|
||||
<el-table
|
||||
:data="sortedItems"
|
||||
v-loading="loading"
|
||||
style="width: 100%"
|
||||
class="ctms-table"
|
||||
@row-click="onRowClick"
|
||||
:row-class-name="specialRowClass"
|
||||
>
|
||||
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
|
||||
<el-table-column prop="fee_type" :label="TEXT.common.fields.type" width="120">
|
||||
<template #default="scope">
|
||||
{{ displayEnum(TEXT.enums.financeSpecialType, scope.row.fee_type) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="occur_date" :label="TEXT.common.fields.occurDate" width="140">
|
||||
<template #default="scope">
|
||||
{{ displayDate(scope.row.occur_date) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="staff_name" :label="TEXT.common.fields.staff" min-width="120" />
|
||||
<el-table-column :label="TEXT.common.fields.amount" width="140">
|
||||
<template #default="scope">
|
||||
{{ scope.row.amount }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" width="180">
|
||||
<template #default="scope">
|
||||
{{ displayDateTime(scope.row.updated_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isInactiveSite(scope.row.site_name)"
|
||||
@click.stop="remove(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.financeSpecials.empty" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -194,17 +192,6 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-card :deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
@@ -212,18 +199,6 @@ onMounted(async () => {
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
background: white;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-item-form {
|
||||
@@ -243,10 +218,9 @@ onMounted(async () => {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
.table-section {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<div class="filter-container unified-action-bar">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-input
|
||||
@@ -24,40 +24,42 @@
|
||||
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
|
||||
</el-form-item>
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="goNew" class="header-action-btn">
|
||||
<el-button type="primary" @click="goNew">
|
||||
{{ TEXT.modules.knowledgeNotes.newTitle }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-table
|
||||
:data="sortedItems"
|
||||
v-loading="loading"
|
||||
style="width: 100%"
|
||||
class="ctms-table"
|
||||
@row-click="onRowClick"
|
||||
:row-class-name="noteRowClass"
|
||||
>
|
||||
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
|
||||
<el-table-column prop="title" :label="TEXT.common.fields.title" min-width="200" />
|
||||
<el-table-column prop="level" :label="TEXT.common.fields.level" width="120" />
|
||||
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" width="180">
|
||||
<template #default="scope">{{ displayDateTime(scope.row.updated_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isInactiveSite(scope.row.site_name)"
|
||||
@click.stop="remove(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.knowledgeNotes.empty" />
|
||||
<div class="unified-section table-section">
|
||||
<el-table
|
||||
:data="sortedItems"
|
||||
v-loading="loading"
|
||||
style="width: 100%"
|
||||
class="ctms-table"
|
||||
@row-click="onRowClick"
|
||||
:row-class-name="noteRowClass"
|
||||
>
|
||||
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
|
||||
<el-table-column prop="title" :label="TEXT.common.fields.title" min-width="200" />
|
||||
<el-table-column prop="level" :label="TEXT.common.fields.level" width="120" />
|
||||
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" width="180">
|
||||
<template #default="scope">{{ displayDateTime(scope.row.updated_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isInactiveSite(scope.row.site_name)"
|
||||
@click.stop="remove(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.knowledgeNotes.empty" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -173,14 +175,6 @@ onMounted(async () => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
@@ -201,10 +195,9 @@ onMounted(async () => {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
.table-section {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<ModulePlaceholder
|
||||
:title="TEXT.modules.materialEquipment.title"
|
||||
:subtitle="TEXT.modules.materialEquipment.subtitle"
|
||||
:list-title="TEXT.modules.materialEquipment.listTitle"
|
||||
:empty-description="TEXT.modules.materialEquipment.emptyDescription"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<ModulePlaceholder
|
||||
:title="TEXT.modules.materialOthers.title"
|
||||
:subtitle="TEXT.modules.materialOthers.subtitle"
|
||||
:list-title="TEXT.modules.materialOthers.listTitle"
|
||||
:empty-description="TEXT.modules.materialOthers.emptyDescription"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<ModulePlaceholder
|
||||
:title="TEXT.modules.monitoringAudit.title"
|
||||
:subtitle="TEXT.modules.monitoringAudit.subtitle"
|
||||
:list-title="TEXT.modules.monitoringAudit.listTitle"
|
||||
:empty-description="TEXT.modules.monitoringAudit.emptyDescription"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<ModulePlaceholder
|
||||
:title="TEXT.modules.projectMilestones.title"
|
||||
:subtitle="TEXT.modules.projectMilestones.subtitle"
|
||||
:list-title="TEXT.modules.projectMilestones.listTitle"
|
||||
:empty-description="TEXT.modules.projectMilestones.emptyDescription"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
</script>
|
||||
@@ -1,19 +1,13 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div v-if="study.currentStudy" class="page-body">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">项目概览</h1>
|
||||
</div>
|
||||
<div class="header-meta">
|
||||
<el-tag v-if="usingDemo" effect="plain" type="info" class="demo-tag">示例数据</el-tag>
|
||||
<div class="updated-at">更新:{{ nowLabel }}</div>
|
||||
<el-button size="small" @click="loadOverview">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="unified-shell">
|
||||
<section class="unified-section">
|
||||
<div class="overview-meta-inline">
|
||||
<el-tag v-if="usingDemo" effect="plain" type="info" class="demo-tag">示例数据</el-tag>
|
||||
<div class="updated-at">更新:{{ nowLabel }}</div>
|
||||
<el-button size="small" @click="loadOverview">刷新</el-button>
|
||||
</div>
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="card-title">中心整体进度</div>
|
||||
@@ -25,25 +19,22 @@
|
||||
<span class="legend-item"><span class="legend-dot blocked"></span>阻塞/延期</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<StateLoading v-if="loading" :rows="6" />
|
||||
<StateEmpty
|
||||
v-else-if="centers.length === 0"
|
||||
title="暂无中心进度"
|
||||
description="当前项目未配置中心或暂无进度数据"
|
||||
/>
|
||||
<div v-else class="progress-list">
|
||||
<CenterProgressRow
|
||||
v-for="(center, index) in centers"
|
||||
:key="center.center_id || center.center_name || index"
|
||||
:center="center"
|
||||
<StateLoading v-if="loading" :rows="6" />
|
||||
<StateEmpty
|
||||
v-else-if="centers.length === 0"
|
||||
title="暂无中心进度"
|
||||
description="当前项目未配置中心或暂无进度数据"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
<div v-else class="progress-list">
|
||||
<CenterProgressRow
|
||||
v-for="(center, index) in centers"
|
||||
:key="center.center_id || center.center_name || index"
|
||||
:center="center"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-card>
|
||||
<template #header>
|
||||
<section class="unified-section">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="card-title">入组进度</div>
|
||||
@@ -56,14 +47,14 @@
|
||||
<el-radio-button label="month">按月份</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
<EnrollmentBarChart
|
||||
:mode="chartMode"
|
||||
:items="chartItems"
|
||||
:loading="loading"
|
||||
:empty-text="chartEmptyText"
|
||||
/>
|
||||
</el-card>
|
||||
<EnrollmentBarChart
|
||||
:mode="chartMode"
|
||||
:items="chartItems"
|
||||
:loading="loading"
|
||||
:empty-text="chartEmptyText"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
|
||||
</div>
|
||||
@@ -198,55 +189,13 @@ watch(
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.page-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.study-meta {
|
||||
margin: 6px 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.study-divider {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--ctms-text-secondary);
|
||||
font-size: 12px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.demo-tag {
|
||||
@@ -258,6 +207,14 @@ watch(
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overview-meta-inline {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -268,8 +225,8 @@ watch(
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ctms-text-main);
|
||||
font-weight: 700;
|
||||
color: #0f2345;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
@@ -322,7 +279,7 @@ watch(
|
||||
.progress-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.mode-switch :deep(.el-radio-button__inner) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<ModulePlaceholder
|
||||
:title="TEXT.modules.riskIssueMonitoringVisits.title"
|
||||
:subtitle="TEXT.modules.riskIssueMonitoringVisits.subtitle"
|
||||
:list-title="TEXT.modules.riskIssueMonitoringVisits.listTitle"
|
||||
:empty-description="TEXT.modules.riskIssueMonitoringVisits.emptyDescription"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
</script>
|
||||
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<div class="filter-container unified-action-bar">
|
||||
<el-form :inline="true" class="filter-form">
|
||||
<el-form-item class="filter-item-form">
|
||||
<el-input v-model="filters.keyword" :placeholder="TEXT.modules.subjectManagement.screeningNo" clearable class="filter-input-comp">
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item class="filter-item-form">
|
||||
<el-select v-model="filters.siteId" :placeholder="TEXT.common.fields.site" clearable filterable class="filter-select-comp">
|
||||
<template #prefix>
|
||||
<el-icon><Location /></el-icon>
|
||||
</template>
|
||||
<el-option :label="TEXT.common.labels.all" value="" />
|
||||
<el-option v-for="site in siteOptions" :key="site.id" :label="site.name" :value="site.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item class="filter-item-form">
|
||||
<el-select v-model="filters.status" :placeholder="TEXT.common.fields.status" clearable class="filter-select-comp">
|
||||
<template #prefix>
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
</template>
|
||||
<el-option :label="TEXT.common.labels.all" value="" />
|
||||
<el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item class="filter-actions-comp">
|
||||
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="unified-section table-section">
|
||||
<el-table :data="filteredItems" v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" min-width="160" />
|
||||
<el-table-column :label="TEXT.common.fields.site" min-width="160">
|
||||
<template #default="scope">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
|
||||
<template #default="scope">{{ displayEnum(TEXT.enums.subjectStatus, scope.row.status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="enrollment_date" :label="TEXT.common.fields.enrollmentDate" width="140">
|
||||
<template #default="scope">{{ displayDate(scope.row.enrollment_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" min-width="180">
|
||||
<template #default="scope">{{ displayDate(scope.row.updated_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.riskIssuePd.syncStatus" width="120">
|
||||
<template #default>
|
||||
<el-tag size="small" type="success" effect="plain">{{ TEXT.modules.riskIssuePd.synced }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" size="small" @click="goSubject(scope.row.id)">
|
||||
{{ TEXT.modules.riskIssuePd.toSubject }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loading && filteredItems.length === 0" :description="TEXT.modules.riskIssuePd.emptyDescription" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { CircleCheck, Location, Search } from "@element-plus/icons-vue";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { fetchSubjects } from "../../api/subjects";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import { displayDate, displayEnum } from "../../utils/display";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
const router = useRouter();
|
||||
const study = useStudyStore();
|
||||
const loading = ref(false);
|
||||
const items = ref<any[]>([]);
|
||||
const siteOptions = ref<Array<{ id: string; name: string }>>([]);
|
||||
const siteMap = ref<Record<string, string>>({});
|
||||
const filters = ref({
|
||||
keyword: "",
|
||||
siteId: study.currentSite?.id || "",
|
||||
status: "",
|
||||
});
|
||||
|
||||
const statusOptions = Object.entries(TEXT.enums.subjectStatus).map(([value, label]) => ({ value, label }));
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.value.keyword = "";
|
||||
filters.value.siteId = "";
|
||||
filters.value.status = "";
|
||||
};
|
||||
|
||||
const loadSites = async (studyId: string) => {
|
||||
try {
|
||||
const { data } = await fetchSites(studyId, { limit: 500 });
|
||||
const list = Array.isArray(data) ? data : data.items || [];
|
||||
siteOptions.value = list.map((site: any) => ({ id: site.id, name: site.name }));
|
||||
siteMap.value = list.reduce((acc: Record<string, string>, site: any) => {
|
||||
acc[site.id] = site.name;
|
||||
return acc;
|
||||
}, {});
|
||||
} catch {
|
||||
siteOptions.value = [];
|
||||
siteMap.value = {};
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
await loadSites(studyId);
|
||||
const { data } = await fetchSubjects(studyId);
|
||||
items.value = (Array.isArray(data) ? data : data.items || []).sort((a: any, b: any) =>
|
||||
String(b.updated_at || "").localeCompare(String(a.updated_at || ""))
|
||||
);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const keyword = filters.value.keyword.trim().toLowerCase();
|
||||
return items.value.filter((item) => {
|
||||
const subjectNo = String(item?.subject_no || "").toLowerCase();
|
||||
if (keyword && !subjectNo.includes(keyword)) return false;
|
||||
if (filters.value.siteId && item?.site_id !== filters.value.siteId) return false;
|
||||
if (filters.value.status && item?.status !== filters.value.status) return false;
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
const goSubject = (subjectId: string) => {
|
||||
if (!subjectId) return;
|
||||
router.push({ path: `/subjects/${subjectId}`, query: { tab: "pd" } });
|
||||
};
|
||||
|
||||
watch(
|
||||
() => study.currentSite,
|
||||
(newSite) => {
|
||||
filters.value.siteId = newSite?.id || "";
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.filter-item-form {
|
||||
margin-bottom: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.filter-input-comp {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.filter-select-comp {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.filter-actions-comp {
|
||||
margin-bottom: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<div class="filter-container unified-action-bar">
|
||||
<el-form :inline="true" class="filter-form">
|
||||
<el-form-item class="filter-item-form">
|
||||
<el-input v-model="filters.keyword" :placeholder="TEXT.common.fields.keyword" clearable class="filter-input-comp">
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item class="filter-item-form">
|
||||
<el-select v-model="filters.siteId" :placeholder="TEXT.common.fields.site" clearable filterable class="filter-select-comp">
|
||||
<template #prefix>
|
||||
<el-icon><Location /></el-icon>
|
||||
</template>
|
||||
<el-option :label="TEXT.common.labels.all" value="" />
|
||||
<el-option v-for="site in siteOptions" :key="site.id" :label="site.name" :value="site.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item class="filter-item-form">
|
||||
<el-select v-model="filters.status" :placeholder="TEXT.common.fields.status" clearable class="filter-select-comp">
|
||||
<template #prefix>
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
</template>
|
||||
<el-option :label="TEXT.common.labels.all" value="" />
|
||||
<el-option v-for="option in aeStatusOptions" :key="option.value" :label="option.label" :value="option.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item class="filter-item-form">
|
||||
<el-select v-model="filters.seriousness" :placeholder="TEXT.common.fields.seriousness" clearable class="filter-select-comp">
|
||||
<template #prefix>
|
||||
<el-icon><Warning /></el-icon>
|
||||
</template>
|
||||
<el-option :label="TEXT.common.labels.all" value="" />
|
||||
<el-option v-for="option in aeSeriousnessOptions" :key="option.value" :label="option.label" :value="option.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item class="filter-item-form">
|
||||
<el-select v-model="filters.aeType" :placeholder="TEXT.common.fields.aeType" clearable class="filter-select-comp">
|
||||
<template #prefix>
|
||||
<el-icon><Warning /></el-icon>
|
||||
</template>
|
||||
<el-option :label="TEXT.common.labels.all" value="" />
|
||||
<el-option v-for="option in aeTypeOptions" :key="option.value" :label="option.label" :value="option.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item class="filter-actions-comp">
|
||||
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="unified-section table-section">
|
||||
<el-table :data="filteredItems" v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="onset_date" :label="TEXT.common.fields.occurDate" width="140">
|
||||
<template #default="scope">{{ displayDate(scope.row.onset_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="subject_no" :label="TEXT.modules.riskIssueSae.subjectNo" min-width="140">
|
||||
<template #default="scope">{{ subjectMap[scope.row.subject_id] || TEXT.common.fallback }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.site" min-width="140">
|
||||
<template #default="scope">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="term" :label="TEXT.common.fields.title" min-width="180" />
|
||||
<el-table-column prop="seriousness" :label="TEXT.common.fields.seriousness" width="120">
|
||||
<template #default="scope">{{ displayEnum(TEXT.enums.aeSeriousness, scope.row.seriousness) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
|
||||
<template #default="scope">{{ displayEnum(TEXT.enums.aeStatus, scope.row.status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" min-width="180">
|
||||
<template #default="scope">{{ displayDate(scope.row.updated_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" size="small" :disabled="!scope.row.subject_id" @click="goSubject(scope.row.subject_id, 'ae')">
|
||||
{{ TEXT.modules.riskIssueSae.toSubject }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loading && filteredItems.length === 0" :description="TEXT.modules.riskIssueSae.emptyDescription" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { CircleCheck, Location, Search, Warning } from "@element-plus/icons-vue";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { fetchAes } from "../../api/aes";
|
||||
import { fetchSubjects } from "../../api/subjects";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import { displayDate, displayEnum } from "../../utils/display";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
const router = useRouter();
|
||||
const study = useStudyStore();
|
||||
const loading = ref(false);
|
||||
const items = ref<any[]>([]);
|
||||
const siteOptions = ref<Array<{ id: string; name: string }>>([]);
|
||||
const siteMap = ref<Record<string, string>>({});
|
||||
const subjectMap = ref<Record<string, string>>({});
|
||||
const subjectSiteMap = ref<Record<string, string>>({});
|
||||
const filters = ref({
|
||||
keyword: "",
|
||||
siteId: study.currentSite?.id || "",
|
||||
status: "",
|
||||
seriousness: "",
|
||||
aeType: "",
|
||||
});
|
||||
|
||||
const aeStatusOptions = Object.entries(TEXT.enums.aeStatus).map(([value, label]) => ({ value, label }));
|
||||
const aeSeriousnessOptions = Object.entries(TEXT.enums.aeSeriousness).map(([value, label]) => ({ value, label }));
|
||||
const aeTypeOptions = [
|
||||
{ value: "ae", label: TEXT.modules.subjectDetail.aeType.ae },
|
||||
{ value: "sae", label: TEXT.modules.subjectDetail.aeType.sae },
|
||||
{ value: "susar", label: TEXT.modules.subjectDetail.aeType.susar },
|
||||
];
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.value.keyword = "";
|
||||
filters.value.siteId = "";
|
||||
filters.value.status = "";
|
||||
filters.value.seriousness = "";
|
||||
filters.value.aeType = "";
|
||||
};
|
||||
|
||||
const loadSites = async (studyId: string) => {
|
||||
try {
|
||||
const { data } = await fetchSites(studyId, { limit: 500 });
|
||||
const list = Array.isArray(data) ? data : data.items || [];
|
||||
siteOptions.value = list.map((site: any) => ({ id: site.id, name: site.name }));
|
||||
siteMap.value = list.reduce((acc: Record<string, string>, site: any) => {
|
||||
acc[site.id] = site.name;
|
||||
return acc;
|
||||
}, {});
|
||||
} catch {
|
||||
siteOptions.value = [];
|
||||
siteMap.value = {};
|
||||
}
|
||||
};
|
||||
|
||||
const loadSubjects = async (studyId: string) => {
|
||||
const { data } = await fetchSubjects(studyId);
|
||||
const list = Array.isArray(data) ? data : data.items || [];
|
||||
subjectMap.value = list.reduce((acc: Record<string, string>, item: any) => {
|
||||
acc[item.id] = item.subject_no;
|
||||
return acc;
|
||||
}, {});
|
||||
subjectSiteMap.value = list.reduce((acc: Record<string, string>, item: any) => {
|
||||
acc[item.id] = item.site_id;
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
await Promise.all([loadSites(studyId), loadSubjects(studyId)]);
|
||||
const { data } = await fetchAes(studyId);
|
||||
items.value = (Array.isArray(data) ? data : data.items || []).sort((a: any, b: any) =>
|
||||
String(b.updated_at || "").localeCompare(String(a.updated_at || ""))
|
||||
);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const keyword = filters.value.keyword.trim().toLowerCase();
|
||||
return items.value.filter((item) => {
|
||||
const subjectId = item?.subject_id ? String(item.subject_id) : "";
|
||||
const siteId = item?.site_id || subjectSiteMap.value[subjectId] || "";
|
||||
const subjectNo = String(subjectMap.value[subjectId] || "").toLowerCase();
|
||||
const term = String(item?.term || "").toLowerCase();
|
||||
if (keyword && !subjectNo.includes(keyword) && !term.includes(keyword)) return false;
|
||||
if (filters.value.siteId && String(siteId) !== filters.value.siteId) return false;
|
||||
if (filters.value.status && item?.status !== filters.value.status) return false;
|
||||
if (filters.value.seriousness && item?.seriousness !== filters.value.seriousness) return false;
|
||||
if (filters.value.aeType && getAeType(item) !== filters.value.aeType) return false;
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
const getAeType = (item: any) => {
|
||||
if (item?.is_susar) return "susar";
|
||||
if (item?.is_sae) return "sae";
|
||||
return "ae";
|
||||
};
|
||||
|
||||
const goSubject = (subjectId: string, tab: "ae" | "pd") => {
|
||||
if (!subjectId) return;
|
||||
router.push({ path: `/subjects/${subjectId}`, query: { tab } });
|
||||
};
|
||||
|
||||
watch(
|
||||
() => study.currentSite,
|
||||
(newSite) => {
|
||||
filters.value.siteId = newSite?.id || "";
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.filter-item-form {
|
||||
margin-bottom: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.filter-input-comp {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.filter-select-comp {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.filter-actions-comp {
|
||||
margin-bottom: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<ModulePlaceholder
|
||||
:title="TEXT.modules.riskIssues.title"
|
||||
:subtitle="TEXT.modules.riskIssues.subtitle"
|
||||
:list-title="TEXT.modules.riskIssues.listTitle"
|
||||
:empty-description="TEXT.modules.riskIssues.emptyDescription"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
</script>
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="ctms-page">
|
||||
|
||||
<el-card class="ctms-table-card">
|
||||
<div class="page">
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<el-tabs v-model="activeTab" class="ctms-tabs">
|
||||
<el-tab-pane :label="TEXT.modules.startupFeasibilityEthics.feasibilityTab" name="feasibility">
|
||||
<div class="ctms-tab-content">
|
||||
@@ -306,14 +305,14 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ctms-page {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.ctms-tab-content {
|
||||
padding: 16px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
|
||||
<el-card>
|
||||
<div class="unified-shell">
|
||||
<section class="unified-section">
|
||||
<el-table
|
||||
:data="kickoffRows"
|
||||
v-loading="loading"
|
||||
@@ -26,7 +26,8 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loading && kickoffRows.length === 0" :description="TEXT.modules.startupMeetingAuth.emptyKickoff" />
|
||||
</el-card>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -162,25 +163,7 @@ onMounted(() => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<el-card shadow="never" class="main-content-card unified-shell">
|
||||
<div class="filter-container unified-action-bar">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-input
|
||||
@@ -55,61 +54,69 @@
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-table
|
||||
:data="pagedItems"
|
||||
v-loading="loading"
|
||||
style="width: 100%"
|
||||
class="ctms-table"
|
||||
:row-class-name="subjectRowClass"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" min-width="160">
|
||||
<template #default="scope">
|
||||
<div class="subject-info-cell">
|
||||
<span class="subject-no">{{ scope.row.subject_no || TEXT.common.fallback }}</span>
|
||||
<span v-if="scope.row.has_ae" class="ae-badge">AE</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.site" min-width="160">
|
||||
<template #default="scope">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="consent_date" :label="TEXT.common.fields.consentDate" width="140">
|
||||
<template #default="scope">{{ displayDate(scope.row.consent_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
|
||||
<template #default="scope">{{ displayEnum(TEXT.enums.subjectStatus, scope.row.status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="enrollment_date" :label="TEXT.common.fields.enrollmentDate" width="140">
|
||||
<template #default="scope">{{ displayDate(scope.row.enrollment_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="completion_date" :label="TEXT.common.fields.completionDate" width="140">
|
||||
<template #default="scope">{{ displayDate(scope.row.completion_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isInactiveSite(scope.row.site_id)"
|
||||
@click.stop="remove(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.currentPage"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="filteredItems.length"
|
||||
layout="total, prev, pager, next, sizes"
|
||||
/>
|
||||
<div class="unified-section table-section">
|
||||
<el-table
|
||||
:data="pagedItems"
|
||||
v-loading="loading"
|
||||
style="width: 100%"
|
||||
class="ctms-table"
|
||||
:row-class-name="subjectRowClass"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" min-width="160">
|
||||
<template #default="scope">
|
||||
<div class="subject-info-cell">
|
||||
<span class="subject-no">{{ scope.row.subject_no || TEXT.common.fallback }}</span>
|
||||
<span
|
||||
v-for="badge in getAeBadges(scope.row)"
|
||||
:key="badge.type"
|
||||
:class="['ae-badge', `ae-badge-${badge.type}`]"
|
||||
>
|
||||
{{ badge.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.site" min-width="160">
|
||||
<template #default="scope">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="consent_date" :label="TEXT.common.fields.consentDate" width="140">
|
||||
<template #default="scope">{{ displayDate(scope.row.consent_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
|
||||
<template #default="scope">{{ displayEnum(TEXT.enums.subjectStatus, scope.row.status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="enrollment_date" :label="TEXT.common.fields.enrollmentDate" width="140">
|
||||
<template #default="scope">{{ displayDate(scope.row.enrollment_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="completion_date" :label="TEXT.common.fields.completionDate" width="140">
|
||||
<template #default="scope">{{ displayDate(scope.row.completion_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isInactiveSite(scope.row.site_id)"
|
||||
@click.stop="remove(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.currentPage"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="filteredItems.length"
|
||||
layout="total, prev, pager, next, sizes"
|
||||
/>
|
||||
</div>
|
||||
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.subjectManagement.empty" />
|
||||
</div>
|
||||
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.subjectManagement.empty" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -208,6 +215,15 @@ const remove = async (row: any) => {
|
||||
};
|
||||
|
||||
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
|
||||
const getAeBadges = (row: any) => {
|
||||
const badges: Array<{ type: "ae" | "sae" | "susar"; label: string }> = [];
|
||||
if (row?.has_sae) badges.push({ type: "sae", label: TEXT.modules.subjectDetail.aeType.sae });
|
||||
if (row?.has_susar) badges.push({ type: "susar", label: TEXT.modules.subjectDetail.aeType.susar });
|
||||
if (!row?.has_sae && !row?.has_susar && row?.has_ae) {
|
||||
badges.push({ type: "ae", label: TEXT.modules.subjectDetail.aeType.ae });
|
||||
}
|
||||
return badges;
|
||||
};
|
||||
const filteredItems = computed(() => {
|
||||
const keyword = filters.value.keyword.trim().toLowerCase();
|
||||
return items.value.filter((item) => {
|
||||
@@ -261,14 +277,6 @@ onMounted(async () => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
@@ -311,9 +319,8 @@ onMounted(async () => {
|
||||
.ae-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, #fff1f2 0%, #ffe4e6 100%);
|
||||
color: #be123c;
|
||||
border: 1px solid rgba(225, 29, 72, 0.2);
|
||||
color: #ffffff;
|
||||
border: 1px solid transparent;
|
||||
padding: 2px 6px;
|
||||
height: 18px;
|
||||
line-height: normal;
|
||||
@@ -322,7 +329,7 @@ onMounted(async () => {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
box-shadow: 0 1px 2px rgba(225, 29, 72, 0.05);
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.ae-badge::before {
|
||||
@@ -330,29 +337,28 @@ onMounted(async () => {
|
||||
display: inline-block;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background-color: #e11d48;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.ae-badge-ae {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.ae-badge-sae {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.ae-badge-susar {
|
||||
background: #8b5cf6;
|
||||
}
|
||||
|
||||
.table-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div class="page-header unified-action-bar">
|
||||
<div>
|
||||
<h1 class="page-title">{{ TEXT.modules.knowledgeNotes.detailTitle }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.knowledgeNotes.detailSubtitle }}</p>
|
||||
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card v-loading="loading">
|
||||
<el-card class="unified-shell" v-loading="loading">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item :label="TEXT.common.fields.site">{{ detail.site_name || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.level">{{ detail.level || TEXT.common.fallback }}</el-descriptions-item>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div class="page-header unified-action-bar">
|
||||
<div>
|
||||
<h1 class="page-title">{{ isEdit ? TEXT.modules.knowledgeNotes.editTitle : TEXT.modules.knowledgeNotes.newTitle }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.knowledgeNotes.formSubtitle }}</p>
|
||||
@@ -8,7 +8,7 @@
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-card>
|
||||
<el-card class="unified-shell">
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item :label="TEXT.common.fields.site" required>
|
||||
<el-input v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
|
||||
@@ -36,7 +36,7 @@
|
||||
:entity-id="noteId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
<el-card v-else class="hint-card">
|
||||
<el-card v-else class="hint-card unified-shell">
|
||||
<StateEmpty :description="TEXT.modules.knowledgeNotes.uploadHint" />
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
@@ -1,42 +1,41 @@
|
||||
<template>
|
||||
<div class="ctms-page">
|
||||
<div class="ctms-page-header">
|
||||
<div>
|
||||
<h1 class="ctms-page-title">{{ TEXT.modules.startupFeasibilityEthics.ethicsDetailTitle }}</h1>
|
||||
<p class="ctms-page-subtitle">{{ TEXT.modules.startupFeasibilityEthics.ethicsDetailSubtitle }}</p>
|
||||
</div>
|
||||
<div class="ctms-page-actions">
|
||||
<el-button type="primary" :disabled="isInactiveSite(detail.site_id)" @click="goEdit">
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button @click="goBack">
|
||||
<el-icon class="el-icon--left"><ArrowLeft /></el-icon>
|
||||
{{ TEXT.common.actions.back }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="page">
|
||||
<div class="unified-shell" v-loading="loading">
|
||||
<section class="unified-section">
|
||||
<div class="unified-section-header">
|
||||
<div class="ctms-section-title">{{ TEXT.modules.startupFeasibilityEthics.ethicsDetailTitle }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button type="primary" :disabled="isInactiveSite(detail.site_id)" @click="goEdit">
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button @click="goBack">
|
||||
<el-icon class="el-icon--left"><ArrowLeft /></el-icon>
|
||||
{{ TEXT.common.actions.back }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-descriptions :column="2" border class="ctms-descriptions">
|
||||
<el-descriptions-item :label="TEXT.common.fields.site">{{ displaySite(detail.site_id) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.approvalNo">{{ detail.approval_no || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.submitDate">{{ displayDate(detail.submit_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.acceptDate">{{ displayDate(detail.accept_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.meetingDate">{{ displayDate(detail.meeting_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.approvedDate">{{ displayDate(detail.approved_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.status">
|
||||
<el-tag :type="statusTagType(status)">{{ statusLabel(status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</section>
|
||||
<section class="unified-section">
|
||||
<AttachmentList
|
||||
v-if="recordId"
|
||||
:study-id="studyId"
|
||||
entity-type="startup_ethics"
|
||||
:entity-id="recordId"
|
||||
:readonly="isInactiveSite(detail.site_id)"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<el-card class="ctms-table-card" v-loading="loading">
|
||||
<el-descriptions :column="2" border class="ctms-descriptions">
|
||||
<el-descriptions-item :label="TEXT.common.fields.site">{{ displaySite(detail.site_id) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.approvalNo">{{ detail.approval_no || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.submitDate">{{ displayDate(detail.submit_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.acceptDate">{{ displayDate(detail.accept_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.meetingDate">{{ displayDate(detail.meeting_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.approvedDate">{{ displayDate(detail.approved_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.status">
|
||||
<el-tag :type="statusTagType(status)">{{ statusLabel(status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<AttachmentList
|
||||
v-if="recordId"
|
||||
:study-id="studyId"
|
||||
entity-type="startup_ethics"
|
||||
:entity-id="recordId"
|
||||
:readonly="isInactiveSite(detail.site_id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -158,5 +157,9 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Scoped styles can be minimized as we rely on global classes */
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,107 +1,104 @@
|
||||
<template>
|
||||
<div class="ctms-page">
|
||||
<div class="ctms-page-header">
|
||||
<div>
|
||||
<h1 class="ctms-page-title">{{ isEdit ? TEXT.modules.startupFeasibilityEthics.ethicsEditTitle : TEXT.modules.startupFeasibilityEthics.ethicsNewTitle }}</h1>
|
||||
<p class="ctms-page-subtitle">{{ TEXT.modules.startupFeasibilityEthics.ethicsFormSubtitle }}</p>
|
||||
</div>
|
||||
<div class="ctms-page-actions">
|
||||
<el-button @click="goBack">
|
||||
<el-icon class="el-icon--left"><ArrowLeft /></el-icon>
|
||||
{{ TEXT.common.actions.back }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="ctms-form-card">
|
||||
<el-form :model="form" label-width="140px" class="ctms-form" label-position="top">
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.site">
|
||||
<el-select v-model="form.site_id" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select" clearable style="width: 100%">
|
||||
<el-option v-for="site in siteOptions" :key="site.value" :label="site.label" :value="site.value" :disabled="site.disabled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.approvalNo">
|
||||
<el-input v-model="form.approval_no" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.approvalNo" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="6">
|
||||
<el-form-item :label="TEXT.common.fields.submitDate">
|
||||
<el-date-picker
|
||||
v-model="form.submit_date"
|
||||
:disabled="isReadOnly"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item :label="TEXT.common.fields.acceptDate">
|
||||
<el-date-picker
|
||||
v-model="form.accept_date"
|
||||
:disabled="isReadOnly"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item :label="TEXT.common.fields.meetingDate">
|
||||
<el-date-picker
|
||||
v-model="form.meeting_date"
|
||||
:disabled="isReadOnly"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item :label="TEXT.common.fields.approvedDate">
|
||||
<el-date-picker
|
||||
v-model="form.approved_date"
|
||||
:disabled="isReadOnly"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item class="form-actions">
|
||||
<el-button type="primary" :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>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<div class="ctms-section-card" v-if="isEdit && recordId">
|
||||
<div class="ctms-section-header">
|
||||
<div class="ctms-section-title">{{ TEXT.common.labels.attachments }}</div>
|
||||
<div class="page">
|
||||
<div class="unified-shell">
|
||||
<section class="unified-section">
|
||||
<div class="unified-section-header">
|
||||
<div class="ctms-section-title">{{ isEdit ? TEXT.modules.startupFeasibilityEthics.ethicsEditTitle : TEXT.modules.startupFeasibilityEthics.ethicsNewTitle }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button @click="goBack">
|
||||
<el-icon class="el-icon--left"><ArrowLeft /></el-icon>
|
||||
{{ TEXT.common.actions.back }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<AttachmentList
|
||||
:study-id="studyId"
|
||||
entity-type="startup_ethics"
|
||||
:entity-id="recordId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
<el-form :model="form" label-width="140px" class="ctms-form" label-position="top">
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.site">
|
||||
<el-select v-model="form.site_id" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select" clearable style="width: 100%">
|
||||
<el-option v-for="site in siteOptions" :key="site.value" :label="site.label" :value="site.value" :disabled="site.disabled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.approvalNo">
|
||||
<el-input v-model="form.approval_no" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.approvalNo" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="6">
|
||||
<el-form-item :label="TEXT.common.fields.submitDate">
|
||||
<el-date-picker
|
||||
v-model="form.submit_date"
|
||||
:disabled="isReadOnly"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item :label="TEXT.common.fields.acceptDate">
|
||||
<el-date-picker
|
||||
v-model="form.accept_date"
|
||||
:disabled="isReadOnly"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item :label="TEXT.common.fields.meetingDate">
|
||||
<el-date-picker
|
||||
v-model="form.meeting_date"
|
||||
:disabled="isReadOnly"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item :label="TEXT.common.fields.approvedDate">
|
||||
<el-date-picker
|
||||
v-model="form.approved_date"
|
||||
:disabled="isReadOnly"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item class="form-actions">
|
||||
<el-button type="primary" :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>
|
||||
</el-form>
|
||||
</section>
|
||||
<section class="unified-section">
|
||||
<template v-if="isEdit && recordId">
|
||||
<div class="ctms-section-header">
|
||||
<div class="ctms-section-title">{{ TEXT.common.labels.attachments }}</div>
|
||||
</div>
|
||||
<AttachmentList
|
||||
:study-id="studyId"
|
||||
entity-type="startup_ethics"
|
||||
:entity-id="recordId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
</template>
|
||||
<StateEmpty v-else :description="TEXT.modules.startupFeasibilityEthics.ethicsUploadHint" />
|
||||
</section>
|
||||
</div>
|
||||
<el-card v-else class="hint-card">
|
||||
<StateEmpty :description="TEXT.modules.startupFeasibilityEthics.ethicsUploadHint" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -221,10 +218,13 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 24px;
|
||||
}
|
||||
.hint-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,41 +1,40 @@
|
||||
<template>
|
||||
<div class="ctms-page">
|
||||
<div class="ctms-page-header">
|
||||
<div>
|
||||
<h1 class="ctms-page-title">{{ TEXT.modules.startupFeasibilityEthics.feasibilityDetailTitle }}</h1>
|
||||
<p class="ctms-page-subtitle">{{ TEXT.modules.startupFeasibilityEthics.feasibilityDetailSubtitle }}</p>
|
||||
</div>
|
||||
<div class="ctms-page-actions">
|
||||
<el-button type="primary" :disabled="isInactiveSite(detail.site_id)" @click="goEdit">
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button @click="goBack">
|
||||
<el-icon class="el-icon--left"><ArrowLeft /></el-icon>
|
||||
{{ TEXT.common.actions.back }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="page">
|
||||
<div class="unified-shell" v-loading="loading">
|
||||
<section class="unified-section">
|
||||
<div class="unified-section-header">
|
||||
<div class="ctms-section-title">{{ TEXT.modules.startupFeasibilityEthics.feasibilityDetailTitle }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button type="primary" :disabled="isInactiveSite(detail.site_id)" @click="goEdit">
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button @click="goBack">
|
||||
<el-icon class="el-icon--left"><ArrowLeft /></el-icon>
|
||||
{{ TEXT.common.actions.back }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-descriptions :column="2" border class="ctms-descriptions">
|
||||
<el-descriptions-item :label="TEXT.common.fields.site">{{ displaySite(detail.site_id) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.projectNo">{{ detail.project_no || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.submitDate">{{ displayDate(detail.submit_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.acceptDate">{{ displayDate(detail.accept_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.approvedDate">{{ displayDate(detail.approved_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.status">
|
||||
<el-tag :type="statusTagType(status)">{{ statusLabel(status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</section>
|
||||
<section class="unified-section">
|
||||
<AttachmentList
|
||||
v-if="recordId"
|
||||
:study-id="studyId"
|
||||
entity-type="startup_feasibility"
|
||||
:entity-id="recordId"
|
||||
:readonly="isInactiveSite(detail.site_id)"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<el-card class="ctms-table-card" v-loading="loading">
|
||||
<el-descriptions :column="2" border class="ctms-descriptions">
|
||||
<el-descriptions-item :label="TEXT.common.fields.site">{{ displaySite(detail.site_id) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.projectNo">{{ detail.project_no || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.submitDate">{{ displayDate(detail.submit_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.acceptDate">{{ displayDate(detail.accept_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.approvedDate">{{ displayDate(detail.approved_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.status">
|
||||
<el-tag :type="statusTagType(status)">{{ statusLabel(status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<AttachmentList
|
||||
v-if="recordId"
|
||||
:study-id="studyId"
|
||||
entity-type="startup_feasibility"
|
||||
:entity-id="recordId"
|
||||
:readonly="isInactiveSite(detail.site_id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -156,5 +155,9 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Scoped styles can be minimized as we rely on global classes */
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,95 +1,92 @@
|
||||
<template>
|
||||
<div class="ctms-page">
|
||||
<div class="ctms-page-header">
|
||||
<div>
|
||||
<h1 class="ctms-page-title">{{ isEdit ? TEXT.modules.startupFeasibilityEthics.feasibilityEditTitle : TEXT.modules.startupFeasibilityEthics.feasibilityNewTitle }}</h1>
|
||||
<p class="ctms-page-subtitle">{{ TEXT.modules.startupFeasibilityEthics.feasibilityFormSubtitle }}</p>
|
||||
</div>
|
||||
<div class="ctms-page-actions">
|
||||
<el-button @click="goBack">
|
||||
<el-icon class="el-icon--left"><ArrowLeft /></el-icon>
|
||||
{{ TEXT.common.actions.back }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="ctms-form-card">
|
||||
<el-form :model="form" label-width="140px" class="ctms-form" label-position="top">
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.site">
|
||||
<el-select v-model="form.site_id" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select" clearable style="width: 100%">
|
||||
<el-option v-for="site in siteOptions" :key="site.value" :label="site.label" :value="site.value" :disabled="site.disabled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.projectNo">
|
||||
<el-input v-model="form.project_no" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectNo" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="8">
|
||||
<el-form-item :label="TEXT.common.fields.submitDate">
|
||||
<el-date-picker
|
||||
v-model="form.submit_date"
|
||||
:disabled="isReadOnly"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item :label="TEXT.common.fields.acceptDate">
|
||||
<el-date-picker
|
||||
v-model="form.accept_date"
|
||||
:disabled="isReadOnly"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item :label="TEXT.common.fields.approvedDate">
|
||||
<el-date-picker
|
||||
v-model="form.approved_date"
|
||||
:disabled="isReadOnly"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item class="form-actions">
|
||||
<el-button type="primary" :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>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<div class="ctms-section-card" v-if="isEdit && recordId">
|
||||
<div class="ctms-section-header">
|
||||
<div class="ctms-section-title">{{ TEXT.common.labels.attachments }}</div>
|
||||
<div class="page">
|
||||
<div class="unified-shell">
|
||||
<section class="unified-section">
|
||||
<div class="unified-section-header">
|
||||
<div class="ctms-section-title">{{ isEdit ? TEXT.modules.startupFeasibilityEthics.feasibilityEditTitle : TEXT.modules.startupFeasibilityEthics.feasibilityNewTitle }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button @click="goBack">
|
||||
<el-icon class="el-icon--left"><ArrowLeft /></el-icon>
|
||||
{{ TEXT.common.actions.back }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<AttachmentList
|
||||
:study-id="studyId"
|
||||
entity-type="startup_feasibility"
|
||||
:entity-id="recordId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
<el-form :model="form" label-width="140px" class="ctms-form" label-position="top">
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.site">
|
||||
<el-select v-model="form.site_id" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select" clearable style="width: 100%">
|
||||
<el-option v-for="site in siteOptions" :key="site.value" :label="site.label" :value="site.value" :disabled="site.disabled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.projectNo">
|
||||
<el-input v-model="form.project_no" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectNo" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="8">
|
||||
<el-form-item :label="TEXT.common.fields.submitDate">
|
||||
<el-date-picker
|
||||
v-model="form.submit_date"
|
||||
:disabled="isReadOnly"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item :label="TEXT.common.fields.acceptDate">
|
||||
<el-date-picker
|
||||
v-model="form.accept_date"
|
||||
:disabled="isReadOnly"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item :label="TEXT.common.fields.approvedDate">
|
||||
<el-date-picker
|
||||
v-model="form.approved_date"
|
||||
:disabled="isReadOnly"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item class="form-actions">
|
||||
<el-button type="primary" :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>
|
||||
</el-form>
|
||||
</section>
|
||||
<section class="unified-section">
|
||||
<template v-if="isEdit && recordId">
|
||||
<div class="ctms-section-header">
|
||||
<div class="ctms-section-title">{{ TEXT.common.labels.attachments }}</div>
|
||||
</div>
|
||||
<AttachmentList
|
||||
:study-id="studyId"
|
||||
entity-type="startup_feasibility"
|
||||
:entity-id="recordId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
</template>
|
||||
<StateEmpty v-else :description="TEXT.modules.startupFeasibilityEthics.feasibilityUploadHint" />
|
||||
</section>
|
||||
</div>
|
||||
<el-card v-else class="hint-card">
|
||||
<StateEmpty :description="TEXT.modules.startupFeasibilityEthics.feasibilityUploadHint" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -206,10 +203,13 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 24px;
|
||||
}
|
||||
.hint-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,143 +1,143 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ TEXT.modules.startupMeetingAuth.kickoffDetailTitle }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.kickoffDetailSubtitle }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button
|
||||
v-if="!editMode"
|
||||
type="primary"
|
||||
:disabled="loading || isReadOnly"
|
||||
@click="startEdit"
|
||||
<div class="unified-shell">
|
||||
<section class="unified-section" v-loading="loading">
|
||||
<div class="unified-section-header">
|
||||
<div class="ctms-section-title">{{ TEXT.modules.startupMeetingAuth.kickoffDetailTitle }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button
|
||||
v-if="!editMode"
|
||||
type="primary"
|
||||
:disabled="loading || isReadOnly"
|
||||
@click="startEdit"
|
||||
>
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button v-else type="primary" :loading="saving" @click="saveEdit">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button v-if="editMode" :disabled="saving" @click="cancelEdit">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item :label="TEXT.common.fields.site">
|
||||
{{ siteInfo.name || TEXT.common.fallback }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.startupMeetingAuth.ownerLabel">
|
||||
{{ ownerLabel }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.status">
|
||||
<el-tag :type="statusTagType">{{ statusLabel }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.kickoffDate">
|
||||
<el-date-picker
|
||||
v-if="editMode"
|
||||
v-model="draft.kickoff_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
/>
|
||||
<span v-else>{{ displayDate(detail.kickoff_date) }}</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</section>
|
||||
|
||||
<section class="unified-section">
|
||||
<div class="training-header">
|
||||
<div class="training-title">{{ TEXT.modules.startupMeetingAuth.trainingTab }}</div>
|
||||
</div>
|
||||
<el-table :data="trainingRows" v-loading="loadingTraining" style="width: 100%" class="ctms-table">
|
||||
<el-table-column prop="name" :label="TEXT.common.fields.name" min-width="140">
|
||||
<template #default="scope">
|
||||
<el-input v-if="isTrainingEditing(scope.row)" v-model="trainingRowDraft.name" size="small" />
|
||||
<span v-else>{{ scope.row.name || TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="role" :label="TEXT.common.labels.role" min-width="120">
|
||||
<template #default="scope">
|
||||
<el-input v-if="isTrainingEditing(scope.row)" v-model="trainingRowDraft.role" size="small" />
|
||||
<span v-else>{{ scope.row.role || TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.trained" width="120">
|
||||
<template #default="scope">
|
||||
<el-checkbox
|
||||
v-if="isTrainingEditing(scope.row)"
|
||||
v-model="trainingRowDraft.trained"
|
||||
:disabled="isReadOnly"
|
||||
@click.stop
|
||||
/>
|
||||
<el-checkbox
|
||||
v-else
|
||||
v-model="scope.row.trained"
|
||||
:disabled="isReadOnly"
|
||||
@change="toggleTraining(scope.row)"
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.authorized" width="120">
|
||||
<template #default="scope">
|
||||
<el-checkbox
|
||||
v-if="isTrainingEditing(scope.row)"
|
||||
v-model="trainingRowDraft.authorized"
|
||||
:disabled="isReadOnly"
|
||||
@click.stop
|
||||
/>
|
||||
<el-checkbox
|
||||
v-else
|
||||
v-model="scope.row.authorized"
|
||||
:disabled="isReadOnly"
|
||||
@change="toggleTraining(scope.row)"
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
||||
<template #default="scope">
|
||||
<template v-if="isTrainingEditing(scope.row)">
|
||||
<el-button link type="primary" size="small" :loading="savingTraining" @click.stop="saveTraining(scope.row)">
|
||||
{{ TEXT.common.actions.save }}
|
||||
</el-button>
|
||||
<el-button link size="small" :disabled="savingTraining" @click.stop="cancelTrainingEdit">
|
||||
{{ TEXT.common.actions.cancel }}
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button link type="primary" size="small" :disabled="isReadOnly" @click.stop="startTrainingEdit(scope.row)">
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button link type="danger" size="small" :disabled="isReadOnly" @click.stop="removeTraining(scope.row)">
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div
|
||||
class="training-add-row"
|
||||
:class="{ disabled: newTrainingActive || savingTraining || isReadOnly }"
|
||||
@click="startTrainingAdd"
|
||||
>
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button v-else type="primary" :loading="saving" @click="saveEdit">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button v-if="editMode" :disabled="saving" @click="cancelEdit">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="training-add-icon">+</span>
|
||||
</div>
|
||||
<StateEmpty
|
||||
v-if="!loadingTraining && trainingItems.length === 0 && !newTrainingActive"
|
||||
:description="TEXT.modules.startupMeetingAuth.emptyTraining"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<el-card v-loading="loading">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item :label="TEXT.common.fields.site">
|
||||
{{ siteInfo.name || TEXT.common.fallback }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.startupMeetingAuth.ownerLabel">
|
||||
{{ ownerLabel }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.status">
|
||||
<el-tag :type="statusTagType">{{ statusLabel }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.kickoffDate">
|
||||
<el-date-picker
|
||||
v-if="editMode"
|
||||
v-model="draft.kickoff_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
<section v-if="meetingId" class="unified-section">
|
||||
<div class="attachment-grid">
|
||||
<AttachmentList
|
||||
v-for="group in kickoffAttachmentGroups"
|
||||
:key="group.entityType"
|
||||
:study-id="studyId"
|
||||
:entity-type="group.entityType"
|
||||
:entity-id="meetingId"
|
||||
:title="group.title"
|
||||
/>
|
||||
<span v-else>{{ displayDate(detail.kickoff_date) }}</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
<div class="training-header">
|
||||
<div class="training-title">{{ TEXT.modules.startupMeetingAuth.trainingTab }}</div>
|
||||
</div>
|
||||
<el-table :data="trainingRows" v-loading="loadingTraining" style="width: 100%" class="ctms-table">
|
||||
<el-table-column prop="name" :label="TEXT.common.fields.name" min-width="140">
|
||||
<template #default="scope">
|
||||
<el-input v-if="isTrainingEditing(scope.row)" v-model="trainingRowDraft.name" size="small" />
|
||||
<span v-else>{{ scope.row.name || TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="role" :label="TEXT.common.labels.role" min-width="120">
|
||||
<template #default="scope">
|
||||
<el-input v-if="isTrainingEditing(scope.row)" v-model="trainingRowDraft.role" size="small" />
|
||||
<span v-else>{{ scope.row.role || TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.trained" width="120">
|
||||
<template #default="scope">
|
||||
<el-checkbox
|
||||
v-if="isTrainingEditing(scope.row)"
|
||||
v-model="trainingRowDraft.trained"
|
||||
:disabled="isReadOnly"
|
||||
@click.stop
|
||||
/>
|
||||
<el-checkbox
|
||||
v-else
|
||||
v-model="scope.row.trained"
|
||||
:disabled="isReadOnly"
|
||||
@change="toggleTraining(scope.row)"
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.authorized" width="120">
|
||||
<template #default="scope">
|
||||
<el-checkbox
|
||||
v-if="isTrainingEditing(scope.row)"
|
||||
v-model="trainingRowDraft.authorized"
|
||||
:disabled="isReadOnly"
|
||||
@click.stop
|
||||
/>
|
||||
<el-checkbox
|
||||
v-else
|
||||
v-model="scope.row.authorized"
|
||||
:disabled="isReadOnly"
|
||||
@change="toggleTraining(scope.row)"
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
||||
<template #default="scope">
|
||||
<template v-if="isTrainingEditing(scope.row)">
|
||||
<el-button link type="primary" size="small" :loading="savingTraining" @click.stop="saveTraining(scope.row)">
|
||||
{{ TEXT.common.actions.save }}
|
||||
</el-button>
|
||||
<el-button link size="small" :disabled="savingTraining" @click.stop="cancelTrainingEdit">
|
||||
{{ TEXT.common.actions.cancel }}
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button link type="primary" size="small" :disabled="isReadOnly" @click.stop="startTrainingEdit(scope.row)">
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button link type="danger" size="small" :disabled="isReadOnly" @click.stop="removeTraining(scope.row)">
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div
|
||||
class="training-add-row"
|
||||
:class="{ disabled: newTrainingActive || savingTraining || isReadOnly }"
|
||||
@click="startTrainingAdd"
|
||||
>
|
||||
<span class="training-add-icon">+</span>
|
||||
</div>
|
||||
<StateEmpty
|
||||
v-if="!loadingTraining && trainingItems.length === 0 && !newTrainingActive"
|
||||
:description="TEXT.modules.startupMeetingAuth.emptyTraining"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<div v-if="meetingId" class="attachment-grid">
|
||||
<AttachmentList
|
||||
v-for="group in kickoffAttachmentGroups"
|
||||
:key="group.entityType"
|
||||
:study-id="studyId"
|
||||
:entity-type="group.entityType"
|
||||
:entity-id="meetingId"
|
||||
:title="group.title"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -447,35 +447,11 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.training-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -1,54 +1,53 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ isEdit ? TEXT.modules.startupMeetingAuth.kickoffEditTitle : TEXT.modules.startupMeetingAuth.kickoffNewTitle }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.kickoffFormSubtitle }}</p>
|
||||
</div>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-card>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item :label="TEXT.common.fields.site">
|
||||
<el-input v-model="siteInfo.name" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.modules.startupMeetingAuth.ownerLabel">
|
||||
<el-input :model-value="ownerLabel" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.kickoffDate">
|
||||
<el-date-picker v-model="form.kickoff_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.attendees">
|
||||
<el-input
|
||||
v-model="form.attendees"
|
||||
:disabled="isReadOnly"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="TEXT.modules.startupMeetingAuth.attendeesPlaceholder"
|
||||
<div class="unified-shell">
|
||||
<section class="unified-section">
|
||||
<div class="unified-section-header">
|
||||
<div class="ctms-section-title">{{ isEdit ? TEXT.modules.startupMeetingAuth.kickoffEditTitle : TEXT.modules.startupMeetingAuth.kickoffNewTitle }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item :label="TEXT.common.fields.site">
|
||||
<el-input v-model="siteInfo.name" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.modules.startupMeetingAuth.ownerLabel">
|
||||
<el-input :model-value="ownerLabel" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.kickoffDate">
|
||||
<el-date-picker v-model="form.kickoff_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.attendees">
|
||||
<el-input
|
||||
v-model="form.attendees"
|
||||
:disabled="isReadOnly"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="TEXT.modules.startupMeetingAuth.attendeesPlaceholder"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :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>
|
||||
</el-form>
|
||||
</section>
|
||||
<section class="unified-section">
|
||||
<div v-if="isEdit && meetingId" class="attachment-grid">
|
||||
<AttachmentList
|
||||
v-for="group in kickoffAttachmentGroups"
|
||||
:key="group.entityType"
|
||||
:study-id="studyId"
|
||||
:entity-type="group.entityType"
|
||||
:entity-id="meetingId"
|
||||
:title="group.title"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :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>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<div v-if="isEdit && meetingId" class="attachment-grid">
|
||||
<AttachmentList
|
||||
v-for="group in kickoffAttachmentGroups"
|
||||
:key="group.entityType"
|
||||
:study-id="studyId"
|
||||
:entity-type="group.entityType"
|
||||
:entity-id="meetingId"
|
||||
:title="group.title"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
</div>
|
||||
<StateEmpty v-else :description="TEXT.modules.startupMeetingAuth.kickoffUploadHint" />
|
||||
</section>
|
||||
</div>
|
||||
<el-card v-else class="hint-card">
|
||||
<StateEmpty :description="TEXT.modules.startupMeetingAuth.kickoffUploadHint" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -200,29 +199,7 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.hint-card {
|
||||
padding: 12px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.attachment-grid {
|
||||
|
||||
@@ -1,36 +1,35 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ TEXT.modules.startupMeetingAuth.trainingDetailTitle }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.trainingDetailSubtitle }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :disabled="isReadOnly" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
<div class="unified-shell">
|
||||
<section class="unified-section" v-loading="loading">
|
||||
<div class="unified-section-header">
|
||||
<div class="ctms-section-title">{{ TEXT.modules.startupMeetingAuth.trainingDetailTitle }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button type="primary" :disabled="isReadOnly" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item :label="TEXT.common.fields.name">{{ detail.name || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.labels.role">{{ displayEnum(TEXT.enums.userRole, detail.role) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.site">{{ detail.site_name || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.trained">{{ detail.trained ? TEXT.common.boolean.yes : TEXT.common.boolean.no }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.trainedDate">{{ displayDate(detail.trained_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.authorized">{{ detail.authorized ? TEXT.common.boolean.yes : TEXT.common.boolean.no }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.authorizedDate">{{ displayDate(detail.authorized_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</section>
|
||||
<section class="unified-section">
|
||||
<AttachmentList
|
||||
v-if="recordId"
|
||||
:study-id="studyId"
|
||||
entity-type="training_authorization"
|
||||
:entity-id="recordId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<el-card v-loading="loading">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item :label="TEXT.common.fields.name">{{ detail.name || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.labels.role">{{ displayEnum(TEXT.enums.userRole, detail.role) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.site">{{ detail.site_name || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.trained">{{ detail.trained ? TEXT.common.boolean.yes : TEXT.common.boolean.no }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.trainedDate">{{ displayDate(detail.trained_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.authorized">{{ detail.authorized ? TEXT.common.boolean.yes : TEXT.common.boolean.no }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.authorizedDate">{{ displayDate(detail.authorized_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<AttachmentList
|
||||
v-if="recordId"
|
||||
:study-id="studyId"
|
||||
entity-type="training_authorization"
|
||||
:entity-id="recordId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -115,29 +114,6 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,56 +1,55 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ isEdit ? TEXT.modules.startupMeetingAuth.trainingEditTitle : TEXT.modules.startupMeetingAuth.trainingNewTitle }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.trainingFormSubtitle }}</p>
|
||||
</div>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
<div class="unified-shell">
|
||||
<section class="unified-section">
|
||||
<div class="unified-section-header">
|
||||
<div class="ctms-section-title">{{ isEdit ? TEXT.modules.startupMeetingAuth.trainingEditTitle : TEXT.modules.startupMeetingAuth.trainingNewTitle }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item :label="TEXT.common.fields.name" required>
|
||||
<el-input v-model="form.name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.labels.role">
|
||||
<el-input v-model="form.role" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.labels.role" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.site">
|
||||
<el-input v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.trained">
|
||||
<el-switch v-model="form.trained" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.trainedDate">
|
||||
<el-date-picker v-model="form.trained_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.authorized">
|
||||
<el-switch v-model="form.authorized" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.authorizedDate">
|
||||
<el-date-picker v-model="form.authorized_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.remark">
|
||||
<el-input v-model="form.remark" :disabled="isReadOnly" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :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>
|
||||
</el-form>
|
||||
</section>
|
||||
<section class="unified-section">
|
||||
<AttachmentList
|
||||
v-if="isEdit && recordId"
|
||||
:study-id="studyId"
|
||||
entity-type="training_authorization"
|
||||
:entity-id="recordId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
<StateEmpty v-else :description="TEXT.modules.startupMeetingAuth.trainingUploadHint" />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<el-card>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item :label="TEXT.common.fields.name" required>
|
||||
<el-input v-model="form.name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.labels.role">
|
||||
<el-input v-model="form.role" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.labels.role" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.site">
|
||||
<el-input v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.trained">
|
||||
<el-switch v-model="form.trained" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.trainedDate">
|
||||
<el-date-picker v-model="form.trained_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.authorized">
|
||||
<el-switch v-model="form.authorized" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.authorizedDate">
|
||||
<el-date-picker v-model="form.authorized_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.remark">
|
||||
<el-input v-model="form.remark" :disabled="isReadOnly" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :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>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<AttachmentList
|
||||
v-if="isEdit && recordId"
|
||||
:study-id="studyId"
|
||||
entity-type="training_authorization"
|
||||
:entity-id="recordId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
<el-card v-else class="hint-card">
|
||||
<StateEmpty :description="TEXT.modules.startupMeetingAuth.trainingUploadHint" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -174,28 +173,6 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.hint-card {
|
||||
padding: 12px;
|
||||
gap: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,31 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header unified-action-bar">
|
||||
<div>
|
||||
<h1 class="page-title">{{ detail.subject_no || TEXT.modules.subjectManagement.screeningNo }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.subjectManagement.screeningNo }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button
|
||||
v-if="subjectEditing"
|
||||
type="primary"
|
||||
:loading="subjectSaving"
|
||||
:disabled="isReadOnly"
|
||||
@click="saveSubjectEdit"
|
||||
>
|
||||
{{ TEXT.common.actions.save }}
|
||||
</el-button>
|
||||
<el-button v-if="subjectEditing" :disabled="isReadOnly" @click="cancelSubjectEdit">
|
||||
{{ TEXT.common.actions.cancel }}
|
||||
</el-button>
|
||||
<el-button v-else type="primary" :disabled="isReadOnly" @click="startSubjectEdit">
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card v-loading="loading">
|
||||
<el-card class="unified-shell" v-loading="loading">
|
||||
<el-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>
|
||||
@@ -52,7 +76,7 @@
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
<el-card class="unified-shell">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.history" name="history">
|
||||
<div class="tab-actions">
|
||||
@@ -182,17 +206,17 @@
|
||||
<el-table-column prop="onset_date" :label="TEXT.common.fields.occurDate" width="140">
|
||||
<template #default="scope">{{ displayDate(scope.row.onset_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.aeType" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag size="small" effect="light" :class="getAeTypeClass(scope.row)">
|
||||
{{ getAeTypeLabel(scope.row) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="term" :label="TEXT.common.fields.title" min-width="140" />
|
||||
<el-table-column prop="seriousness" :label="TEXT.common.fields.seriousness" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag
|
||||
:type="getAeSeriousnessType(normalizeSeriousness(scope.row.seriousness))"
|
||||
size="small"
|
||||
effect="light"
|
||||
class="seriousness-tag"
|
||||
>
|
||||
{{ displayEnum(TEXT.enums.aeSeriousness, normalizeSeriousness(scope.row.seriousness)) }}
|
||||
</el-tag>
|
||||
{{ displayEnum(TEXT.enums.aeSeriousness, normalizeSeriousness(scope.row.seriousness)) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="causality" :label="TEXT.common.fields.causality" width="120">
|
||||
@@ -227,6 +251,10 @@
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loadingAes && aeItems.length === 0" :description="TEXT.modules.subjectDetail.emptyAe" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.pd" name="pd">
|
||||
<StateEmpty :description="TEXT.modules.subjectDetail.emptyPd" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
@@ -289,6 +317,12 @@
|
||||
<el-option v-for="option in aeSeriousnessOptions" :key="option.value" :label="option.label" :value="option.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.isSae">
|
||||
<el-switch v-model="aeForm.is_sae" @change="onSaeChanged" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.isSusar">
|
||||
<el-switch v-model="aeForm.is_susar" @change="onSusarChanged" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.causality">
|
||||
<el-select v-model="aeForm.causality" clearable>
|
||||
<el-option v-for="option in aeCausalityOptions" :key="option.value" :label="option.label" :value="option.value" />
|
||||
@@ -312,7 +346,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
@@ -330,6 +364,10 @@ const router = useRouter();
|
||||
const study = useStudyStore();
|
||||
const subjectId = route.params.subjectId as string;
|
||||
const studyId = study.currentStudy?.id || "";
|
||||
const resolveTabFromQuery = () => {
|
||||
const tab = typeof route.query.tab === "string" ? route.query.tab : "";
|
||||
return ["history", "visits", "ae", "pd"].includes(tab) ? tab : "history";
|
||||
};
|
||||
|
||||
const loading = ref(false);
|
||||
const detail = reactive<any>({
|
||||
@@ -356,7 +394,7 @@ const subjectForm = reactive({
|
||||
const siteMap = ref<Record<string, string>>({});
|
||||
const siteActiveMap = ref<Record<string, boolean>>({});
|
||||
const isReadOnly = computed(() => !!detail.site_id && siteActiveMap.value[detail.site_id] === false);
|
||||
const activeTab = ref("history");
|
||||
const activeTab = ref(resolveTabFromQuery());
|
||||
|
||||
const historyItems = ref<any[]>([]);
|
||||
const loadingHistory = ref(false);
|
||||
@@ -410,6 +448,8 @@ const aeForm = reactive({
|
||||
term: "",
|
||||
onset_date: "",
|
||||
seriousness: "I",
|
||||
is_sae: false,
|
||||
is_susar: false,
|
||||
causality: "",
|
||||
outcome: "",
|
||||
description: "",
|
||||
@@ -714,19 +754,25 @@ const getVisitStatusTagType = (status: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getAeSeriousnessType = (seriousness: string) => {
|
||||
switch (seriousness) {
|
||||
case "I":
|
||||
return "info";
|
||||
case "II":
|
||||
return "success";
|
||||
case "III":
|
||||
return "warning";
|
||||
case "IV":
|
||||
case "V":
|
||||
return "danger";
|
||||
default:
|
||||
return "info";
|
||||
const getAeTypeKey = (row: any) => {
|
||||
if (row?.is_susar) return "susar";
|
||||
if (row?.is_sae) return "sae";
|
||||
return "ae";
|
||||
};
|
||||
|
||||
const getAeTypeLabel = (row: any) => TEXT.modules.subjectDetail.aeType[getAeTypeKey(row)];
|
||||
|
||||
const getAeTypeClass = (row: any) => `ae-type-tag ae-type-${getAeTypeKey(row)}`;
|
||||
|
||||
const onSaeChanged = (value: boolean) => {
|
||||
if (!value) {
|
||||
aeForm.is_susar = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onSusarChanged = (value: boolean) => {
|
||||
if (value) {
|
||||
aeForm.is_sae = true;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -803,6 +849,8 @@ const openAeDialog = (row?: any) => {
|
||||
term: row?.term || "",
|
||||
onset_date: row?.onset_date || "",
|
||||
seriousness: normalizeSeriousness(row?.seriousness),
|
||||
is_sae: !!row?.is_sae || !!row?.is_susar,
|
||||
is_susar: !!row?.is_susar,
|
||||
causality: row?.causality || "",
|
||||
outcome: row?.outcome || "",
|
||||
description: row?.description || "",
|
||||
@@ -823,6 +871,8 @@ const saveAe = async () => {
|
||||
term: aeForm.term,
|
||||
onset_date: aeForm.onset_date || null,
|
||||
seriousness: aeForm.seriousness,
|
||||
is_sae: !!aeForm.is_sae || !!aeForm.is_susar,
|
||||
is_susar: !!aeForm.is_susar,
|
||||
causality: aeForm.causality || null,
|
||||
outcome: aeForm.outcome || null,
|
||||
description: aeForm.description || null,
|
||||
@@ -857,6 +907,13 @@ const removeAe = async (row: any) => {
|
||||
|
||||
const goBack = () => router.push("/subjects");
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
() => {
|
||||
activeTab.value = resolveTabFromQuery();
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSites();
|
||||
await loadSubject();
|
||||
@@ -901,4 +958,23 @@ onMounted(async () => {
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ae-type-tag {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.ae-type-ae {
|
||||
color: #ffffff;
|
||||
background-color: #3b82f6;
|
||||
}
|
||||
|
||||
.ae-type-sae {
|
||||
color: #ffffff;
|
||||
background-color: #ef4444;
|
||||
}
|
||||
|
||||
.ae-type-susar {
|
||||
color: #ffffff;
|
||||
background-color: #8b5cf6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<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>
|
||||
@@ -8,7 +8,7 @@
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-card>
|
||||
<el-card class="unified-shell">
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item :label="TEXT.modules.subjectManagement.screeningNo" required>
|
||||
<el-input
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-card shadow="never" class="todo-card">
|
||||
<el-card shadow="never" class="todo-card unified-shell">
|
||||
<template #header>
|
||||
<div class="section-header">
|
||||
<div class="header-left">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-card shadow="never" class="section-card">
|
||||
<el-card shadow="never" class="section-card unified-shell">
|
||||
<template #header>
|
||||
<div class="section-header">
|
||||
<div class="section-title-wrapper">
|
||||
|
||||
Reference in New Issue
Block a user