立项配置页初步优化

This commit is contained in:
Cheng Zhou
2026-02-24 16:53:22 +08:00
parent 0693f09b1d
commit 8f3f717e48
124 changed files with 12519 additions and 2954 deletions
+3 -2
View File
@@ -3,9 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," />
<title>CTMS</title>
<script type="module" crossorigin src="/assets/index-CsWv41CQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B7Ge_Ohp.css">
<script type="module" crossorigin src="/assets/index-DNEAoWP5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-J1iXJzyy.css">
</head>
<body>
<div id="app"></div>
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," />
<title>CTMS</title>
</head>
<body>
+18 -4
View File
@@ -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);
+31 -1
View File
@@ -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 });
+94 -40
View File
@@ -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;
}
/* 过渡动画 */
+84 -5
View File
@@ -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,
};
};
+90 -2
View File
@@ -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: "结果",
+1
View File
@@ -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";
+75 -13
View File
@@ -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) {
+92 -16
View File
@@ -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();
};
+8
View File
@@ -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;
};
+9 -1
View File
@@ -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,
+181
View File
@@ -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;
}
}
+21
View File
@@ -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;
}
+116
View File
@@ -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;
}
+155
View File
@@ -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),
};
});
};
+33
View File
@@ -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
View File
@@ -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>
+3 -3
View File
@@ -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>
+11 -1
View File
@@ -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 -1
View File
@@ -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">
+2 -2
View File
@@ -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>
+48 -49
View File
@@ -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>
+71 -55
View File
@@ -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
+3 -35
View File
@@ -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);
}
+66 -80
View File
@@ -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 {
+63 -96
View File
@@ -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;
+1 -8
View File
@@ -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) {
+103 -69
View File
@@ -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;
+60 -67
View File
@@ -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 {
+57 -41
View File
@@ -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">&nbsp;</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);
}
+66 -64
View File
@@ -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>
+31 -60
View File
@@ -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>
+102 -125
View File
@@ -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 {
+20 -34
View File
@@ -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;
+7 -42
View File
@@ -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);
+94 -93
View File
@@ -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 {
+12 -42
View File
@@ -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;
+83 -73
View File
@@ -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 {
+29 -53
View File
@@ -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>
+48 -71
View File
@@ -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>
+28 -52
View File
@@ -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>
+49 -72
View File
@@ -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>
+62 -69
View File
@@ -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 {
+13
View File
@@ -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>
+48 -74
View File
@@ -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;
}
+53 -79
View File
@@ -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>
+37 -44
View File
@@ -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>
+13
View File
@@ -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>
+42 -85
View File
@@ -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>
+195
View File
@@ -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>
+251
View File
@@ -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>
+13
View File
@@ -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>
+5 -22
View File
@@ -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>
+89 -83
View File
@@ -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>
+2 -2
View File
@@ -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>
+3 -3
View File
@@ -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>
+41 -38
View File
@@ -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>
+104 -104
View File
@@ -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>
+92 -92
View File
@@ -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>
+136 -160
View File
@@ -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;
}
+47 -70
View File
@@ -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 {
+30 -54
View File
@@ -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>
+50 -73
View File
@@ -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>
+101 -25
View File
@@ -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>
+2 -2
View File
@@ -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">